@longsightgroup/qti3-core 0.10.3 → 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/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +2 -27
- package/dist/attempt-state.js.map +1 -1
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +31 -9
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- 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-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +1 -0
- package/dist/parser-item-metadata.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 +31 -5
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +65 -34
- package/dist/parser.js.map +1 -1
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +16 -6
- package/dist/processing-evaluator-comparison.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-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +15 -7
- package/dist/processing-mapping.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.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/session.js +1 -1
- package/dist/session.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 +2 -0
- package/dist/types.d.ts.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 +40 -2
- package/dist/validation-primitives.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +28 -0
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/attempt-state.ts +2 -26
- package/src/content-text.ts +45 -9
- package/src/index.ts +3 -0
- package/src/package-xml.ts +2 -0
- package/src/parser-custom-interactions.ts +1 -0
- package/src/parser-item-metadata.ts +1 -0
- package/src/parser-values.ts +31 -5
- package/src/parser.ts +122 -91
- package/src/processing-evaluator-comparison.ts +24 -5
- package/src/processing-evaluator-variable.ts +1 -0
- package/src/processing-mapping.ts +22 -6
- 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.ts +185 -20
- package/src/session.ts +1 -0
- package/src/trusted-item-session.ts +5 -3
- package/src/types.ts +2 -0
- package/src/validation-primitives.ts +43 -3
- package/src/xml.ts +37 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
valuesEqual,
|
|
10
10
|
valueContainer,
|
|
11
11
|
} from "./processing-values.js";
|
|
12
|
-
import { expressionIsOrdered } from "./processing-variables.js";
|
|
12
|
+
import { expressionBaseType, expressionIsOrdered } from "./processing-variables.js";
|
|
13
13
|
import { roundWithMode } from "./processing-operators.js";
|
|
14
14
|
|
|
15
15
|
type ComparisonExpression = Extract<
|
|
@@ -40,12 +40,22 @@ export function evaluateComparisonExpression(
|
|
|
40
40
|
right,
|
|
41
41
|
expressionIsOrdered(expression.left, context.document) ||
|
|
42
42
|
expressionIsOrdered(expression.right, context.document),
|
|
43
|
+
expressionBaseType(expression.left, context.document) ??
|
|
44
|
+
expressionBaseType(expression.right, context.document),
|
|
43
45
|
);
|
|
44
46
|
}
|
|
45
47
|
case "equal": {
|
|
46
48
|
const left = context.evaluate(expression.left);
|
|
47
49
|
const right = context.evaluate(expression.right);
|
|
48
|
-
return left === null || right === null
|
|
50
|
+
return left === null || right === null
|
|
51
|
+
? null
|
|
52
|
+
: valuesEqual(
|
|
53
|
+
left,
|
|
54
|
+
right,
|
|
55
|
+
false,
|
|
56
|
+
expressionBaseType(expression.left, context.document) ??
|
|
57
|
+
expressionBaseType(expression.right, context.document),
|
|
58
|
+
);
|
|
49
59
|
}
|
|
50
60
|
case "equalRounded": {
|
|
51
61
|
const validRounding =
|
|
@@ -86,7 +96,10 @@ export function evaluateComparisonExpression(
|
|
|
86
96
|
const value = context.evaluate(expression.value);
|
|
87
97
|
const collection = context.evaluate(expression.collection);
|
|
88
98
|
if (value === null || collection === null) return null;
|
|
89
|
-
|
|
99
|
+
const baseType =
|
|
100
|
+
expressionBaseType(expression.value, context.document) ??
|
|
101
|
+
expressionBaseType(expression.collection, context.document);
|
|
102
|
+
return valueContainer(collection).some((item) => valuesEqual(item, value, false, baseType));
|
|
90
103
|
}
|
|
91
104
|
case "delete": {
|
|
92
105
|
const value = context.evaluate(expression.value);
|
|
@@ -94,7 +107,10 @@ export function evaluateComparisonExpression(
|
|
|
94
107
|
if (value === null || collectionValue === null) return null;
|
|
95
108
|
const collection = valueContainer(collectionValue);
|
|
96
109
|
if (collection.length === 0) return null;
|
|
97
|
-
const
|
|
110
|
+
const baseType =
|
|
111
|
+
expressionBaseType(expression.value, context.document) ??
|
|
112
|
+
expressionBaseType(expression.collection, context.document);
|
|
113
|
+
const filtered = collection.filter((item) => !valuesEqual(item, value, false, baseType));
|
|
98
114
|
return filtered.length > 0 ? filtered : null;
|
|
99
115
|
}
|
|
100
116
|
case "contains": {
|
|
@@ -104,7 +120,10 @@ export function evaluateComparisonExpression(
|
|
|
104
120
|
const collection = valueContainer(collectionValue);
|
|
105
121
|
const values = valueContainer(valuesValue);
|
|
106
122
|
if (collection.length === 0 || values.length === 0) return null;
|
|
107
|
-
|
|
123
|
+
const baseType =
|
|
124
|
+
expressionBaseType(expression.collection, context.document) ??
|
|
125
|
+
expressionBaseType(expression.values, context.document);
|
|
126
|
+
return containsValues(collection, values, baseType);
|
|
108
127
|
}
|
|
109
128
|
default:
|
|
110
129
|
return assertNever(expression);
|
|
@@ -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(
|
|
@@ -125,15 +133,17 @@ function pointInsidePolygon(point: { x: number; y: number }, coords: number[]):
|
|
|
125
133
|
function scoreMapping(
|
|
126
134
|
response: QtiValue,
|
|
127
135
|
mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
|
|
136
|
+
baseType: QtiResponseDeclaration["baseType"],
|
|
128
137
|
): number {
|
|
129
|
-
const values =
|
|
138
|
+
const values = new Map(
|
|
130
139
|
mapping.entries
|
|
131
140
|
.filter((entry) => entry.mapKey !== undefined)
|
|
132
|
-
.map((entry) => [entry.mapKey!, entry.mappedValue]),
|
|
141
|
+
.map((entry) => [mappingKey(entry.mapKey!, baseType), entry.mappedValue] as const),
|
|
133
142
|
);
|
|
134
143
|
if (Array.isArray(response)) {
|
|
135
144
|
const score = response.reduce<number>(
|
|
136
|
-
(sum, value) =>
|
|
145
|
+
(sum, value) =>
|
|
146
|
+
sum + (values.get(mappingKey(String(value), baseType)) ?? mapping.defaultValue),
|
|
137
147
|
0,
|
|
138
148
|
);
|
|
139
149
|
return clampMappedScore(score, mapping.attributes);
|
|
@@ -141,10 +151,16 @@ function scoreMapping(
|
|
|
141
151
|
const score =
|
|
142
152
|
response === null || isRecordValue(response)
|
|
143
153
|
? 0
|
|
144
|
-
: (values
|
|
154
|
+
: (values.get(mappingKey(String(response), baseType)) ?? mapping.defaultValue);
|
|
145
155
|
return clampMappedScore(score, mapping.attributes);
|
|
146
156
|
}
|
|
147
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
|
+
|
|
148
164
|
function clampMappedScore(score: number, attributes: Record<string, string>): number {
|
|
149
165
|
const lower = numericBound(attributes["lower-bound"]);
|
|
150
166
|
const upper = numericBound(attributes["upper-bound"]);
|
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
|
}
|