@longsightgroup/qti3-core 0.10.3 → 0.10.5
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 +35 -0
- 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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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-assets.d.ts +3 -2
- package/dist/qti-package-assets.d.ts.map +1 -1
- package/dist/qti-package-assets.js.map +1 -1
- package/dist/qti-package-items.d.ts +1 -0
- package/dist/qti-package-items.d.ts.map +1 -1
- package/dist/qti-package-items.js +55 -41
- package/dist/qti-package-items.js.map +1 -1
- package/dist/qti-package-manifest.d.ts +3 -2
- 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 +3 -2
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +8 -4
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-plan.d.ts +41 -0
- package/dist/qti-package-plan.d.ts.map +1 -0
- package/dist/qti-package-plan.js +85 -0
- package/dist/qti-package-plan.js.map +1 -0
- package/dist/qti-package-stream.d.ts +38 -0
- package/dist/qti-package-stream.d.ts.map +1 -0
- package/dist/qti-package-stream.js +149 -0
- package/dist/qti-package-stream.js.map +1 -0
- 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 +23 -47
- 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 +11 -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-assets.ts +3 -4
- package/src/qti-package-items.ts +101 -84
- package/src/qti-package-manifest.ts +15 -7
- package/src/qti-package-metadata.ts +19 -6
- package/src/qti-package-plan.ts +168 -0
- package/src/qti-package-stream.ts +254 -0
- package/src/qti-package-xml.ts +16 -3
- package/src/qti-package-zip.ts +128 -3
- package/src/qti-package.ts +30 -81
- 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";
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
resolvePackageHref,
|
|
15
15
|
type PackageXmlFile,
|
|
16
16
|
} from "./qti-package-xml.js";
|
|
17
|
-
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
18
17
|
import type { QtiDiagnostic } from "./types.js";
|
|
19
18
|
|
|
20
19
|
interface PendingAssetReference {
|
|
@@ -87,8 +86,8 @@ export function discoverContentAssetHrefs(
|
|
|
87
86
|
export function collectPackageAssets(
|
|
88
87
|
resourcesByIdentifier: ReadonlyMap<string, QtiManifestResource>,
|
|
89
88
|
assessmentTest: QtiAssessmentTestPackageModel | undefined,
|
|
90
|
-
items: readonly QtiPackageItem[],
|
|
91
|
-
entriesByPath:
|
|
89
|
+
items: readonly Pick<QtiPackageItem, "href" | "manifestResourceIdentifier" | "assetHrefs">[],
|
|
90
|
+
entriesByPath: { has(path: string): boolean },
|
|
92
91
|
diagnostics: QtiDiagnostic[],
|
|
93
92
|
): QtiPackageAsset[] {
|
|
94
93
|
const pending: PendingAssetReference[] = [];
|
|
@@ -220,7 +219,7 @@ function collectPackageRelativeTextRefs(
|
|
|
220
219
|
|
|
221
220
|
function materializeAssets(
|
|
222
221
|
pending: readonly PendingAssetReference[],
|
|
223
|
-
entriesByPath:
|
|
222
|
+
entriesByPath: { has(path: string): boolean },
|
|
224
223
|
): QtiPackageAsset[] {
|
|
225
224
|
const assetsByHref = new Map<string, PendingAssetReference[]>();
|
|
226
225
|
for (const reference of pending) {
|
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 },
|
|
@@ -342,6 +346,55 @@ export function itemReferencesForPackageShape(
|
|
|
342
346
|
return references;
|
|
343
347
|
}
|
|
344
348
|
|
|
349
|
+
export function parsePackageItem(
|
|
350
|
+
reference: PackageItemReference,
|
|
351
|
+
entry: QtiPackageEntry,
|
|
352
|
+
xmlFile: PackageXmlFile | undefined,
|
|
353
|
+
diagnostics: QtiDiagnostic[],
|
|
354
|
+
): QtiPackageItem {
|
|
355
|
+
const resolvedXml = xmlFile ?? parsePackageXml(entry);
|
|
356
|
+
// Full QTI parse for document model; lightweight tree above supplies metadata and asset refs.
|
|
357
|
+
const parsed = parseQtiXml(resolvedXml.xml);
|
|
358
|
+
const itemDiagnostics = parsed.diagnostics.map((diagnostic) =>
|
|
359
|
+
scopeDiagnosticToPackagePath(reference.href, diagnostic),
|
|
360
|
+
);
|
|
361
|
+
diagnostics.push(...itemDiagnostics);
|
|
362
|
+
|
|
363
|
+
const assetHrefs = resolvedXml.root ? discoverContentAssetHrefs(resolvedXml, diagnostics) : [];
|
|
364
|
+
const root =
|
|
365
|
+
resolvedXml.root?.localName === "qti-assessment-item" &&
|
|
366
|
+
resolvedXml.root.uri === QTI_ASI_NAMESPACE
|
|
367
|
+
? resolvedXml.root
|
|
368
|
+
: undefined;
|
|
369
|
+
const item = parsed.document?.item;
|
|
370
|
+
const timeLimits = root
|
|
371
|
+
? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], reference.href, diagnostics)
|
|
372
|
+
: undefined;
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
href: reference.href,
|
|
376
|
+
source: reference.source,
|
|
377
|
+
manifestResourceIdentifier: reference.manifestResourceIdentifier,
|
|
378
|
+
assessmentItemRefIdentifier: reference.assessmentItemRefIdentifier,
|
|
379
|
+
identifier: item?.identifier,
|
|
380
|
+
title: item?.title,
|
|
381
|
+
document: parsed.document,
|
|
382
|
+
timing: root ? parseTimingMetadata(root, reference.href, diagnostics, timeLimits) : undefined,
|
|
383
|
+
timeLimits,
|
|
384
|
+
standards: uniqueStandards([
|
|
385
|
+
...reference.standards.filter(
|
|
386
|
+
(standard) =>
|
|
387
|
+
standard.resourcePartIdentifier === undefined ||
|
|
388
|
+
standard.resourcePartIdentifier === item?.identifier,
|
|
389
|
+
),
|
|
390
|
+
...(root ? parseStandardAlignments(root, reference.href) : []),
|
|
391
|
+
]),
|
|
392
|
+
assetHrefs,
|
|
393
|
+
diagnostics: itemDiagnostics,
|
|
394
|
+
xml: resolvedXml.xml,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
345
398
|
export function parsePackageItems(
|
|
346
399
|
references: readonly PackageItemReference[],
|
|
347
400
|
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
@@ -376,48 +429,12 @@ export function parsePackageItems(
|
|
|
376
429
|
continue;
|
|
377
430
|
}
|
|
378
431
|
|
|
379
|
-
|
|
380
|
-
// Full QTI parse for document model; lightweight tree above supplies metadata and asset refs.
|
|
381
|
-
const parsed = parseQtiXml(xmlFile.xml);
|
|
382
|
-
const itemDiagnostics = parsed.diagnostics.map((diagnostic) =>
|
|
383
|
-
scopeDiagnosticToPackagePath(reference.href, diagnostic),
|
|
384
|
-
);
|
|
385
|
-
diagnostics.push(...itemDiagnostics);
|
|
386
|
-
|
|
387
|
-
const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
|
|
388
|
-
const root = xmlFile.root?.localName === "qti-assessment-item" ? xmlFile.root : undefined;
|
|
389
|
-
const item = parsed.document?.item;
|
|
390
|
-
const timeLimits = root
|
|
391
|
-
? parseTimeLimits(
|
|
392
|
-
childPackageElements(root, "qti-time-limits")[0],
|
|
393
|
-
reference.href,
|
|
394
|
-
diagnostics,
|
|
395
|
-
)
|
|
396
|
-
: undefined;
|
|
397
|
-
|
|
398
|
-
items.push({
|
|
399
|
-
href: reference.href,
|
|
400
|
-
source: reference.source,
|
|
401
|
-
manifestResourceIdentifier: reference.manifestResourceIdentifier,
|
|
402
|
-
assessmentItemRefIdentifier: reference.assessmentItemRefIdentifier,
|
|
403
|
-
identifier: item?.identifier,
|
|
404
|
-
title: item?.title,
|
|
405
|
-
document: parsed.document,
|
|
406
|
-
timing: root ? parseTimingMetadata(root, reference.href, diagnostics, timeLimits) : undefined,
|
|
407
|
-
timeLimits,
|
|
408
|
-
standards: uniqueStandards([
|
|
409
|
-
...reference.standards.filter(
|
|
410
|
-
(standard) =>
|
|
411
|
-
standard.resourcePartIdentifier === undefined ||
|
|
412
|
-
standard.resourcePartIdentifier === item?.identifier,
|
|
413
|
-
),
|
|
414
|
-
...(root ? parseStandardAlignments(root, reference.href) : []),
|
|
415
|
-
]),
|
|
416
|
-
assetHrefs,
|
|
417
|
-
diagnostics: itemDiagnostics,
|
|
418
|
-
xml: xmlFile.xml,
|
|
419
|
-
});
|
|
432
|
+
items.push(parsePackageItem(reference, entry, xmlFilesByPath.get(reference.href), diagnostics));
|
|
420
433
|
}
|
|
421
434
|
|
|
422
435
|
return items;
|
|
423
436
|
}
|
|
437
|
+
|
|
438
|
+
function qtiChildElements(node: QtiPackageXmlNode, localName: string): QtiPackageXmlNode[] {
|
|
439
|
+
return childPackageElements(node, localName, QTI_ASI_NAMESPACE);
|
|
440
|
+
}
|