@longsightgroup/qti3-core 0.10.2 → 0.10.4
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 +21 -2
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +20 -27
- package/dist/attempt-state.js.map +1 -1
- package/dist/content-text.d.ts +4 -0
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +137 -12
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/media-definition.d.ts +55 -0
- package/dist/media-definition.d.ts.map +1 -0
- package/dist/media-definition.js +97 -0
- package/dist/media-definition.js.map +1 -0
- package/dist/package-xml.d.ts +1 -0
- package/dist/package-xml.d.ts.map +1 -1
- package/dist/package-xml.js +1 -0
- package/dist/package-xml.js.map +1 -1
- package/dist/parser-custom-interactions.js +1 -0
- package/dist/parser-custom-interactions.js.map +1 -1
- package/dist/parser-declarations.d.ts +4 -4
- package/dist/parser-declarations.d.ts.map +1 -1
- package/dist/parser-declarations.js +87 -46
- package/dist/parser-declarations.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +5 -3
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-processing.js +10 -9
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts +6 -0
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +35 -2
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +81 -47
- package/dist/parser.js.map +1 -1
- package/dist/processing-evaluator-collection.d.ts.map +1 -1
- package/dist/processing-evaluator-collection.js +8 -2
- package/dist/processing-evaluator-collection.js.map +1 -1
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +22 -6
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-numeric.d.ts.map +1 -1
- package/dist/processing-evaluator-numeric.js +8 -1
- package/dist/processing-evaluator-numeric.js.map +1 -1
- package/dist/processing-evaluator-variable.d.ts.map +1 -1
- package/dist/processing-evaluator-variable.js +1 -1
- package/dist/processing-evaluator-variable.js.map +1 -1
- package/dist/processing-expression-children.d.ts.map +1 -1
- package/dist/processing-expression-children.js +2 -1
- package/dist/processing-expression-children.js.map +1 -1
- package/dist/processing-limits.d.ts +3 -0
- package/dist/processing-limits.d.ts.map +1 -0
- package/dist/processing-limits.js +3 -0
- package/dist/processing-limits.js.map +1 -0
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +28 -9
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-operators.d.ts +2 -0
- package/dist/processing-operators.d.ts.map +1 -1
- package/dist/processing-operators.js +34 -5
- package/dist/processing-operators.js.map +1 -1
- package/dist/processing-values.d.ts +4 -4
- package/dist/processing-values.d.ts.map +1 -1
- package/dist/processing-values.js +28 -14
- package/dist/processing-values.js.map +1 -1
- package/dist/processing-variables.d.ts +3 -1
- package/dist/processing-variables.d.ts.map +1 -1
- package/dist/processing-variables.js +38 -2
- package/dist/processing-variables.js.map +1 -1
- package/dist/qti-namespaces.d.ts +7 -0
- package/dist/qti-namespaces.d.ts.map +1 -0
- package/dist/qti-namespaces.js +7 -0
- package/dist/qti-namespaces.js.map +1 -0
- package/dist/qti-package-items.d.ts.map +1 -1
- package/dist/qti-package-items.js +23 -13
- package/dist/qti-package-items.js.map +1 -1
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js +4 -3
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +3 -2
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-xml.d.ts +4 -2
- package/dist/qti-package-xml.d.ts.map +1 -1
- package/dist/qti-package-xml.js +10 -5
- package/dist/qti-package-xml.js.map +1 -1
- package/dist/qti-package-zip.d.ts +17 -1
- package/dist/qti-package-zip.d.ts.map +1 -1
- package/dist/qti-package-zip.js +60 -5
- package/dist/qti-package-zip.js.map +1 -1
- package/dist/qti-package.d.ts +3 -3
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +10 -4
- package/dist/qti-package.js.map +1 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +5 -6
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts +12 -2
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +122 -19
- package/dist/response-validation.js.map +1 -1
- package/dist/serializer-processing-expressions.js +1 -1
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/session.js +1 -1
- package/dist/session.js.map +1 -1
- package/dist/slider-definition.d.ts +59 -0
- package/dist/slider-definition.d.ts.map +1 -0
- package/dist/slider-definition.js +198 -0
- package/dist/slider-definition.js.map +1 -0
- package/dist/support-evidence.d.ts +6 -2
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +15 -11
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +96 -100
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +5 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-declaration-entries.d.ts +13 -0
- package/dist/validation-declaration-entries.d.ts.map +1 -0
- package/dist/validation-declaration-entries.js +136 -0
- package/dist/validation-declaration-entries.js.map +1 -0
- package/dist/validation-interactions.d.ts.map +1 -1
- package/dist/validation-interactions.js +14 -46
- package/dist/validation-interactions.js.map +1 -1
- package/dist/validation-primitives.d.ts +5 -1
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +41 -3
- package/dist/validation-primitives.js.map +1 -1
- package/dist/validation-processing.d.ts.map +1 -1
- package/dist/validation-processing.js +125 -1
- package/dist/validation-processing.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +8 -134
- package/dist/validation.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +31 -2
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/attempt-state.ts +22 -26
- package/src/content-text.ts +174 -14
- package/src/index.ts +28 -0
- package/src/media-definition.ts +174 -0
- package/src/package-xml.ts +2 -0
- package/src/parser-custom-interactions.ts +1 -0
- package/src/parser-declarations.ts +134 -40
- package/src/parser-item-metadata.ts +5 -3
- package/src/parser-processing.ts +10 -9
- package/src/parser-values.ts +35 -2
- package/src/parser.ts +137 -103
- package/src/processing-evaluator-collection.ts +7 -2
- package/src/processing-evaluator-comparison.ts +30 -5
- package/src/processing-evaluator-numeric.ts +5 -1
- package/src/processing-evaluator-variable.ts +1 -0
- package/src/processing-expression-children.ts +2 -1
- package/src/processing-limits.ts +2 -0
- package/src/processing-mapping.ts +32 -8
- package/src/processing-operators.ts +38 -5
- package/src/processing-values.ts +59 -14
- package/src/processing-variables.ts +47 -2
- package/src/qti-namespaces.ts +8 -0
- package/src/qti-package-items.ts +56 -49
- package/src/qti-package-manifest.ts +13 -4
- package/src/qti-package-metadata.ts +3 -2
- package/src/qti-package-xml.ts +16 -3
- package/src/qti-package-zip.ts +128 -3
- package/src/qti-package.ts +12 -3
- package/src/response-validation-policy.ts +5 -5
- package/src/response-validation.ts +185 -20
- package/src/serializer-processing-expressions.ts +1 -1
- package/src/session.ts +1 -0
- package/src/slider-definition.ts +363 -0
- package/src/support-evidence.ts +15 -17
- package/src/support.ts +106 -102
- package/src/trusted-item-session.ts +5 -3
- package/src/types.ts +10 -1
- package/src/validation-declaration-entries.ts +162 -0
- package/src/validation-interactions.ts +14 -64
- package/src/validation-primitives.ts +44 -4
- package/src/validation-processing.ts +145 -1
- package/src/validation.ts +11 -151
- package/src/xml.ts +40 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QtiDocument, QtiResponseDeclaration, QtiValue } from "./types.js";
|
|
2
|
+
import { parseQtiPair } from "./parser-values.js";
|
|
2
3
|
import { qtiScalarToString, qtiValueToStringList } from "./value-format.js";
|
|
3
4
|
import { isRecordValue, numericValue, valuesEqual } from "./processing-values.js";
|
|
4
5
|
|
|
@@ -36,8 +37,15 @@ export function mapOrMatchResponse(
|
|
|
36
37
|
correctResponse: QtiValue,
|
|
37
38
|
): number {
|
|
38
39
|
if (declaration.areaMapping) return scoreAreaMapping(response, declaration.areaMapping);
|
|
39
|
-
if (declaration.mapping) return scoreMapping(response, declaration.mapping);
|
|
40
|
-
return valuesEqual(
|
|
40
|
+
if (declaration.mapping) return scoreMapping(response, declaration.mapping, declaration.baseType);
|
|
41
|
+
return valuesEqual(
|
|
42
|
+
response,
|
|
43
|
+
correctResponse,
|
|
44
|
+
declaration.cardinality === "ordered",
|
|
45
|
+
declaration.baseType,
|
|
46
|
+
)
|
|
47
|
+
? 1
|
|
48
|
+
: 0;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export function scoreAreaMapping(
|
|
@@ -49,6 +57,7 @@ export function scoreAreaMapping(
|
|
|
49
57
|
: response === null
|
|
50
58
|
? []
|
|
51
59
|
: qtiValueToStringList(response);
|
|
60
|
+
const matchedAreaIndexes = new Set<number>();
|
|
52
61
|
let score = 0;
|
|
53
62
|
for (const point of points) {
|
|
54
63
|
const parsed = parsePoint(point);
|
|
@@ -56,8 +65,15 @@ export function scoreAreaMapping(
|
|
|
56
65
|
score += areaMapping.defaultValue;
|
|
57
66
|
continue;
|
|
58
67
|
}
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
let matchedArea = false;
|
|
69
|
+
for (const [index, entry] of areaMapping.entries.entries()) {
|
|
70
|
+
if (!pointInsideArea(parsed, entry)) continue;
|
|
71
|
+
matchedArea = true;
|
|
72
|
+
if (matchedAreaIndexes.has(index)) continue;
|
|
73
|
+
matchedAreaIndexes.add(index);
|
|
74
|
+
score += entry.mappedValue;
|
|
75
|
+
}
|
|
76
|
+
if (!matchedArea) score += areaMapping.defaultValue;
|
|
61
77
|
}
|
|
62
78
|
return clampMappedScore(score, areaMapping.attributes);
|
|
63
79
|
}
|
|
@@ -117,15 +133,17 @@ function pointInsidePolygon(point: { x: number; y: number }, coords: number[]):
|
|
|
117
133
|
function scoreMapping(
|
|
118
134
|
response: QtiValue,
|
|
119
135
|
mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
|
|
136
|
+
baseType: QtiResponseDeclaration["baseType"],
|
|
120
137
|
): number {
|
|
121
|
-
const values =
|
|
138
|
+
const values = new Map(
|
|
122
139
|
mapping.entries
|
|
123
140
|
.filter((entry) => entry.mapKey !== undefined)
|
|
124
|
-
.map((entry) => [entry.mapKey!, entry.mappedValue]),
|
|
141
|
+
.map((entry) => [mappingKey(entry.mapKey!, baseType), entry.mappedValue] as const),
|
|
125
142
|
);
|
|
126
143
|
if (Array.isArray(response)) {
|
|
127
144
|
const score = response.reduce<number>(
|
|
128
|
-
(sum, value) =>
|
|
145
|
+
(sum, value) =>
|
|
146
|
+
sum + (values.get(mappingKey(String(value), baseType)) ?? mapping.defaultValue),
|
|
129
147
|
0,
|
|
130
148
|
);
|
|
131
149
|
return clampMappedScore(score, mapping.attributes);
|
|
@@ -133,10 +151,16 @@ function scoreMapping(
|
|
|
133
151
|
const score =
|
|
134
152
|
response === null || isRecordValue(response)
|
|
135
153
|
? 0
|
|
136
|
-
: (values
|
|
154
|
+
: (values.get(mappingKey(String(response), baseType)) ?? mapping.defaultValue);
|
|
137
155
|
return clampMappedScore(score, mapping.attributes);
|
|
138
156
|
}
|
|
139
157
|
|
|
158
|
+
function mappingKey(value: string, baseType: QtiResponseDeclaration["baseType"]): string {
|
|
159
|
+
return baseType === "pair" || baseType === "directedPair"
|
|
160
|
+
? (parseQtiPair(value, baseType) ?? value)
|
|
161
|
+
: value;
|
|
162
|
+
}
|
|
163
|
+
|
|
140
164
|
function clampMappedScore(score: number, attributes: Record<string, string>): number {
|
|
141
165
|
const lower = numericBound(attributes["lower-bound"]);
|
|
142
166
|
const upper = numericBound(attributes["upper-bound"]);
|
|
@@ -39,15 +39,45 @@ export const STATS_OPERATOR_NAMES = [
|
|
|
39
39
|
"popSD",
|
|
40
40
|
] as const;
|
|
41
41
|
|
|
42
|
+
/** Round decimal ties away from zero without an intermediate scaled binary float. */
|
|
42
43
|
export function roundToDecimalPlaces(value: number, figures: number): number {
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
if (!Number.isFinite(value) || value === 0) return value;
|
|
45
|
+
const decimal = decimalDigits(value);
|
|
46
|
+
const retainedDigits = decimal.exponent + figures + 1;
|
|
47
|
+
return roundDecimalDigits(value, decimal, retainedDigits);
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/** Round significant decimal digits, with half-up ties on the absolute magnitude. */
|
|
47
51
|
export function roundToSignificantFigures(value: number, figures: number): number {
|
|
48
52
|
if (value === 0 || figures <= 0) return 0;
|
|
49
|
-
|
|
50
|
-
return
|
|
53
|
+
if (!Number.isFinite(value)) return Number.NaN;
|
|
54
|
+
return roundDecimalDigits(value, decimalDigits(value), figures);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface DecimalDigits {
|
|
58
|
+
digits: string;
|
|
59
|
+
exponent: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decimalDigits(value: number): DecimalDigits {
|
|
63
|
+
const [coefficient = "", exponent = "0"] = Math.abs(value).toExponential().split("e");
|
|
64
|
+
return {
|
|
65
|
+
digits: coefficient.replace(".", ""),
|
|
66
|
+
exponent: Number(exponent),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function roundDecimalDigits(value: number, decimal: DecimalDigits, retainedDigits: number): number {
|
|
71
|
+
if (retainedDigits >= decimal.digits.length) return value;
|
|
72
|
+
if (retainedDigits < 0) return value < 0 ? -0 : 0;
|
|
73
|
+
|
|
74
|
+
const roundingDigit = decimal.digits[retainedDigits] ?? "0";
|
|
75
|
+
const retained = retainedDigits === 0 ? 0n : BigInt(decimal.digits.slice(0, retainedDigits));
|
|
76
|
+
const rounded = roundingDigit >= "5" ? retained + 1n : retained;
|
|
77
|
+
if (rounded === 0n) return value < 0 ? -0 : 0;
|
|
78
|
+
|
|
79
|
+
const unitExponent = decimal.exponent - retainedDigits + 1;
|
|
80
|
+
return Number(`${value < 0 ? "-" : ""}${rounded}e${unitExponent}`);
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
export function roundWithMode(value: number, roundingMode: string, figures: number): number | null {
|
|
@@ -151,7 +181,10 @@ export function mathOperatorValue(name: string, values: number[]): QtiValue {
|
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
export function statsOperatorValue(name: string, values: number[]): QtiValue {
|
|
154
|
-
|
|
184
|
+
// QTI 3.0.1 defines the input as a numeric container but does not define empty-container
|
|
185
|
+
// results, so this implementation conservatively returns NULL.
|
|
186
|
+
// https://www.imsglobal.org/sites/default/files/spec/qti/v3/info/index.html#OpStatsOp
|
|
187
|
+
if (values.length === 0) return null;
|
|
155
188
|
const meanValue = mean(values);
|
|
156
189
|
const squareDiffs = values.map((value) => (value - meanValue) ** 2);
|
|
157
190
|
switch (name) {
|
package/src/processing-values.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { parseQtiPair } from "./parser-values.js";
|
|
2
|
+
import type {
|
|
3
|
+
QtiBaseType,
|
|
4
|
+
QtiRecordValue,
|
|
5
|
+
QtiResponseDeclaration,
|
|
6
|
+
QtiScalarValue,
|
|
7
|
+
QtiValue,
|
|
8
|
+
} from "./types.js";
|
|
2
9
|
import { qtiValueToString } from "./value-format.js";
|
|
3
10
|
|
|
4
11
|
export function isNullResponse(response: QtiValue): boolean {
|
|
5
12
|
return response === null || response === "" || (Array.isArray(response) && response.length === 0);
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
export function valuesEqual(
|
|
15
|
+
export function valuesEqual(
|
|
16
|
+
actual: QtiValue,
|
|
17
|
+
expected: QtiValue,
|
|
18
|
+
ordered = false,
|
|
19
|
+
baseType?: QtiBaseType,
|
|
20
|
+
): boolean {
|
|
9
21
|
if (isRecordValue(actual) || isRecordValue(expected)) {
|
|
10
22
|
if (!isRecordValue(actual) || !isRecordValue(expected)) return false;
|
|
11
23
|
const actualKeys = Object.keys(actual).toSorted();
|
|
12
24
|
const expectedKeys = Object.keys(expected).toSorted();
|
|
13
25
|
return (
|
|
14
26
|
valuesEqual(actualKeys, expectedKeys, true) &&
|
|
15
|
-
actualKeys.every((key) =>
|
|
27
|
+
actualKeys.every((key) =>
|
|
28
|
+
valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered, baseType),
|
|
29
|
+
)
|
|
16
30
|
);
|
|
17
31
|
}
|
|
18
32
|
if (Array.isArray(actual) || Array.isArray(expected)) {
|
|
@@ -20,25 +34,38 @@ export function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = fals
|
|
|
20
34
|
const expectedValues = Array.isArray(expected) ? expected : expected === null ? [] : [expected];
|
|
21
35
|
if (actualValues.length !== expectedValues.length) return false;
|
|
22
36
|
if (ordered)
|
|
23
|
-
return actualValues.every((value, index) =>
|
|
24
|
-
|
|
37
|
+
return actualValues.every((value, index) =>
|
|
38
|
+
scalarValuesEqual(value, expectedValues[index]!, baseType),
|
|
39
|
+
);
|
|
40
|
+
const compareValues = (left: QtiScalarValue, right: QtiScalarValue): number =>
|
|
41
|
+
compareScalarValues(left, right, baseType);
|
|
42
|
+
const sortedExpected = [...expectedValues].toSorted(compareValues);
|
|
25
43
|
return [...actualValues]
|
|
26
|
-
.toSorted(
|
|
27
|
-
.every((value, index) => scalarValuesEqual(value, sortedExpected[index]
|
|
44
|
+
.toSorted(compareValues)
|
|
45
|
+
.every((value, index) => scalarValuesEqual(value, sortedExpected[index]!, baseType));
|
|
28
46
|
}
|
|
29
|
-
return scalarValuesEqual(actual, expected);
|
|
47
|
+
return scalarValuesEqual(actual, expected, baseType);
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
export function qtiMatchValues(
|
|
33
51
|
actual: QtiValue,
|
|
34
52
|
expected: QtiValue,
|
|
35
53
|
ordered = false,
|
|
54
|
+
baseType?: QtiBaseType,
|
|
36
55
|
): boolean | null {
|
|
37
56
|
if (actual === null || expected === null) return null;
|
|
38
|
-
return valuesEqual(actual, expected, ordered);
|
|
57
|
+
return valuesEqual(actual, expected, ordered, baseType);
|
|
39
58
|
}
|
|
40
59
|
|
|
41
|
-
function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
|
|
60
|
+
function scalarValuesEqual(actual: QtiValue, expected: QtiValue, baseType?: QtiBaseType): boolean {
|
|
61
|
+
if (
|
|
62
|
+
(baseType === "pair" || baseType === "directedPair") &&
|
|
63
|
+
typeof actual === "string" &&
|
|
64
|
+
typeof expected === "string"
|
|
65
|
+
) {
|
|
66
|
+
const normalizedActual = parseQtiPair(actual, baseType);
|
|
67
|
+
return normalizedActual !== undefined && normalizedActual === parseQtiPair(expected, baseType);
|
|
68
|
+
}
|
|
42
69
|
if (typeof actual === "boolean" && typeof expected === "string") {
|
|
43
70
|
return String(actual) === expected;
|
|
44
71
|
}
|
|
@@ -79,18 +106,36 @@ export function isRecordValue(value: QtiValue): value is QtiRecordValue {
|
|
|
79
106
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
export function containsValues(
|
|
109
|
+
export function containsValues(
|
|
110
|
+
collection: QtiScalarValue[],
|
|
111
|
+
values: QtiScalarValue[],
|
|
112
|
+
baseType?: QtiBaseType,
|
|
113
|
+
): boolean {
|
|
83
114
|
const remaining = [...collection];
|
|
84
115
|
for (const value of values) {
|
|
85
|
-
const index = remaining.findIndex((candidate) =>
|
|
116
|
+
const index = remaining.findIndex((candidate) =>
|
|
117
|
+
valuesEqual(candidate, value, false, baseType),
|
|
118
|
+
);
|
|
86
119
|
if (index === -1) return false;
|
|
87
120
|
remaining.splice(index, 1);
|
|
88
121
|
}
|
|
89
122
|
return true;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
function compareScalarValues(
|
|
93
|
-
|
|
125
|
+
function compareScalarValues(
|
|
126
|
+
left: QtiScalarValue,
|
|
127
|
+
right: QtiScalarValue,
|
|
128
|
+
baseType?: QtiBaseType,
|
|
129
|
+
): number {
|
|
130
|
+
const leftKey =
|
|
131
|
+
(baseType === "pair" || baseType === "directedPair") && typeof left === "string"
|
|
132
|
+
? (parseQtiPair(left, baseType) ?? left)
|
|
133
|
+
: String(left);
|
|
134
|
+
const rightKey =
|
|
135
|
+
(baseType === "pair" || baseType === "directedPair") && typeof right === "string"
|
|
136
|
+
? (parseQtiPair(right, baseType) ?? right)
|
|
137
|
+
: String(right);
|
|
138
|
+
return leftKey.localeCompare(rightKey);
|
|
94
139
|
}
|
|
95
140
|
|
|
96
141
|
export function numericValue(value: QtiValue): number {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
QtiBaseType,
|
|
2
3
|
QtiDocument,
|
|
3
4
|
QtiOutcomeDeclaration,
|
|
4
5
|
QtiProcessingExpression,
|
|
@@ -6,6 +7,7 @@ import type {
|
|
|
6
7
|
QtiTemplateDeclaration,
|
|
7
8
|
QtiValue,
|
|
8
9
|
} from "./types.js";
|
|
10
|
+
import { parseBaseType } from "./parser-values.js";
|
|
9
11
|
|
|
10
12
|
export function getResponseDeclaration(
|
|
11
13
|
document: QtiDocument,
|
|
@@ -51,11 +53,11 @@ export function expressionIsOrdered(
|
|
|
51
53
|
document: QtiDocument,
|
|
52
54
|
): boolean {
|
|
53
55
|
if (expression.type === "ordered") return true;
|
|
56
|
+
if (expression.type === "delete") return expressionIsOrdered(expression.collection, document);
|
|
54
57
|
if (
|
|
55
58
|
(expression.type === "variable" ||
|
|
56
59
|
expression.type === "correct" ||
|
|
57
|
-
expression.type === "default"
|
|
58
|
-
expression.type === "isNull") &&
|
|
60
|
+
expression.type === "default") &&
|
|
59
61
|
variableCardinality(document, expression.identifier) === "ordered"
|
|
60
62
|
) {
|
|
61
63
|
return true;
|
|
@@ -63,6 +65,49 @@ export function expressionIsOrdered(
|
|
|
63
65
|
return false;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** Resolve the declared atomic base type for an expression when it is statically knowable. */
|
|
69
|
+
export function expressionBaseType(
|
|
70
|
+
expression: QtiProcessingExpression,
|
|
71
|
+
document: QtiDocument,
|
|
72
|
+
): QtiBaseType | undefined {
|
|
73
|
+
if (expression.type === "baseValue") return parseBaseType(expression.baseType);
|
|
74
|
+
if (expression.type === "isNull") return "boolean";
|
|
75
|
+
if (
|
|
76
|
+
expression.type === "variable" ||
|
|
77
|
+
expression.type === "correct" ||
|
|
78
|
+
expression.type === "default"
|
|
79
|
+
) {
|
|
80
|
+
return resolveVariableDeclaration(document, expression.identifier)?.baseType;
|
|
81
|
+
}
|
|
82
|
+
if (expression.type === "index") return expressionBaseType(expression.expression, document);
|
|
83
|
+
if (expression.type === "delete") return expressionBaseType(expression.collection, document);
|
|
84
|
+
if (
|
|
85
|
+
expression.type === "random" ||
|
|
86
|
+
expression.type === "multiple" ||
|
|
87
|
+
expression.type === "ordered" ||
|
|
88
|
+
expression.type === "repeat"
|
|
89
|
+
) {
|
|
90
|
+
return commonExpressionBaseType(
|
|
91
|
+
expression.type === "random" ? expression.values : expression.expressions,
|
|
92
|
+
document,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function commonExpressionBaseType(
|
|
99
|
+
expressions: QtiProcessingExpression[],
|
|
100
|
+
document: QtiDocument,
|
|
101
|
+
): QtiBaseType | undefined {
|
|
102
|
+
const [first, ...rest] = expressions;
|
|
103
|
+
if (!first) return undefined;
|
|
104
|
+
const baseType = expressionBaseType(first, document);
|
|
105
|
+
if (!baseType) return undefined;
|
|
106
|
+
return rest.every((expression) => expressionBaseType(expression, document) === baseType)
|
|
107
|
+
? baseType
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
function variableCardinality(document: QtiDocument, identifier: string): string | undefined {
|
|
67
112
|
return resolveVariableDeclaration(document, identifier)?.cardinality;
|
|
68
113
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Namespace for QTI 3 assessment, section, and item documents. */
|
|
2
|
+
export const QTI_ASI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
|
|
3
|
+
|
|
4
|
+
/** Namespace for MathML embedded in QTI content. */
|
|
5
|
+
export const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
|
|
6
|
+
|
|
7
|
+
/** Namespace for QTI 3 content-package manifests. */
|
|
8
|
+
export const QTI_PACKAGE_MANIFEST_NAMESPACE = "http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p1";
|
package/src/qti-package-items.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "./qti-package-xml.js";
|
|
35
35
|
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
36
36
|
import type { QtiDiagnostic } from "./types.js";
|
|
37
|
+
import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
|
|
37
38
|
|
|
38
39
|
export interface PackageItemReference {
|
|
39
40
|
readonly href: string;
|
|
@@ -83,13 +84,18 @@ export function parseAssessmentTestPackageModel(
|
|
|
83
84
|
const xmlFile = xmlFilesByPath.get(href) ?? parsePackageXml(entry);
|
|
84
85
|
const testDiagnostics: QtiDiagnostic[] = [];
|
|
85
86
|
pushXmlDiagnostics(xmlFile, testDiagnostics);
|
|
86
|
-
const root =
|
|
87
|
+
const root =
|
|
88
|
+
xmlFile.root?.localName === "qti-assessment-test" && xmlFile.root.uri === QTI_ASI_NAMESPACE
|
|
89
|
+
? xmlFile.root
|
|
90
|
+
: undefined;
|
|
87
91
|
if (xmlFile.root && !root) {
|
|
88
92
|
pushPackageDiagnostic(
|
|
89
93
|
testDiagnostics,
|
|
90
94
|
"package.assessmentTest.root",
|
|
91
95
|
"error",
|
|
92
|
-
|
|
96
|
+
xmlFile.root.localName === "qti-assessment-test"
|
|
97
|
+
? `Expected qti-assessment-test in namespace ${QTI_ASI_NAMESPACE}, found ${xmlFile.root.uri ?? "(none)"}.`
|
|
98
|
+
: `Expected qti-assessment-test root, found ${xmlFile.root.localName}.`,
|
|
93
99
|
href,
|
|
94
100
|
);
|
|
95
101
|
}
|
|
@@ -98,7 +104,7 @@ export function parseAssessmentTestPackageModel(
|
|
|
98
104
|
const itemRefs = testParts.flatMap(flattenTestPartItemRefs);
|
|
99
105
|
const assetHrefs = root ? discoverContentAssetHrefs(xmlFile, testDiagnostics) : [];
|
|
100
106
|
const timeLimits = root
|
|
101
|
-
? parseTimeLimits(
|
|
107
|
+
? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], href, testDiagnostics)
|
|
102
108
|
: undefined;
|
|
103
109
|
const timing = root ? parseTimingMetadata(root, href, testDiagnostics, timeLimits) : undefined;
|
|
104
110
|
const scopedDiagnostics = testDiagnostics.map((diagnostic) =>
|
|
@@ -128,18 +134,18 @@ function parseAssessmentTestParts(
|
|
|
128
134
|
sourcePath: string,
|
|
129
135
|
diagnostics: QtiDiagnostic[],
|
|
130
136
|
): QtiTestPartPackageModel[] {
|
|
131
|
-
return
|
|
137
|
+
return qtiChildElements(root, "qti-test-part").map((partNode) => {
|
|
132
138
|
const identifier = partNode.attributes.identifier ?? "";
|
|
133
139
|
return {
|
|
134
140
|
identifier,
|
|
135
141
|
navigationMode: parseNavigationMode(partNode, sourcePath, diagnostics),
|
|
136
142
|
submissionMode: parseSubmissionMode(partNode, sourcePath, diagnostics),
|
|
137
143
|
timeLimits: parseTimeLimits(
|
|
138
|
-
|
|
144
|
+
qtiChildElements(partNode, "qti-time-limits")[0],
|
|
139
145
|
sourcePath,
|
|
140
146
|
diagnostics,
|
|
141
147
|
),
|
|
142
|
-
sections:
|
|
148
|
+
sections: qtiChildElements(partNode, "qti-assessment-section").map((sectionNode) =>
|
|
143
149
|
parseAssessmentSection(sectionNode, identifier, undefined, sourcePath, diagnostics),
|
|
144
150
|
),
|
|
145
151
|
attributes: { ...partNode.attributes },
|
|
@@ -156,43 +162,41 @@ function parseAssessmentSection(
|
|
|
156
162
|
): QtiAssessmentSectionPackageModel {
|
|
157
163
|
const identifier = sectionNode.attributes.identifier ?? "";
|
|
158
164
|
const visible = parseOptionalXmlBoolean(sectionNode, "visible", sourcePath, diagnostics);
|
|
159
|
-
const itemRefs =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
const itemRefs = qtiChildElements(sectionNode, "qti-assessment-item-ref").flatMap((refNode) => {
|
|
166
|
+
const rawHref = refNode.attributes.href;
|
|
167
|
+
if (!rawHref) {
|
|
168
|
+
pushPackageDiagnostic(
|
|
169
|
+
diagnostics,
|
|
170
|
+
"package.assessmentTest.itemRef.href.missing",
|
|
171
|
+
"error",
|
|
172
|
+
"qti-assessment-item-ref is missing href.",
|
|
173
|
+
sourcePath,
|
|
174
|
+
);
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
|
|
179
|
+
if (!href) return [];
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
identifier: refNode.attributes.identifier,
|
|
183
|
+
href,
|
|
184
|
+
testPartIdentifier,
|
|
185
|
+
sectionIdentifier: identifier,
|
|
186
|
+
timeLimits: parseTimeLimits(
|
|
187
|
+
qtiChildElements(refNode, "qti-time-limits")[0],
|
|
188
|
+
sourcePath,
|
|
164
189
|
diagnostics,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"qti-
|
|
190
|
+
),
|
|
191
|
+
itemSessionControl: parseItemSessionControl(
|
|
192
|
+
qtiChildElements(refNode, "qti-item-session-control")[0],
|
|
168
193
|
sourcePath,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return [
|
|
176
|
-
{
|
|
177
|
-
identifier: refNode.attributes.identifier,
|
|
178
|
-
href,
|
|
179
|
-
testPartIdentifier,
|
|
180
|
-
sectionIdentifier: identifier,
|
|
181
|
-
timeLimits: parseTimeLimits(
|
|
182
|
-
childPackageElements(refNode, "qti-time-limits")[0],
|
|
183
|
-
sourcePath,
|
|
184
|
-
diagnostics,
|
|
185
|
-
),
|
|
186
|
-
itemSessionControl: parseItemSessionControl(
|
|
187
|
-
childPackageElements(refNode, "qti-item-session-control")[0],
|
|
188
|
-
sourcePath,
|
|
189
|
-
diagnostics,
|
|
190
|
-
),
|
|
191
|
-
attributes: { ...refNode.attributes },
|
|
192
|
-
},
|
|
193
|
-
];
|
|
194
|
-
},
|
|
195
|
-
);
|
|
194
|
+
diagnostics,
|
|
195
|
+
),
|
|
196
|
+
attributes: { ...refNode.attributes },
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
});
|
|
196
200
|
|
|
197
201
|
return {
|
|
198
202
|
identifier,
|
|
@@ -201,12 +205,12 @@ function parseAssessmentSection(
|
|
|
201
205
|
testPartIdentifier,
|
|
202
206
|
parentSectionIdentifier,
|
|
203
207
|
timeLimits: parseTimeLimits(
|
|
204
|
-
|
|
208
|
+
qtiChildElements(sectionNode, "qti-time-limits")[0],
|
|
205
209
|
sourcePath,
|
|
206
210
|
diagnostics,
|
|
207
211
|
),
|
|
208
212
|
itemRefs,
|
|
209
|
-
sections:
|
|
213
|
+
sections: qtiChildElements(sectionNode, "qti-assessment-section").map((child) =>
|
|
210
214
|
parseAssessmentSection(child, testPartIdentifier, identifier, sourcePath, diagnostics),
|
|
211
215
|
),
|
|
212
216
|
attributes: { ...sectionNode.attributes },
|
|
@@ -385,14 +389,13 @@ export function parsePackageItems(
|
|
|
385
389
|
diagnostics.push(...itemDiagnostics);
|
|
386
390
|
|
|
387
391
|
const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
|
|
388
|
-
const root =
|
|
392
|
+
const root =
|
|
393
|
+
xmlFile.root?.localName === "qti-assessment-item" && xmlFile.root.uri === QTI_ASI_NAMESPACE
|
|
394
|
+
? xmlFile.root
|
|
395
|
+
: undefined;
|
|
389
396
|
const item = parsed.document?.item;
|
|
390
397
|
const timeLimits = root
|
|
391
|
-
? parseTimeLimits(
|
|
392
|
-
childPackageElements(root, "qti-time-limits")[0],
|
|
393
|
-
reference.href,
|
|
394
|
-
diagnostics,
|
|
395
|
-
)
|
|
398
|
+
? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], reference.href, diagnostics)
|
|
396
399
|
: undefined;
|
|
397
400
|
|
|
398
401
|
items.push({
|
|
@@ -421,3 +424,7 @@ export function parsePackageItems(
|
|
|
421
424
|
|
|
422
425
|
return items;
|
|
423
426
|
}
|
|
427
|
+
|
|
428
|
+
function qtiChildElements(node: QtiPackageXmlNode, localName: string): QtiPackageXmlNode[] {
|
|
429
|
+
return childPackageElements(node, localName, QTI_ASI_NAMESPACE);
|
|
430
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { QtiManifestFile, QtiManifestResource, QtiPackageShape } from "./qt
|
|
|
5
5
|
import { childPackageElements, packageDescendants, stripHrefSuffix } from "./qti-package-xml.js";
|
|
6
6
|
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
7
7
|
import type { QtiDiagnostic } from "./types.js";
|
|
8
|
+
import { QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
|
|
8
9
|
|
|
9
10
|
export const QTI_PACKAGE_MANIFEST_PATH = "imsmanifest.xml";
|
|
10
11
|
|
|
@@ -23,8 +24,12 @@ export function parseManifestResources(
|
|
|
23
24
|
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
24
25
|
diagnostics: QtiDiagnostic[],
|
|
25
26
|
): QtiManifestResource[] {
|
|
26
|
-
const resourceNodes = packageDescendants(
|
|
27
|
-
|
|
27
|
+
const resourceNodes = packageDescendants(
|
|
28
|
+
manifestRoot,
|
|
29
|
+
"resources",
|
|
30
|
+
QTI_PACKAGE_MANIFEST_NAMESPACE,
|
|
31
|
+
).flatMap((resources) =>
|
|
32
|
+
childPackageElements(resources, "resource", QTI_PACKAGE_MANIFEST_NAMESPACE),
|
|
28
33
|
);
|
|
29
34
|
const resources: QtiManifestResource[] = [];
|
|
30
35
|
|
|
@@ -71,7 +76,7 @@ function parseManifestFiles(
|
|
|
71
76
|
diagnostics: QtiDiagnostic[],
|
|
72
77
|
): QtiManifestFile[] {
|
|
73
78
|
const files: QtiManifestFile[] = [];
|
|
74
|
-
for (const fileNode of packageDescendants(resourceNode, "file")) {
|
|
79
|
+
for (const fileNode of packageDescendants(resourceNode, "file", QTI_PACKAGE_MANIFEST_NAMESPACE)) {
|
|
75
80
|
const rawHref = fileNode.attributes.href;
|
|
76
81
|
if (!rawHref) {
|
|
77
82
|
pushPackageDiagnostic(
|
|
@@ -105,7 +110,11 @@ function parseManifestDependencies(
|
|
|
105
110
|
diagnostics: QtiDiagnostic[],
|
|
106
111
|
): string[] {
|
|
107
112
|
const dependencies: string[] = [];
|
|
108
|
-
for (const dependencyNode of packageDescendants(
|
|
113
|
+
for (const dependencyNode of packageDescendants(
|
|
114
|
+
resourceNode,
|
|
115
|
+
"dependency",
|
|
116
|
+
QTI_PACKAGE_MANIFEST_NAMESPACE,
|
|
117
|
+
)) {
|
|
109
118
|
const identifierref = dependencyNode.attributes.identifierref;
|
|
110
119
|
if (!identifierref) {
|
|
111
120
|
pushPackageDiagnostic(
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
QtiTimingMetadata,
|
|
12
12
|
} from "./qti-package-types.js";
|
|
13
13
|
import type { QtiDiagnostic } from "./types.js";
|
|
14
|
+
import { QTI_ASI_NAMESPACE, QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
|
|
14
15
|
|
|
15
16
|
/** Known QTI / IMS CP alignment element names plus attribute-driven manifest metadata. */
|
|
16
17
|
const STANDARD_ALIGNMENT_ELEMENT_NAMES = new Set([
|
|
@@ -34,7 +35,7 @@ export function parseTimingMetadata(
|
|
|
34
35
|
diagnostics: QtiDiagnostic[],
|
|
35
36
|
parsedTimeLimits?: QtiTimeLimits,
|
|
36
37
|
): QtiTimingMetadata | undefined {
|
|
37
|
-
const timeLimitsNode = childPackageElements(root, "qti-time-limits")[0];
|
|
38
|
+
const timeLimitsNode = childPackageElements(root, "qti-time-limits", QTI_ASI_NAMESPACE)[0];
|
|
38
39
|
const timeLimits = parsedTimeLimits ?? parseTimeLimits(timeLimitsNode, sourcePath, diagnostics);
|
|
39
40
|
const timeDependent = parseXmlBoolean(root.attributes["time-dependent"]);
|
|
40
41
|
if (!timeLimitsNode && timeDependent === undefined) return undefined;
|
|
@@ -296,7 +297,7 @@ export function packageTitle(
|
|
|
296
297
|
items: readonly QtiPackageItem[],
|
|
297
298
|
): string {
|
|
298
299
|
const manifestTitle = manifestRoot
|
|
299
|
-
? packageDescendants(manifestRoot, "title")
|
|
300
|
+
? packageDescendants(manifestRoot, "title", QTI_PACKAGE_MANIFEST_NAMESPACE)
|
|
300
301
|
.map((node) => node.text.trim())
|
|
301
302
|
.find((text) => text.length > 0)
|
|
302
303
|
: undefined;
|
package/src/qti-package-xml.ts
CHANGED
|
@@ -39,22 +39,35 @@ export function pushXmlDiagnostics(xmlFile: PackageXmlFile, diagnostics: QtiDiag
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Return direct package-XML children, optionally restricted by local name and namespace. */
|
|
42
43
|
export function childPackageElements(
|
|
43
44
|
node: QtiPackageXmlNode,
|
|
44
45
|
localName?: string,
|
|
46
|
+
namespaceUri?: string,
|
|
45
47
|
): QtiPackageXmlNode[] {
|
|
46
|
-
return node.children.filter(
|
|
48
|
+
return node.children.filter(
|
|
49
|
+
(child) =>
|
|
50
|
+
(!localName || child.localName === localName) &&
|
|
51
|
+
(namespaceUri === undefined || child.uri === namespaceUri),
|
|
52
|
+
);
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
/** Return package-XML descendants, optionally restricted by local name and namespace. */
|
|
49
56
|
export function packageDescendants(
|
|
50
57
|
node: QtiPackageXmlNode | undefined,
|
|
51
58
|
localName?: string,
|
|
59
|
+
namespaceUri?: string,
|
|
52
60
|
): QtiPackageXmlNode[] {
|
|
53
61
|
if (!node) return [];
|
|
54
62
|
const found: QtiPackageXmlNode[] = [];
|
|
55
63
|
for (const child of node.children) {
|
|
56
|
-
if (
|
|
57
|
-
|
|
64
|
+
if (
|
|
65
|
+
(!localName || child.localName === localName) &&
|
|
66
|
+
(namespaceUri === undefined || child.uri === namespaceUri)
|
|
67
|
+
) {
|
|
68
|
+
found.push(child);
|
|
69
|
+
}
|
|
70
|
+
found.push(...packageDescendants(child, localName, namespaceUri));
|
|
58
71
|
}
|
|
59
72
|
return found;
|
|
60
73
|
}
|