@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
package/src/qti-package-zip.ts
CHANGED
|
@@ -7,17 +7,40 @@ export interface QtiPackageInflateContext {
|
|
|
7
7
|
readonly compressedSize: number;
|
|
8
8
|
readonly uncompressedSize: number;
|
|
9
9
|
readonly compressionMethod: number;
|
|
10
|
+
/** Maximum output bytes the inflater may materialize for this entry. */
|
|
11
|
+
readonly maxOutputLength: number;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Optional raw DEFLATE inflater for ZIP entries compressed with method 8.
|
|
16
|
+
* Implementations must enforce `context.maxOutputLength` while expanding input.
|
|
17
|
+
*/
|
|
13
18
|
export type QtiPackageInflateRaw = (
|
|
14
19
|
bytes: Uint8Array,
|
|
15
20
|
context: QtiPackageInflateContext,
|
|
16
21
|
) => Uint8Array;
|
|
17
22
|
|
|
23
|
+
/** Resource budgets applied before and after QTI package entry expansion. */
|
|
24
|
+
export interface QtiPackageResourceLimits {
|
|
25
|
+
readonly maxEntries: number;
|
|
26
|
+
readonly maxEntryUncompressedBytes: number;
|
|
27
|
+
readonly maxTotalUncompressedBytes: number;
|
|
28
|
+
readonly maxCompressionRatio: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Conservative default budgets for untrusted QTI package ZIP input. */
|
|
32
|
+
export const DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS: QtiPackageResourceLimits = Object.freeze({
|
|
33
|
+
maxEntries: 4_096,
|
|
34
|
+
maxEntryUncompressedBytes: 128 * 1_024 * 1_024,
|
|
35
|
+
maxTotalUncompressedBytes: 512 * 1_024 * 1_024,
|
|
36
|
+
maxCompressionRatio: 200,
|
|
37
|
+
});
|
|
38
|
+
|
|
18
39
|
/** Options for parsing a QTI ZIP package. */
|
|
19
40
|
export interface QtiPackageParseOptions {
|
|
20
41
|
readonly inflateRaw?: QtiPackageInflateRaw | undefined;
|
|
42
|
+
/** Overrides for the default package resource budgets. */
|
|
43
|
+
readonly limits?: Partial<QtiPackageResourceLimits> | undefined;
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
/** Decoded ZIP entry from a QTI package. */
|
|
@@ -88,8 +111,19 @@ function readZipEntriesUnsafe(
|
|
|
88
111
|
return [];
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
const limits = resolveResourceLimits(options.limits);
|
|
91
115
|
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
116
|
+
if (entryCount > limits.maxEntries) {
|
|
117
|
+
pushPackageDiagnostic(
|
|
118
|
+
diagnostics,
|
|
119
|
+
"package.zip.limit.entries",
|
|
120
|
+
"error",
|
|
121
|
+
`QTI package ZIP contains ${entryCount} entries; the configured limit is ${limits.maxEntries}.`,
|
|
122
|
+
);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
92
125
|
let offset = view.getUint32(eocdOffset + 16, true);
|
|
126
|
+
let totalUncompressedBytes = 0;
|
|
93
127
|
const entries: QtiPackageEntry[] = [];
|
|
94
128
|
|
|
95
129
|
for (let index = 0; index < entryCount; index += 1) {
|
|
@@ -116,6 +150,8 @@ function readZipEntriesUnsafe(
|
|
|
116
150
|
const compressionMethod = view.getUint16(offset + 10, true);
|
|
117
151
|
const compressedSize = view.getUint32(offset + 20, true);
|
|
118
152
|
const uncompressedSize = view.getUint32(offset + 24, true);
|
|
153
|
+
const declaredExpandedSize =
|
|
154
|
+
compressionMethod === 0 ? Math.max(compressedSize, uncompressedSize) : uncompressedSize;
|
|
119
155
|
const nameLength = view.getUint16(offset + 28, true);
|
|
120
156
|
const extraLength = view.getUint16(offset + 30, true);
|
|
121
157
|
const commentLength = view.getUint16(offset + 32, true);
|
|
@@ -137,6 +173,37 @@ function readZipEntriesUnsafe(
|
|
|
137
173
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
138
174
|
if (decodedName.endsWith("/")) continue;
|
|
139
175
|
|
|
176
|
+
if (declaredExpandedSize > limits.maxEntryUncompressedBytes) {
|
|
177
|
+
pushPackageDiagnostic(
|
|
178
|
+
diagnostics,
|
|
179
|
+
"package.zip.limit.entrySize",
|
|
180
|
+
"error",
|
|
181
|
+
`ZIP entry ${decodedName} declares ${declaredExpandedSize} expanded bytes; the configured per-entry limit is ${limits.maxEntryUncompressedBytes}.`,
|
|
182
|
+
decodedName,
|
|
183
|
+
);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (exceedsCompressionRatio(compressionMethod, compressedSize, uncompressedSize, limits)) {
|
|
187
|
+
pushPackageDiagnostic(
|
|
188
|
+
diagnostics,
|
|
189
|
+
"package.zip.limit.compressionRatio",
|
|
190
|
+
"error",
|
|
191
|
+
`ZIP entry ${decodedName} exceeds the configured compression ratio limit of ${limits.maxCompressionRatio}.`,
|
|
192
|
+
decodedName,
|
|
193
|
+
);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (totalUncompressedBytes + declaredExpandedSize > limits.maxTotalUncompressedBytes) {
|
|
197
|
+
pushPackageDiagnostic(
|
|
198
|
+
diagnostics,
|
|
199
|
+
"package.zip.limit.totalSize",
|
|
200
|
+
"error",
|
|
201
|
+
`QTI package ZIP exceeds the configured total uncompressed size limit of ${limits.maxTotalUncompressedBytes} bytes.`,
|
|
202
|
+
);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
totalUncompressedBytes += declaredExpandedSize;
|
|
206
|
+
|
|
140
207
|
const path = normalizePackagePath(decodedName, "ZIP entry", diagnostics);
|
|
141
208
|
if (!path) continue;
|
|
142
209
|
|
|
@@ -151,6 +218,7 @@ function readZipEntriesUnsafe(
|
|
|
151
218
|
localHeaderOffset,
|
|
152
219
|
},
|
|
153
220
|
options,
|
|
221
|
+
limits,
|
|
154
222
|
diagnostics,
|
|
155
223
|
);
|
|
156
224
|
if (content) entries.push({ path, bytes: content });
|
|
@@ -170,6 +238,7 @@ function zipEntryBytes(
|
|
|
170
238
|
readonly localHeaderOffset: number;
|
|
171
239
|
},
|
|
172
240
|
options: QtiPackageParseOptions,
|
|
241
|
+
limits: QtiPackageResourceLimits,
|
|
173
242
|
diagnostics: QtiDiagnostic[],
|
|
174
243
|
): Uint8Array | undefined {
|
|
175
244
|
if (entry.localHeaderOffset + 30 > view.byteLength) {
|
|
@@ -210,7 +279,9 @@ function zipEntryBytes(
|
|
|
210
279
|
}
|
|
211
280
|
|
|
212
281
|
const compressed = bytes.slice(dataOffset, dataEnd);
|
|
213
|
-
if (entry.compressionMethod === 0)
|
|
282
|
+
if (entry.compressionMethod === 0) {
|
|
283
|
+
return validateExpandedSize(compressed, entry, limits, diagnostics);
|
|
284
|
+
}
|
|
214
285
|
|
|
215
286
|
if (entry.compressionMethod === 8) {
|
|
216
287
|
const inflateRaw = options.inflateRaw;
|
|
@@ -226,12 +297,14 @@ function zipEntryBytes(
|
|
|
226
297
|
}
|
|
227
298
|
|
|
228
299
|
try {
|
|
229
|
-
|
|
300
|
+
const expanded = inflateRaw(compressed, {
|
|
230
301
|
path: entry.path,
|
|
231
302
|
compressedSize: entry.compressedSize,
|
|
232
303
|
uncompressedSize: entry.uncompressedSize,
|
|
233
304
|
compressionMethod: entry.compressionMethod,
|
|
305
|
+
maxOutputLength: Math.max(1, entry.uncompressedSize),
|
|
234
306
|
});
|
|
307
|
+
return validateExpandedSize(expanded, entry, limits, diagnostics);
|
|
235
308
|
} catch {
|
|
236
309
|
pushPackageDiagnostic(
|
|
237
310
|
diagnostics,
|
|
@@ -254,6 +327,58 @@ function zipEntryBytes(
|
|
|
254
327
|
return undefined;
|
|
255
328
|
}
|
|
256
329
|
|
|
330
|
+
function resolveResourceLimits(
|
|
331
|
+
overrides: QtiPackageParseOptions["limits"],
|
|
332
|
+
): QtiPackageResourceLimits {
|
|
333
|
+
return {
|
|
334
|
+
maxEntries: overrides?.maxEntries ?? DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxEntries,
|
|
335
|
+
maxEntryUncompressedBytes:
|
|
336
|
+
overrides?.maxEntryUncompressedBytes ??
|
|
337
|
+
DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxEntryUncompressedBytes,
|
|
338
|
+
maxTotalUncompressedBytes:
|
|
339
|
+
overrides?.maxTotalUncompressedBytes ??
|
|
340
|
+
DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxTotalUncompressedBytes,
|
|
341
|
+
maxCompressionRatio:
|
|
342
|
+
overrides?.maxCompressionRatio ?? DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxCompressionRatio,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function exceedsCompressionRatio(
|
|
347
|
+
compressionMethod: number,
|
|
348
|
+
compressedSize: number,
|
|
349
|
+
uncompressedSize: number,
|
|
350
|
+
limits: QtiPackageResourceLimits,
|
|
351
|
+
): boolean {
|
|
352
|
+
if (compressionMethod !== 8 || uncompressedSize === 0) return false;
|
|
353
|
+
if (compressedSize === 0) return true;
|
|
354
|
+
return uncompressedSize / compressedSize > limits.maxCompressionRatio;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function validateExpandedSize(
|
|
358
|
+
expanded: Uint8Array,
|
|
359
|
+
entry: {
|
|
360
|
+
readonly path: string;
|
|
361
|
+
readonly uncompressedSize: number;
|
|
362
|
+
},
|
|
363
|
+
limits: QtiPackageResourceLimits,
|
|
364
|
+
diagnostics: QtiDiagnostic[],
|
|
365
|
+
): Uint8Array | undefined {
|
|
366
|
+
if (
|
|
367
|
+
expanded.length === entry.uncompressedSize &&
|
|
368
|
+
expanded.length <= limits.maxEntryUncompressedBytes
|
|
369
|
+
) {
|
|
370
|
+
return expanded;
|
|
371
|
+
}
|
|
372
|
+
pushPackageDiagnostic(
|
|
373
|
+
diagnostics,
|
|
374
|
+
"package.zip.entry.size",
|
|
375
|
+
"error",
|
|
376
|
+
`ZIP entry ${entry.path} expanded to ${expanded.length} bytes; expected ${entry.uncompressedSize}.`,
|
|
377
|
+
entry.path,
|
|
378
|
+
);
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
257
382
|
function findEndOfCentralDirectory(view: DataView): number {
|
|
258
383
|
const minimumOffset = Math.max(0, view.byteLength - 65557);
|
|
259
384
|
for (let offset = view.byteLength - 22; offset >= minimumOffset; offset -= 1) {
|
package/src/qti-package.ts
CHANGED
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
uniqueStandards,
|
|
23
23
|
} from "./qti-package-metadata.js";
|
|
24
24
|
import type { QtiPackageParseResult } from "./qti-package-types.js";
|
|
25
|
+
import { QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
|
|
25
26
|
import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
|
|
26
27
|
import {
|
|
28
|
+
DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
|
|
27
29
|
decodeUtf8,
|
|
28
30
|
readQtiPackageZipEntries,
|
|
29
31
|
type QtiPackageEntry,
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
} from "./qti-package-zip.js";
|
|
32
34
|
import type { QtiDiagnostic } from "./types.js";
|
|
33
35
|
|
|
34
|
-
export { decodeUtf8, readQtiPackageZipEntries };
|
|
36
|
+
export { DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS, decodeUtf8, readQtiPackageZipEntries };
|
|
35
37
|
export { discoverQtiPackageContentAssets } from "./qti-package-assets.js";
|
|
36
38
|
export { normalizePackagePath } from "./qti-package-paths.js";
|
|
37
39
|
export { QTI_ITEM_RESOURCE_TYPE, QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
|
|
@@ -63,6 +65,7 @@ export type {
|
|
|
63
65
|
QtiPackageInflateContext,
|
|
64
66
|
QtiPackageInflateRaw,
|
|
65
67
|
QtiPackageParseOptions,
|
|
68
|
+
QtiPackageResourceLimits,
|
|
66
69
|
} from "./qti-package-zip.js";
|
|
67
70
|
|
|
68
71
|
/** Parse a QTI ZIP package into a neutral manifest, item, asset, and diagnostic model. */
|
|
@@ -96,13 +99,19 @@ function parseQtiPackageEntries(
|
|
|
96
99
|
pushXmlDiagnostics(manifestXml, diagnostics);
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
const manifestRoot =
|
|
102
|
+
const manifestRoot =
|
|
103
|
+
manifestXml?.root?.localName === "manifest" &&
|
|
104
|
+
manifestXml.root.uri === QTI_PACKAGE_MANIFEST_NAMESPACE
|
|
105
|
+
? manifestXml.root
|
|
106
|
+
: undefined;
|
|
100
107
|
if (manifestXml?.root && !manifestRoot) {
|
|
101
108
|
pushPackageDiagnostic(
|
|
102
109
|
diagnostics,
|
|
103
110
|
"package.manifest.root",
|
|
104
111
|
"error",
|
|
105
|
-
|
|
112
|
+
manifestXml.root.localName === "manifest"
|
|
113
|
+
? `Expected imsmanifest.xml manifest in namespace ${QTI_PACKAGE_MANIFEST_NAMESPACE}, found ${manifestXml.root.uri ?? "(none)"}.`
|
|
114
|
+
: `Expected imsmanifest.xml root manifest, found ${manifestXml.root.localName}.`,
|
|
106
115
|
QTI_PACKAGE_MANIFEST_PATH,
|
|
107
116
|
);
|
|
108
117
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QtiChoice, QtiDiagnostic, QtiInteraction, QtiValue } from "./types.js";
|
|
2
|
+
import { parseQtiMediaDefinition } from "./media-definition.js";
|
|
2
3
|
import { parseXmlBoolean } from "./parser-values.js";
|
|
3
4
|
import { parseNonNegativeInteger } from "./validation-primitives.js";
|
|
4
5
|
import { qtiValueToIdentifierList } from "./value-format.js";
|
|
@@ -75,14 +76,13 @@ export function mediaPlayCount(value: QtiValue): number {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function minimumMediaPlays(interaction: QtiInteraction): number {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
return interactionRequiresResponse(interaction) ? 1 : 0;
|
|
79
|
+
const definition = parseQtiMediaDefinition(interaction);
|
|
80
|
+
return definition.ok ? definition.value.minPlays : 0;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function maximumMediaPlays(interaction: QtiInteraction): number | undefined {
|
|
84
|
-
const
|
|
85
|
-
return
|
|
84
|
+
const definition = parseQtiMediaDefinition(interaction);
|
|
85
|
+
return definition.ok ? definition.value.maxPlays : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export { maximumMediaPlays, minimumMediaPlays };
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
QtiAssessmentItem,
|
|
3
|
+
QtiBaseType,
|
|
3
4
|
QtiCardinality,
|
|
4
5
|
QtiDiagnostic,
|
|
5
6
|
QtiInteraction,
|
|
6
7
|
QtiResponseDeclaration,
|
|
8
|
+
QtiScalarValue,
|
|
7
9
|
QtiValue,
|
|
8
10
|
} from "./types.js";
|
|
9
11
|
import { assertNever } from "./assert-never.js";
|
|
10
|
-
import { isNullResponse, isRecordValue } from "./processing-values.js";
|
|
12
|
+
import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
|
|
11
13
|
import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
|
|
12
14
|
import {
|
|
13
15
|
matchMaxDiagnostics,
|
|
@@ -20,7 +22,9 @@ import {
|
|
|
20
22
|
responseLimitAttribute,
|
|
21
23
|
responseValidationPolicy,
|
|
22
24
|
} from "./response-validation-policy.js";
|
|
23
|
-
import {
|
|
25
|
+
import { parseQtiSliderDefinition, parseQtiSliderValue } from "./slider-definition.js";
|
|
26
|
+
import { qtiValueToString, readQtiJsonValue } from "./value-format.js";
|
|
27
|
+
import { parseQtiScalarForBaseType } from "./validation-primitives.js";
|
|
24
28
|
|
|
25
29
|
export type { QtiNamedResponseInput as QtiResponseVariableInput } from "./response-input.js";
|
|
26
30
|
export type QtiResponseVariablesInput = Record<string, unknown> | readonly QtiNamedResponseInput[];
|
|
@@ -30,6 +34,8 @@ export type QtiResponseValidationDiagnosticCode =
|
|
|
30
34
|
| "response.maximum"
|
|
31
35
|
| "response.matchMax"
|
|
32
36
|
| "response.cardinality"
|
|
37
|
+
| "response.baseType"
|
|
38
|
+
| "response.domain"
|
|
33
39
|
| "response.undeclared"
|
|
34
40
|
| "response.identifier.required"
|
|
35
41
|
| "response.value.invalid";
|
|
@@ -44,6 +50,17 @@ export interface QtiResponseValidationResult {
|
|
|
44
50
|
diagnostics: QtiResponseValidationDiagnostic[];
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
type QtiResponseVariablesParseResult =
|
|
54
|
+
| {
|
|
55
|
+
ok: true;
|
|
56
|
+
diagnostics: QtiResponseValidationDiagnostic[];
|
|
57
|
+
responses: Record<string, QtiValue>;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
ok: false;
|
|
61
|
+
diagnostics: QtiResponseValidationDiagnostic[];
|
|
62
|
+
};
|
|
63
|
+
|
|
47
64
|
export interface QtiResponseValidationInput {
|
|
48
65
|
item: QtiAssessmentItem;
|
|
49
66
|
responses: QtiResponseVariablesInput;
|
|
@@ -56,6 +73,14 @@ export interface QtiResponseValidationInput {
|
|
|
56
73
|
export function validateQtiResponseVariables(
|
|
57
74
|
input: QtiResponseValidationInput,
|
|
58
75
|
): QtiResponseValidationResult {
|
|
76
|
+
const result = parseQtiResponseVariables(input);
|
|
77
|
+
return { ok: result.ok, diagnostics: result.diagnostics };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse and normalize submitted response variables for trusted boundary application. */
|
|
81
|
+
export function parseQtiResponseVariables(
|
|
82
|
+
input: QtiResponseValidationInput,
|
|
83
|
+
): QtiResponseVariablesParseResult {
|
|
59
84
|
const diagnostics: QtiResponseValidationDiagnostic[] = [];
|
|
60
85
|
const declaredIdentifiers = new Set(
|
|
61
86
|
input.item.responseDeclarations.map((declaration) => declaration.identifier),
|
|
@@ -81,11 +106,16 @@ export function validateQtiResponseVariables(
|
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
const value = responses.get(declaration.identifier);
|
|
109
|
+
const interactions = interactionsByResponse.get(declaration.identifier);
|
|
84
110
|
if (value !== undefined) {
|
|
85
|
-
validateResponseCardinality(declaration, value, diagnostics);
|
|
111
|
+
const cardinalityMatches = validateResponseCardinality(declaration, value, diagnostics);
|
|
112
|
+
const parsedValue = parseResponseBaseType(declaration, value, diagnostics);
|
|
113
|
+
if (cardinalityMatches && parsedValue !== undefined) {
|
|
114
|
+
responses.set(declaration.identifier, parsedValue);
|
|
115
|
+
validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
|
|
116
|
+
}
|
|
86
117
|
}
|
|
87
118
|
|
|
88
|
-
const interactions = interactionsByResponse.get(declaration.identifier);
|
|
89
119
|
if (interactions === undefined) {
|
|
90
120
|
validateDeclarationResponse(
|
|
91
121
|
declaration,
|
|
@@ -108,10 +138,10 @@ export function validateQtiResponseVariables(
|
|
|
108
138
|
}
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
|
|
112
|
-
ok: diagnostics
|
|
113
|
-
|
|
114
|
-
};
|
|
141
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
142
|
+
return { ok: false, diagnostics };
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, diagnostics, responses: Object.fromEntries(responses) };
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
function ingestSubmittedResponses(
|
|
@@ -146,15 +176,16 @@ function ingestSubmittedResponses(
|
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
if (!declaredIdentifiers.has(identifier)) {
|
|
149
|
-
if (allowedUndeclaredIdentifiers.has(identifier))
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
179
|
+
if (!allowedUndeclaredIdentifiers.has(identifier)) {
|
|
180
|
+
diagnostics.push({
|
|
181
|
+
code: "response.undeclared",
|
|
182
|
+
severity: "error",
|
|
183
|
+
identifier,
|
|
184
|
+
message: `Response ${identifier} is not declared by the assessment item.`,
|
|
185
|
+
path: identifier,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
158
189
|
}
|
|
159
190
|
|
|
160
191
|
responses.set(identifier, value);
|
|
@@ -184,10 +215,10 @@ function validateResponseCardinality(
|
|
|
184
215
|
declaration: QtiResponseDeclaration,
|
|
185
216
|
value: QtiValue,
|
|
186
217
|
diagnostics: QtiResponseValidationDiagnostic[],
|
|
187
|
-
):
|
|
188
|
-
if (value === null) return;
|
|
218
|
+
): boolean {
|
|
219
|
+
if (value === null) return true;
|
|
189
220
|
|
|
190
|
-
if (matchesCardinality(declaration.cardinality, value)) return;
|
|
221
|
+
if (matchesCardinality(declaration.cardinality, value)) return true;
|
|
191
222
|
diagnostics.push({
|
|
192
223
|
code: "response.cardinality",
|
|
193
224
|
severity: "error",
|
|
@@ -196,6 +227,138 @@ function validateResponseCardinality(
|
|
|
196
227
|
path: declaration.identifier,
|
|
197
228
|
source: declaration.source,
|
|
198
229
|
});
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseResponseBaseType(
|
|
234
|
+
declaration: QtiResponseDeclaration,
|
|
235
|
+
value: QtiValue,
|
|
236
|
+
diagnostics: QtiResponseValidationDiagnostic[],
|
|
237
|
+
): QtiValue | undefined {
|
|
238
|
+
const baseType = declaration.baseType;
|
|
239
|
+
if (value === null || baseType === undefined || isRecordValue(value)) return value;
|
|
240
|
+
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
const parsedValues: QtiScalarValue[] = [];
|
|
243
|
+
for (const entry of value) {
|
|
244
|
+
const parsedValue = parseQtiScalarForBaseType(entry, baseType);
|
|
245
|
+
if (parsedValue === undefined) {
|
|
246
|
+
pushResponseBaseTypeDiagnostic(declaration, entry, baseType, diagnostics);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
parsedValues.push(parsedValue);
|
|
250
|
+
}
|
|
251
|
+
return parsedValues;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const parsedValue = parseQtiScalarForBaseType(value, baseType);
|
|
255
|
+
if (parsedValue !== undefined) return parsedValue;
|
|
256
|
+
pushResponseBaseTypeDiagnostic(declaration, value, baseType, diagnostics);
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pushResponseBaseTypeDiagnostic(
|
|
261
|
+
declaration: QtiResponseDeclaration,
|
|
262
|
+
value: QtiScalarValue,
|
|
263
|
+
baseType: QtiBaseType,
|
|
264
|
+
diagnostics: QtiResponseValidationDiagnostic[],
|
|
265
|
+
): void {
|
|
266
|
+
diagnostics.push({
|
|
267
|
+
code: "response.baseType",
|
|
268
|
+
severity: "error",
|
|
269
|
+
identifier: declaration.identifier,
|
|
270
|
+
message: `Response ${declaration.identifier} value ${qtiValueToString(value)} is not valid for base-type ${baseType}.`,
|
|
271
|
+
path: declaration.identifier,
|
|
272
|
+
source: declaration.source,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateResponseDomain(
|
|
277
|
+
declaration: QtiResponseDeclaration,
|
|
278
|
+
interactions: readonly QtiInteraction[],
|
|
279
|
+
value: QtiValue,
|
|
280
|
+
diagnostics: QtiResponseValidationDiagnostic[],
|
|
281
|
+
): void {
|
|
282
|
+
if (value === null) return;
|
|
283
|
+
const interactionsWithChoices = interactions.filter((interaction) =>
|
|
284
|
+
interaction.choices.some((choice) => choice.identifier.length > 0),
|
|
285
|
+
);
|
|
286
|
+
const baseType = declaration.baseType;
|
|
287
|
+
if (
|
|
288
|
+
interactionsWithChoices.length > 0 &&
|
|
289
|
+
(baseType === "identifier" || baseType === "pair" || baseType === "directedPair")
|
|
290
|
+
) {
|
|
291
|
+
const invalidValue = valueContainer(value).find(
|
|
292
|
+
(entry) =>
|
|
293
|
+
typeof entry !== "string" ||
|
|
294
|
+
!interactionsWithChoices.some((interaction) =>
|
|
295
|
+
interactionAcceptsResponseValue(interaction, baseType, entry),
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
if (invalidValue !== undefined) {
|
|
299
|
+
pushResponseDomainDiagnostic(declaration, invalidValue, diagnostics);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const interaction of interactions) {
|
|
305
|
+
if (interaction.type !== "slider") continue;
|
|
306
|
+
const definition = parseQtiSliderDefinition(interaction);
|
|
307
|
+
if (!definition.ok || parseQtiSliderValue(value, definition.value).ok) continue;
|
|
308
|
+
pushResponseDomainDiagnostic(declaration, value, diagnostics);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function interactionAcceptsResponseValue(
|
|
314
|
+
interaction: QtiInteraction,
|
|
315
|
+
baseType: "identifier" | "pair" | "directedPair",
|
|
316
|
+
value: string,
|
|
317
|
+
): boolean {
|
|
318
|
+
const parts = value.trim().split(/\s+/);
|
|
319
|
+
const identifiers = new Set(
|
|
320
|
+
interaction.choices.flatMap((choice) => (choice.identifier ? [choice.identifier] : [])),
|
|
321
|
+
);
|
|
322
|
+
if (baseType === "identifier") return parts.length === 1 && identifiers.has(parts[0] ?? "");
|
|
323
|
+
if (parts.length !== 2) return false;
|
|
324
|
+
|
|
325
|
+
const sourceIdentifiers = choiceIdentifiersForRoles(interaction, ["matchSource", "gapChoice"]);
|
|
326
|
+
const targetIdentifiers = choiceIdentifiersForRoles(interaction, [
|
|
327
|
+
"matchTarget",
|
|
328
|
+
"gap",
|
|
329
|
+
"hotspot",
|
|
330
|
+
]);
|
|
331
|
+
if (baseType === "directedPair" && sourceIdentifiers.size > 0 && targetIdentifiers.size > 0) {
|
|
332
|
+
return sourceIdentifiers.has(parts[0] ?? "") && targetIdentifiers.has(parts[1] ?? "");
|
|
333
|
+
}
|
|
334
|
+
return parts.every((part) => identifiers.has(part));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function choiceIdentifiersForRoles(
|
|
338
|
+
interaction: QtiInteraction,
|
|
339
|
+
roles: readonly QtiInteraction["choices"][number]["role"][],
|
|
340
|
+
): Set<string> {
|
|
341
|
+
const acceptedRoles = new Set(roles);
|
|
342
|
+
return new Set(
|
|
343
|
+
interaction.choices.flatMap((choice) =>
|
|
344
|
+
choice.identifier && acceptedRoles.has(choice.role) ? [choice.identifier] : [],
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function pushResponseDomainDiagnostic(
|
|
350
|
+
declaration: QtiResponseDeclaration,
|
|
351
|
+
value: QtiValue,
|
|
352
|
+
diagnostics: QtiResponseValidationDiagnostic[],
|
|
353
|
+
): void {
|
|
354
|
+
diagnostics.push({
|
|
355
|
+
code: "response.domain",
|
|
356
|
+
severity: "error",
|
|
357
|
+
identifier: declaration.identifier,
|
|
358
|
+
message: `Response ${declaration.identifier} value ${qtiValueToString(value)} is not in the authored interaction domain.`,
|
|
359
|
+
path: declaration.identifier,
|
|
360
|
+
source: declaration.source,
|
|
361
|
+
});
|
|
199
362
|
}
|
|
200
363
|
|
|
201
364
|
function matchesCardinality(cardinality: QtiCardinality, value: QtiValue): boolean {
|
|
@@ -291,6 +454,8 @@ function isResponseValidationDiagnosticCode(
|
|
|
291
454
|
code === "response.maximum" ||
|
|
292
455
|
code === "response.matchMax" ||
|
|
293
456
|
code === "response.cardinality" ||
|
|
457
|
+
code === "response.baseType" ||
|
|
458
|
+
code === "response.domain" ||
|
|
294
459
|
code === "response.undeclared" ||
|
|
295
460
|
code === "response.identifier.required" ||
|
|
296
461
|
code === "response.value.invalid"
|
package/src/session.ts
CHANGED
|
@@ -410,6 +410,7 @@ function applyResponseProcessing(context: SessionProcessingContext): void {
|
|
|
410
410
|
evaluation.responses.RESPONSE ?? null,
|
|
411
411
|
evaluation.correctResponses.RESPONSE ?? null,
|
|
412
412
|
declaration.cardinality === "ordered",
|
|
413
|
+
declaration.baseType,
|
|
413
414
|
)
|
|
414
415
|
: null;
|
|
415
416
|
evaluation.outcomes.SCORE = matches === true ? 1 : 0;
|