@longsightgroup/qti3-core 0.9.1 → 0.9.3
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/README.md +59 -3
- package/dist/adaptive-turn-materializer.d.ts +14 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -0
- package/dist/adaptive-turn-materializer.js +56 -0
- package/dist/adaptive-turn-materializer.js.map +1 -0
- package/dist/adaptive-turn.d.ts +23 -0
- package/dist/adaptive-turn.d.ts.map +1 -0
- package/dist/adaptive-turn.js +60 -0
- package/dist/adaptive-turn.js.map +1 -0
- package/dist/assert-never.d.ts +3 -0
- package/dist/assert-never.d.ts.map +1 -0
- package/dist/assert-never.js +5 -0
- package/dist/assert-never.js.map +1 -0
- package/dist/attempt-state-constants.d.ts +6 -0
- package/dist/attempt-state-constants.d.ts.map +1 -0
- package/dist/attempt-state-constants.js +6 -0
- package/dist/attempt-state-constants.js.map +1 -0
- package/dist/attempt-state.d.ts +5 -0
- package/dist/attempt-state.d.ts.map +1 -0
- package/dist/attempt-state.js +190 -0
- package/dist/attempt-state.js.map +1 -0
- package/dist/catalog.js +1 -1
- package/dist/catalog.js.map +1 -1
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +3 -0
- package/dist/content-text.js.map +1 -1
- package/dist/custom-operators.d.ts +13 -0
- package/dist/custom-operators.d.ts.map +1 -0
- package/dist/custom-operators.js +2 -0
- package/dist/custom-operators.js.map +1 -0
- package/dist/delivery-redaction.d.ts +50 -0
- package/dist/delivery-redaction.d.ts.map +1 -0
- package/dist/delivery-redaction.js +176 -0
- package/dist/delivery-redaction.js.map +1 -0
- package/dist/delivery-security.d.ts +3 -18
- package/dist/delivery-security.d.ts.map +1 -1
- package/dist/delivery-security.js +35 -189
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/parser-custom-interactions.js +1 -1
- package/dist/parser-custom-interactions.js.map +1 -1
- package/dist/parser-declarations.d.ts.map +1 -1
- package/dist/parser-declarations.js +4 -4
- package/dist/parser-declarations.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +8 -7
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-processing.js +2 -2
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts +5 -1
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +33 -0
- package/dist/parser-values.js.map +1 -1
- package/dist/processing-evaluator-boolean.d.ts +8 -0
- package/dist/processing-evaluator-boolean.d.ts.map +1 -0
- package/dist/processing-evaluator-boolean.js +47 -0
- package/dist/processing-evaluator-boolean.js.map +1 -0
- package/dist/processing-evaluator-collection.d.ts +12 -0
- package/dist/processing-evaluator-collection.d.ts.map +1 -0
- package/dist/processing-evaluator-collection.js +35 -0
- package/dist/processing-evaluator-collection.js.map +1 -0
- package/dist/processing-evaluator-comparison.d.ts +8 -0
- package/dist/processing-evaluator-comparison.d.ts.map +1 -0
- package/dist/processing-evaluator-comparison.js +88 -0
- package/dist/processing-evaluator-comparison.js.map +1 -0
- package/dist/processing-evaluator-custom.d.ts +8 -0
- package/dist/processing-evaluator-custom.d.ts.map +1 -0
- package/dist/processing-evaluator-custom.js +14 -0
- package/dist/processing-evaluator-custom.js.map +1 -0
- package/dist/processing-evaluator-geometry.d.ts +8 -0
- package/dist/processing-evaluator-geometry.d.ts.map +1 -0
- package/dist/processing-evaluator-geometry.js +21 -0
- package/dist/processing-evaluator-geometry.js.map +1 -0
- package/dist/processing-evaluator-numeric.d.ts +8 -0
- package/dist/processing-evaluator-numeric.d.ts.map +1 -0
- package/dist/processing-evaluator-numeric.js +112 -0
- package/dist/processing-evaluator-numeric.js.map +1 -0
- package/dist/processing-evaluator-string.d.ts +8 -0
- package/dist/processing-evaluator-string.d.ts.map +1 -0
- package/dist/processing-evaluator-string.js +33 -0
- package/dist/processing-evaluator-string.js.map +1 -0
- package/dist/processing-evaluator-variable.d.ts +8 -0
- package/dist/processing-evaluator-variable.d.ts.map +1 -0
- package/dist/processing-evaluator-variable.js +39 -0
- package/dist/processing-evaluator-variable.js.map +1 -0
- package/dist/processing-evaluator.d.ts +22 -0
- package/dist/processing-evaluator.d.ts.map +1 -0
- package/dist/processing-evaluator.js +137 -0
- package/dist/processing-evaluator.js.map +1 -0
- package/dist/processing-expression-children.d.ts +3 -0
- package/dist/processing-expression-children.d.ts.map +1 -0
- package/dist/processing-expression-children.js +68 -0
- package/dist/processing-expression-children.js.map +1 -0
- package/dist/processing-mapping.d.ts +13 -0
- package/dist/processing-mapping.d.ts.map +1 -0
- package/dist/processing-mapping.js +120 -0
- package/dist/processing-mapping.js.map +1 -0
- package/dist/processing-operators.d.ts +11 -0
- package/dist/processing-operators.d.ts.map +1 -0
- package/dist/processing-operators.js +178 -0
- package/dist/processing-operators.js.map +1 -0
- package/dist/processing-random.d.ts +2 -0
- package/dist/processing-random.d.ts.map +1 -0
- package/dist/processing-random.js +16 -0
- package/dist/processing-random.js.map +1 -0
- package/dist/processing-state.d.ts +8 -0
- package/dist/processing-state.d.ts.map +1 -0
- package/dist/processing-state.js +44 -0
- package/dist/processing-state.js.map +1 -0
- package/dist/processing-templates.d.ts +3 -0
- package/dist/processing-templates.d.ts.map +1 -0
- package/dist/processing-templates.js +12 -0
- package/dist/processing-templates.js.map +1 -0
- package/dist/processing-values.d.ts +15 -0
- package/dist/processing-values.d.ts.map +1 -0
- package/dist/processing-values.js +150 -0
- package/dist/processing-values.js.map +1 -0
- package/dist/processing-variables.d.ts +7 -0
- package/dist/processing-variables.d.ts.map +1 -0
- package/dist/processing-variables.js +40 -0
- package/dist/processing-variables.js.map +1 -0
- package/dist/serializer-processing-expressions.d.ts.map +1 -1
- package/dist/serializer-processing-expressions.js +2 -8
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/serializer-processing-xml.js +2 -2
- package/dist/serializer-processing-xml.js.map +1 -1
- package/dist/serializer-processing.d.ts.map +1 -1
- package/dist/serializer-processing.js +3 -0
- package/dist/serializer-processing.js.map +1 -1
- package/dist/server-scoring.d.ts +4 -14
- package/dist/server-scoring.d.ts.map +1 -1
- package/dist/server-scoring.js +19 -157
- package/dist/server-scoring.js.map +1 -1
- package/dist/session.d.ts +5 -14
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +76 -1158
- package/dist/session.js.map +1 -1
- package/dist/shared-vocabulary-coverage-policy.js +1 -1
- package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
- package/dist/shared-vocabulary-generated-families.js +1 -1
- package/dist/shared-vocabulary-generated-families.js.map +1 -1
- package/dist/shared-vocabulary-support.js +3 -3
- package/dist/shared-vocabulary-support.js.map +1 -1
- package/dist/shared-vocabulary.d.ts.map +1 -1
- package/dist/shared-vocabulary.js +4 -3
- package/dist/shared-vocabulary.js.map +1 -1
- package/dist/support-evidence.d.ts +22 -0
- package/dist/support-evidence.d.ts.map +1 -0
- package/dist/support-evidence.js +93 -0
- package/dist/support-evidence.js.map +1 -0
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +38 -47
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts +49 -0
- package/dist/trusted-item-session.d.ts.map +1 -0
- package/dist/trusted-item-session.js +261 -0
- package/dist/trusted-item-session.js.map +1 -0
- package/dist/tts.d.ts.map +1 -1
- package/dist/tts.js +16 -2
- package/dist/tts.js.map +1 -1
- package/dist/validation-geometry.d.ts +8 -0
- package/dist/validation-geometry.d.ts.map +1 -0
- package/dist/validation-geometry.js +187 -0
- package/dist/validation-geometry.js.map +1 -0
- package/dist/validation-interactions.d.ts +3 -0
- package/dist/validation-interactions.d.ts.map +1 -0
- package/dist/validation-interactions.js +644 -0
- package/dist/validation-interactions.js.map +1 -0
- package/dist/validation-primitives.d.ts +11 -0
- package/dist/validation-primitives.d.ts.map +1 -0
- package/dist/validation-primitives.js +48 -0
- package/dist/validation-primitives.js.map +1 -0
- package/dist/validation-processing.d.ts +4 -0
- package/dist/validation-processing.d.ts.map +1 -0
- package/dist/validation-processing.js +569 -0
- package/dist/validation-processing.js.map +1 -0
- package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
- package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
- package/dist/validation-shared-vocabulary-layout.js +102 -0
- package/dist/validation-shared-vocabulary-layout.js.map +1 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +27 -1641
- package/dist/validation.js.map +1 -1
- package/dist/xml.js +1 -1
- package/dist/xml.js.map +1 -1
- package/package.json +4 -2
- package/src/adaptive-turn-materializer.ts +85 -0
- package/src/adaptive-turn.ts +109 -0
- package/src/assert-never.ts +4 -0
- package/src/attempt-state-constants.ts +5 -0
- package/src/attempt-state.ts +245 -0
- package/src/catalog.ts +1 -1
- package/src/content-text.ts +3 -0
- package/src/custom-operators.ts +12 -0
- package/src/delivery-redaction.ts +268 -0
- package/src/delivery-security.ts +59 -245
- package/src/index.ts +9 -0
- package/src/parser-custom-interactions.ts +1 -1
- package/src/parser-declarations.ts +4 -3
- package/src/parser-item-metadata.ts +8 -7
- package/src/parser-processing.ts +2 -2
- package/src/parser-values.ts +36 -0
- package/src/processing-evaluator-boolean.ts +48 -0
- package/src/processing-evaluator-collection.ts +50 -0
- package/src/processing-evaluator-comparison.ts +106 -0
- package/src/processing-evaluator-custom.ts +20 -0
- package/src/processing-evaluator-geometry.ts +26 -0
- package/src/processing-evaluator-numeric.ts +155 -0
- package/src/processing-evaluator-string.ts +60 -0
- package/src/processing-evaluator-variable.ts +81 -0
- package/src/processing-evaluator.ts +173 -0
- package/src/processing-expression-children.ts +69 -0
- package/src/processing-mapping.ts +153 -0
- package/src/processing-operators.ts +184 -0
- package/src/processing-random.ts +16 -0
- package/src/processing-state.ts +69 -0
- package/src/processing-templates.ts +12 -0
- package/src/processing-values.ts +162 -0
- package/src/processing-variables.ts +83 -0
- package/src/serializer-processing-expressions.ts +2 -12
- package/src/serializer-processing-xml.ts +2 -2
- package/src/serializer-processing.ts +3 -0
- package/src/server-scoring.ts +30 -226
- package/src/session.ts +128 -2006
- package/src/shared-vocabulary-coverage-policy.ts +1 -1
- package/src/shared-vocabulary-generated-families.ts +1 -1
- package/src/shared-vocabulary-support.ts +3 -3
- package/src/shared-vocabulary.ts +3 -2
- package/src/support-evidence.ts +107 -0
- package/src/support.ts +68 -54
- package/src/trusted-item-session.ts +429 -0
- package/src/tts.ts +29 -15
- package/src/validation-geometry.ts +204 -0
- package/src/validation-interactions.ts +831 -0
- package/src/validation-primitives.ts +63 -0
- package/src/validation-processing.ts +785 -0
- package/src/validation-shared-vocabulary-layout.ts +117 -0
- package/src/validation.ts +44 -2077
- package/src/xml.ts +1 -1
- package/src/interaction-test-fixtures.ts +0 -44
- package/src/serializer-processing.fixtures.ts +0 -469
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { QtiDocument, QtiResponseDeclaration, QtiValue } from "./types.js";
|
|
2
|
+
import { qtiScalarToString, qtiValueToStringList } from "./value-format.js";
|
|
3
|
+
import { isRecordValue, numericValue, valuesEqual } from "./processing-values.js";
|
|
4
|
+
|
|
5
|
+
export function lookupOutcomeValue(
|
|
6
|
+
document: QtiDocument,
|
|
7
|
+
identifier: string,
|
|
8
|
+
value: QtiValue,
|
|
9
|
+
): QtiValue {
|
|
10
|
+
const declaration = document.item.outcomeDeclarations.find(
|
|
11
|
+
(outcome) => outcome.identifier === identifier,
|
|
12
|
+
);
|
|
13
|
+
const lookupTable = declaration?.lookupTable;
|
|
14
|
+
if (!lookupTable) return null;
|
|
15
|
+
if (value === null) return lookupTable.defaultValue;
|
|
16
|
+
const numeric = numericValue(value);
|
|
17
|
+
if (lookupTable.type === "match") {
|
|
18
|
+
return (
|
|
19
|
+
lookupTable.entries.find((entry) => entry.sourceValue === numeric)?.targetValue ??
|
|
20
|
+
lookupTable.defaultValue
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const entry = [...lookupTable.entries]
|
|
24
|
+
.toSorted((left, right) => left.sourceValue - right.sourceValue)
|
|
25
|
+
.find(
|
|
26
|
+
(candidate) =>
|
|
27
|
+
numeric < candidate.sourceValue ||
|
|
28
|
+
(candidate.includeBoundary !== false && numeric === candidate.sourceValue),
|
|
29
|
+
);
|
|
30
|
+
return entry?.targetValue ?? lookupTable.defaultValue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mapOrMatchResponse(
|
|
34
|
+
declaration: QtiResponseDeclaration,
|
|
35
|
+
response: QtiValue,
|
|
36
|
+
correctResponse: QtiValue,
|
|
37
|
+
): number {
|
|
38
|
+
if (declaration.areaMapping) return scoreAreaMapping(response, declaration.areaMapping);
|
|
39
|
+
if (declaration.mapping) return scoreMapping(response, declaration.mapping);
|
|
40
|
+
return valuesEqual(response, correctResponse, declaration.cardinality === "ordered") ? 1 : 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function scoreAreaMapping(
|
|
44
|
+
response: QtiValue,
|
|
45
|
+
areaMapping: NonNullable<QtiResponseDeclaration["areaMapping"]>,
|
|
46
|
+
): number {
|
|
47
|
+
const points = Array.isArray(response)
|
|
48
|
+
? response.map(qtiScalarToString)
|
|
49
|
+
: response === null
|
|
50
|
+
? []
|
|
51
|
+
: qtiValueToStringList(response);
|
|
52
|
+
let score = 0;
|
|
53
|
+
for (const point of points) {
|
|
54
|
+
const parsed = parsePoint(point);
|
|
55
|
+
if (!parsed) {
|
|
56
|
+
score += areaMapping.defaultValue;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const entry = areaMapping.entries.find((candidate) => pointInsideArea(parsed, candidate));
|
|
60
|
+
score += entry?.mappedValue ?? areaMapping.defaultValue;
|
|
61
|
+
}
|
|
62
|
+
return clampMappedScore(score, areaMapping.attributes);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parsePoint(value: string): { x: number; y: number } | undefined {
|
|
66
|
+
const [x, y] = value
|
|
67
|
+
.trim()
|
|
68
|
+
.split(/[,\s]+/)
|
|
69
|
+
.map((part) => Number(part));
|
|
70
|
+
if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return { x, y };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pointInsideArea(
|
|
77
|
+
point: { x: number; y: number },
|
|
78
|
+
entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
|
|
79
|
+
): boolean {
|
|
80
|
+
if (entry.shape === "circle") {
|
|
81
|
+
const [cx, cy, radius] = entry.coords;
|
|
82
|
+
if (cx === undefined || cy === undefined || radius === undefined) return false;
|
|
83
|
+
return Math.hypot(point.x - cx, point.y - cy) <= radius;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.shape === "rect") {
|
|
87
|
+
const [left, top, right, bottom] = entry.coords;
|
|
88
|
+
if (left === undefined || top === undefined || right === undefined || bottom === undefined) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (entry.shape === "poly") {
|
|
95
|
+
return pointInsidePolygon(point, entry.coords);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pointInsidePolygon(point: { x: number; y: number }, coords: number[]): boolean {
|
|
102
|
+
if (coords.length < 6 || coords.length % 2 !== 0) return false;
|
|
103
|
+
let inside = false;
|
|
104
|
+
for (let index = 0, previous = coords.length - 2; index < coords.length; index += 2) {
|
|
105
|
+
const xi = coords[index]!;
|
|
106
|
+
const yi = coords[index + 1]!;
|
|
107
|
+
const xj = coords[previous]!;
|
|
108
|
+
const yj = coords[previous + 1]!;
|
|
109
|
+
const intersects =
|
|
110
|
+
yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
|
|
111
|
+
if (intersects) inside = !inside;
|
|
112
|
+
previous = index;
|
|
113
|
+
}
|
|
114
|
+
return inside;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scoreMapping(
|
|
118
|
+
response: QtiValue,
|
|
119
|
+
mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
|
|
120
|
+
): number {
|
|
121
|
+
const values = Object.fromEntries(
|
|
122
|
+
mapping.entries
|
|
123
|
+
.filter((entry) => entry.mapKey !== undefined)
|
|
124
|
+
.map((entry) => [entry.mapKey!, entry.mappedValue]),
|
|
125
|
+
);
|
|
126
|
+
if (Array.isArray(response)) {
|
|
127
|
+
const score = response.reduce<number>(
|
|
128
|
+
(sum, value) => sum + (values[String(value)] ?? mapping.defaultValue),
|
|
129
|
+
0,
|
|
130
|
+
);
|
|
131
|
+
return clampMappedScore(score, mapping.attributes);
|
|
132
|
+
}
|
|
133
|
+
const score =
|
|
134
|
+
response === null || isRecordValue(response)
|
|
135
|
+
? 0
|
|
136
|
+
: (values[String(response)] ?? mapping.defaultValue);
|
|
137
|
+
return clampMappedScore(score, mapping.attributes);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clampMappedScore(score: number, attributes: Record<string, string>): number {
|
|
141
|
+
const lower = numericBound(attributes["lower-bound"]);
|
|
142
|
+
const upper = numericBound(attributes["upper-bound"]);
|
|
143
|
+
let clamped = score;
|
|
144
|
+
if (lower !== undefined) clamped = Math.max(clamped, lower);
|
|
145
|
+
if (upper !== undefined) clamped = Math.min(clamped, upper);
|
|
146
|
+
return clamped;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function numericBound(value: string | undefined): number | undefined {
|
|
150
|
+
if (value === undefined) return undefined;
|
|
151
|
+
const numberValue = Number(value);
|
|
152
|
+
return Number.isFinite(numberValue) ? numberValue : undefined;
|
|
153
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { QtiValue } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const MATH_OPERATOR_NAMES = [
|
|
4
|
+
"abs",
|
|
5
|
+
"acos",
|
|
6
|
+
"acot",
|
|
7
|
+
"acsc",
|
|
8
|
+
"asec",
|
|
9
|
+
"asin",
|
|
10
|
+
"atan",
|
|
11
|
+
"atan2",
|
|
12
|
+
"ceil",
|
|
13
|
+
"cos",
|
|
14
|
+
"cosh",
|
|
15
|
+
"cot",
|
|
16
|
+
"coth",
|
|
17
|
+
"csc",
|
|
18
|
+
"csch",
|
|
19
|
+
"exp",
|
|
20
|
+
"floor",
|
|
21
|
+
"ln",
|
|
22
|
+
"log",
|
|
23
|
+
"sec",
|
|
24
|
+
"sech",
|
|
25
|
+
"signum",
|
|
26
|
+
"sin",
|
|
27
|
+
"sinh",
|
|
28
|
+
"tan",
|
|
29
|
+
"tanh",
|
|
30
|
+
"toDegrees",
|
|
31
|
+
"toRadians",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const STATS_OPERATOR_NAMES = [
|
|
35
|
+
"mean",
|
|
36
|
+
"sampleVariance",
|
|
37
|
+
"sampleSD",
|
|
38
|
+
"popVariance",
|
|
39
|
+
"popSD",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export function roundToDecimalPlaces(value: number, figures: number): number {
|
|
43
|
+
const factor = 10 ** figures;
|
|
44
|
+
return Math.round(value * factor) / factor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function roundToSignificantFigures(value: number, figures: number): number {
|
|
48
|
+
if (value === 0 || figures <= 0) return 0;
|
|
49
|
+
const factor = 10 ** (figures - 1 - Math.floor(Math.log10(Math.abs(value))));
|
|
50
|
+
return Math.round(value * factor) / factor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function roundWithMode(value: number, roundingMode: string, figures: number): number | null {
|
|
54
|
+
if (!Number.isFinite(value)) return null;
|
|
55
|
+
if (roundingMode === "decimalPlaces") return roundToDecimalPlaces(value, figures);
|
|
56
|
+
if (roundingMode === "significantFigures") return roundToSignificantFigures(value, figures);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function generalizedGcd(values: number[]): number {
|
|
61
|
+
let result = 0;
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
result = gcd(result, Math.abs(value));
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function generalizedLcm(values: number[]): number {
|
|
69
|
+
if (values.some((value) => value === 0)) return 0;
|
|
70
|
+
return values.reduce((result, value) => lcm(result, Math.abs(value)), 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gcd(left: number, right: number): number {
|
|
74
|
+
let a = Math.abs(left);
|
|
75
|
+
let b = Math.abs(right);
|
|
76
|
+
while (b !== 0) {
|
|
77
|
+
const next = a % b;
|
|
78
|
+
a = b;
|
|
79
|
+
b = next;
|
|
80
|
+
}
|
|
81
|
+
return a;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lcm(left: number, right: number): number {
|
|
85
|
+
return Math.abs(left * right) / gcd(left, right);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mathOperatorValue(name: string, values: number[]): QtiValue {
|
|
89
|
+
const [first, second] = values;
|
|
90
|
+
if (first === undefined) return null;
|
|
91
|
+
switch (name) {
|
|
92
|
+
case "abs":
|
|
93
|
+
return finiteOrNull(Math.abs(first));
|
|
94
|
+
case "acos":
|
|
95
|
+
return Math.abs(first) > 1 ? null : finiteOrNull(Math.acos(first));
|
|
96
|
+
case "acot":
|
|
97
|
+
return finiteOrNull(Math.PI / 2 - Math.atan(1 / first));
|
|
98
|
+
case "acsc":
|
|
99
|
+
return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.asin(1 / first));
|
|
100
|
+
case "asec":
|
|
101
|
+
return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.acos(1 / first));
|
|
102
|
+
case "asin":
|
|
103
|
+
return Math.abs(first) > 1 ? null : finiteOrNull(Math.asin(first));
|
|
104
|
+
case "atan":
|
|
105
|
+
return finiteOrNull(Math.atan(first));
|
|
106
|
+
case "atan2":
|
|
107
|
+
return second === undefined ? null : finiteOrNull(Math.atan2(first, second));
|
|
108
|
+
case "ceil":
|
|
109
|
+
return finiteOrNull(Math.ceil(first));
|
|
110
|
+
case "cos":
|
|
111
|
+
return finiteOrNull(Math.cos(first));
|
|
112
|
+
case "cosh":
|
|
113
|
+
return finiteOrNull(Math.cosh(first));
|
|
114
|
+
case "cot":
|
|
115
|
+
return finiteOrNull(1 / Math.tan(first));
|
|
116
|
+
case "coth":
|
|
117
|
+
return finiteOrNull(1 / Math.tanh(first));
|
|
118
|
+
case "csc":
|
|
119
|
+
return finiteOrNull(1 / Math.sin(first));
|
|
120
|
+
case "csch":
|
|
121
|
+
return finiteOrNull(1 / Math.sinh(first));
|
|
122
|
+
case "exp":
|
|
123
|
+
return finiteOrNull(Math.exp(first));
|
|
124
|
+
case "floor":
|
|
125
|
+
return finiteOrNull(Math.floor(first));
|
|
126
|
+
case "ln":
|
|
127
|
+
return first < 0 ? null : finiteOrNull(Math.log(first));
|
|
128
|
+
case "log":
|
|
129
|
+
return first < 0 ? null : finiteOrNull(Math.log10(first));
|
|
130
|
+
case "sec":
|
|
131
|
+
return finiteOrNull(1 / Math.cos(first));
|
|
132
|
+
case "sech":
|
|
133
|
+
return finiteOrNull(1 / Math.cosh(first));
|
|
134
|
+
case "signum":
|
|
135
|
+
return finiteOrNull(Math.sign(first));
|
|
136
|
+
case "sin":
|
|
137
|
+
return finiteOrNull(Math.sin(first));
|
|
138
|
+
case "sinh":
|
|
139
|
+
return finiteOrNull(Math.sinh(first));
|
|
140
|
+
case "tan":
|
|
141
|
+
return finiteOrNull(Math.tan(first));
|
|
142
|
+
case "tanh":
|
|
143
|
+
return finiteOrNull(Math.tanh(first));
|
|
144
|
+
case "toDegrees":
|
|
145
|
+
return finiteOrNull((first * 180) / Math.PI);
|
|
146
|
+
case "toRadians":
|
|
147
|
+
return finiteOrNull((first * Math.PI) / 180);
|
|
148
|
+
default:
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function statsOperatorValue(name: string, values: number[]): QtiValue {
|
|
154
|
+
if (values.length === 0) return 0;
|
|
155
|
+
const meanValue = mean(values);
|
|
156
|
+
const squareDiffs = values.map((value) => (value - meanValue) ** 2);
|
|
157
|
+
switch (name) {
|
|
158
|
+
case "mean":
|
|
159
|
+
return meanValue;
|
|
160
|
+
case "sampleVariance":
|
|
161
|
+
return meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1);
|
|
162
|
+
case "sampleSD":
|
|
163
|
+
return Math.sqrt(meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1));
|
|
164
|
+
case "popVariance":
|
|
165
|
+
return mean(squareDiffs);
|
|
166
|
+
case "popSD":
|
|
167
|
+
return Math.sqrt(mean(squareDiffs));
|
|
168
|
+
default:
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function mean(values: number[]): number {
|
|
174
|
+
return meanWithDivisor(values, values.length);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function meanWithDivisor(values: number[], divisor: number): number {
|
|
178
|
+
if (values.length === 0) return 0;
|
|
179
|
+
return values.reduce((sum, value) => sum + value, 0) / divisor;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function finiteOrNull(value: number): QtiValue {
|
|
183
|
+
return Number.isFinite(value) ? value : null;
|
|
184
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function seededRandom(seed: string | number): () => number {
|
|
2
|
+
let state = typeof seed === "number" ? seed >>> 0 : hashSeed(seed);
|
|
3
|
+
return () => {
|
|
4
|
+
state = (1664525 * state + 1013904223) >>> 0;
|
|
5
|
+
return state / 0x100000000;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hashSeed(seed: string): number {
|
|
10
|
+
let hash = 2166136261;
|
|
11
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
12
|
+
hash ^= seed.charCodeAt(index);
|
|
13
|
+
hash = Math.imul(hash, 16777619);
|
|
14
|
+
}
|
|
15
|
+
return hash >>> 0;
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QtiAttemptStateV1,
|
|
3
|
+
QtiAttemptStatus,
|
|
4
|
+
QtiDiagnostic,
|
|
5
|
+
QtiPortableCustomStateValue,
|
|
6
|
+
QtiValue,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { ATTEMPT_STATE_SCHEMA } from "./attempt-state-constants.js";
|
|
9
|
+
import { isRecordValue } from "./processing-values.js";
|
|
10
|
+
|
|
11
|
+
export function serialize(
|
|
12
|
+
itemIdentifier: string,
|
|
13
|
+
status: QtiAttemptStatus,
|
|
14
|
+
responses: Record<string, QtiValue>,
|
|
15
|
+
outcomes: Record<string, QtiValue>,
|
|
16
|
+
templateValues: Record<string, QtiValue>,
|
|
17
|
+
interactionStates: Record<string, QtiPortableCustomStateValue>,
|
|
18
|
+
validationMessages: QtiDiagnostic[],
|
|
19
|
+
): QtiAttemptStateV1 {
|
|
20
|
+
return {
|
|
21
|
+
schema: ATTEMPT_STATE_SCHEMA,
|
|
22
|
+
itemIdentifier,
|
|
23
|
+
status,
|
|
24
|
+
responses: cloneValueRecord(responses),
|
|
25
|
+
outcomes: cloneValueRecord(outcomes),
|
|
26
|
+
templateValues: cloneValueRecord(templateValues),
|
|
27
|
+
interactionStates: clonePortableCustomStateRecord(interactionStates),
|
|
28
|
+
validationMessages: cloneDiagnostics(validationMessages),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cloneValueRecord(record: Record<string, QtiValue>): Record<string, QtiValue> {
|
|
33
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, cloneValue(value)]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clonePortableCustomStateRecord(
|
|
37
|
+
record: Record<string, QtiPortableCustomStateValue>,
|
|
38
|
+
): Record<string, QtiPortableCustomStateValue> {
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
Object.entries(record).map(([key, value]) => [key, clonePortableCustomState(value)]),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function cloneValue(value: QtiValue): QtiValue {
|
|
45
|
+
if (Array.isArray(value)) return [...value];
|
|
46
|
+
if (isRecordValue(value)) return cloneValueRecord(value);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clonePortableCustomState(
|
|
51
|
+
value: QtiPortableCustomStateValue,
|
|
52
|
+
): QtiPortableCustomStateValue {
|
|
53
|
+
if (Array.isArray(value)) return value.map(clonePortableCustomState);
|
|
54
|
+
if (isPortableCustomStateObject(value)) return clonePortableCustomStateRecord(value);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPortableCustomStateObject(
|
|
59
|
+
value: QtiPortableCustomStateValue,
|
|
60
|
+
): value is { [key: string]: QtiPortableCustomStateValue } {
|
|
61
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cloneDiagnostics(diagnostics: QtiDiagnostic[]): QtiDiagnostic[] {
|
|
65
|
+
return diagnostics.map((diagnostic) => ({
|
|
66
|
+
...diagnostic,
|
|
67
|
+
source: diagnostic.source ? { ...diagnostic.source } : undefined,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ResponseProcessingTemplateKind = "matchCorrect" | "mapResponse" | "mapResponsePoint";
|
|
2
|
+
|
|
3
|
+
export function responseProcessingTemplateKind(
|
|
4
|
+
template: string,
|
|
5
|
+
): ResponseProcessingTemplateKind | undefined {
|
|
6
|
+
const path = template.split(/[?#]/, 1)[0] ?? "";
|
|
7
|
+
const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
|
|
8
|
+
if (name === "match_correct") return "matchCorrect";
|
|
9
|
+
if (name === "map_response") return "mapResponse";
|
|
10
|
+
if (name === "map_response_point") return "mapResponsePoint";
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { QtiRecordValue, QtiResponseDeclaration, QtiScalarValue, QtiValue } from "./types.js";
|
|
2
|
+
import { qtiValueToString } from "./value-format.js";
|
|
3
|
+
|
|
4
|
+
export function isNullResponse(response: QtiValue): boolean {
|
|
5
|
+
return response === null || response === "" || (Array.isArray(response) && response.length === 0);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boolean {
|
|
9
|
+
if (isRecordValue(actual) || isRecordValue(expected)) {
|
|
10
|
+
if (!isRecordValue(actual) || !isRecordValue(expected)) return false;
|
|
11
|
+
const actualKeys = Object.keys(actual).toSorted();
|
|
12
|
+
const expectedKeys = Object.keys(expected).toSorted();
|
|
13
|
+
return (
|
|
14
|
+
valuesEqual(actualKeys, expectedKeys, true) &&
|
|
15
|
+
actualKeys.every((key) => valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered))
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(actual) || Array.isArray(expected)) {
|
|
19
|
+
const actualValues = valueContainer(actual);
|
|
20
|
+
const expectedValues = Array.isArray(expected) ? expected : expected === null ? [] : [expected];
|
|
21
|
+
if (actualValues.length !== expectedValues.length) return false;
|
|
22
|
+
if (ordered)
|
|
23
|
+
return actualValues.every((value, index) => scalarValuesEqual(value, expectedValues[index]!));
|
|
24
|
+
const sortedExpected = [...expectedValues].toSorted(compareScalarValues);
|
|
25
|
+
return [...actualValues]
|
|
26
|
+
.toSorted(compareScalarValues)
|
|
27
|
+
.every((value, index) => scalarValuesEqual(value, sortedExpected[index]!));
|
|
28
|
+
}
|
|
29
|
+
return scalarValuesEqual(actual, expected);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function qtiMatchValues(
|
|
33
|
+
actual: QtiValue,
|
|
34
|
+
expected: QtiValue,
|
|
35
|
+
ordered = false,
|
|
36
|
+
): boolean | null {
|
|
37
|
+
if (actual === null || expected === null) return null;
|
|
38
|
+
return valuesEqual(actual, expected, ordered);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
|
|
42
|
+
if (typeof actual === "boolean" && typeof expected === "string") {
|
|
43
|
+
return String(actual) === expected;
|
|
44
|
+
}
|
|
45
|
+
if (typeof actual === "string" && typeof expected === "boolean") {
|
|
46
|
+
return actual === String(expected);
|
|
47
|
+
}
|
|
48
|
+
if (typeof actual === "number" && typeof expected === "string") {
|
|
49
|
+
return String(actual) === expected;
|
|
50
|
+
}
|
|
51
|
+
if (typeof actual === "string" && typeof expected === "number") {
|
|
52
|
+
return actual === String(expected);
|
|
53
|
+
}
|
|
54
|
+
return actual === expected;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeValueForCardinality(
|
|
58
|
+
value: QtiValue,
|
|
59
|
+
cardinality: QtiResponseDeclaration["cardinality"],
|
|
60
|
+
): QtiValue {
|
|
61
|
+
if (
|
|
62
|
+
(cardinality === "multiple" || cardinality === "ordered") &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
!Array.isArray(value) &&
|
|
65
|
+
!isRecordValue(value)
|
|
66
|
+
) {
|
|
67
|
+
return [value];
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function valueContainer(value: QtiValue): QtiScalarValue[] {
|
|
73
|
+
if (value === null) return [];
|
|
74
|
+
if (isRecordValue(value)) return [];
|
|
75
|
+
return Array.isArray(value) ? value : [value];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isRecordValue(value: QtiValue): value is QtiRecordValue {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
|
|
83
|
+
const remaining = [...collection];
|
|
84
|
+
for (const value of values) {
|
|
85
|
+
const index = remaining.findIndex((candidate) => valuesEqual(candidate, value));
|
|
86
|
+
if (index === -1) return false;
|
|
87
|
+
remaining.splice(index, 1);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compareScalarValues(left: QtiScalarValue, right: QtiScalarValue): number {
|
|
93
|
+
return String(left).localeCompare(String(right));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function numericValue(value: QtiValue): number {
|
|
97
|
+
if (typeof value === "number") return value;
|
|
98
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
99
|
+
if (typeof value === "string") return Number(value);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function numericValueOrNull(value: QtiValue): number | null {
|
|
104
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
105
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
const parsed = Number(value);
|
|
108
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function durationSeconds(value: QtiValue): number | null {
|
|
114
|
+
if (value === null || Array.isArray(value) || isRecordValue(value)) return null;
|
|
115
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
116
|
+
const raw = String(value).trim();
|
|
117
|
+
if (raw.length === 0) return null;
|
|
118
|
+
const numeric = Number(raw);
|
|
119
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
120
|
+
const match =
|
|
121
|
+
/^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(
|
|
122
|
+
raw,
|
|
123
|
+
);
|
|
124
|
+
if (!match) return null;
|
|
125
|
+
const [, days, hours, minutes, seconds] = match;
|
|
126
|
+
if (!days && !hours && !minutes && !seconds) return null;
|
|
127
|
+
const total =
|
|
128
|
+
Number(days ?? 0) * 86_400 +
|
|
129
|
+
Number(hours ?? 0) * 3_600 +
|
|
130
|
+
Number(minutes ?? 0) * 60 +
|
|
131
|
+
Number(seconds ?? 0);
|
|
132
|
+
return Number.isFinite(total) ? total : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function booleanValue(value: QtiValue): boolean {
|
|
136
|
+
if (typeof value === "boolean") return value;
|
|
137
|
+
if (typeof value === "number") return value !== 0;
|
|
138
|
+
if (typeof value === "string") return value.length > 0 && value !== "false";
|
|
139
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function booleanValueOrNull(value: QtiValue): boolean | null {
|
|
144
|
+
if (value === null) return null;
|
|
145
|
+
return booleanValue(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function stringMatch(
|
|
149
|
+
left: QtiValue,
|
|
150
|
+
right: QtiValue,
|
|
151
|
+
caseSensitive: boolean,
|
|
152
|
+
substring: boolean,
|
|
153
|
+
): boolean | null {
|
|
154
|
+
if (left === null || right === null) return null;
|
|
155
|
+
let actual = qtiValueToString(left);
|
|
156
|
+
let expected = qtiValueToString(right);
|
|
157
|
+
if (!caseSensitive) {
|
|
158
|
+
actual = actual.toLocaleLowerCase();
|
|
159
|
+
expected = expected.toLocaleLowerCase();
|
|
160
|
+
}
|
|
161
|
+
return substring ? actual.includes(expected) : actual === expected;
|
|
162
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QtiDocument,
|
|
3
|
+
QtiOutcomeDeclaration,
|
|
4
|
+
QtiProcessingExpression,
|
|
5
|
+
QtiResponseDeclaration,
|
|
6
|
+
QtiTemplateDeclaration,
|
|
7
|
+
QtiValue,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
export function getResponseDeclaration(
|
|
11
|
+
document: QtiDocument,
|
|
12
|
+
identifier: string,
|
|
13
|
+
): QtiResponseDeclaration | undefined {
|
|
14
|
+
return document.item.responseDeclarations.find(
|
|
15
|
+
(declaration) => declaration.identifier === identifier,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveVariableValue(
|
|
20
|
+
document: QtiDocument,
|
|
21
|
+
identifier: string,
|
|
22
|
+
responses: Record<string, QtiValue>,
|
|
23
|
+
outcomes: Record<string, QtiValue>,
|
|
24
|
+
templateValues: Record<string, QtiValue>,
|
|
25
|
+
): QtiValue {
|
|
26
|
+
return (
|
|
27
|
+
resolveOptionalVariableValue(document, identifier, responses, outcomes, templateValues) ?? null
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveOptionalVariableValue(
|
|
32
|
+
document: QtiDocument,
|
|
33
|
+
identifier: string,
|
|
34
|
+
responses: Record<string, QtiValue>,
|
|
35
|
+
outcomes: Record<string, QtiValue>,
|
|
36
|
+
templateValues: Record<string, QtiValue>,
|
|
37
|
+
): QtiValue | undefined {
|
|
38
|
+
const declaration = resolveVariableDeclaration(document, identifier);
|
|
39
|
+
if (!declaration) return undefined;
|
|
40
|
+
if (declaration.kind === "response") return responses[identifier] ?? null;
|
|
41
|
+
if (declaration.kind === "outcome") return outcomes[identifier] ?? null;
|
|
42
|
+
return templateValues[identifier] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultValueForIdentifier(document: QtiDocument, identifier: string): QtiValue {
|
|
46
|
+
return resolveVariableDeclaration(document, identifier)?.defaultValue ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function expressionIsOrdered(
|
|
50
|
+
expression: QtiProcessingExpression,
|
|
51
|
+
document: QtiDocument,
|
|
52
|
+
): boolean {
|
|
53
|
+
if (expression.type === "ordered") return true;
|
|
54
|
+
if (
|
|
55
|
+
(expression.type === "variable" ||
|
|
56
|
+
expression.type === "correct" ||
|
|
57
|
+
expression.type === "default" ||
|
|
58
|
+
expression.type === "isNull") &&
|
|
59
|
+
variableCardinality(document, expression.identifier) === "ordered"
|
|
60
|
+
) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function variableCardinality(document: QtiDocument, identifier: string): string | undefined {
|
|
67
|
+
return resolveVariableDeclaration(document, identifier)?.cardinality;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveVariableDeclaration(
|
|
71
|
+
document: QtiDocument,
|
|
72
|
+
identifier: string,
|
|
73
|
+
): QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration | undefined {
|
|
74
|
+
return (
|
|
75
|
+
document.item.responseDeclarations.find(
|
|
76
|
+
(declaration) => declaration.identifier === identifier,
|
|
77
|
+
) ??
|
|
78
|
+
document.item.outcomeDeclarations.find(
|
|
79
|
+
(declaration) => declaration.identifier === identifier,
|
|
80
|
+
) ??
|
|
81
|
+
document.item.templateDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
82
|
+
);
|
|
83
|
+
}
|