@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.
Files changed (131) hide show
  1. package/README.md +35 -0
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +2 -27
  4. package/dist/attempt-state.js.map +1 -1
  5. package/dist/content-text.d.ts.map +1 -1
  6. package/dist/content-text.js +31 -9
  7. package/dist/content-text.js.map +1 -1
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/package-xml.d.ts +1 -0
  13. package/dist/package-xml.d.ts.map +1 -1
  14. package/dist/package-xml.js +1 -0
  15. package/dist/package-xml.js.map +1 -1
  16. package/dist/parser-custom-interactions.js +1 -0
  17. package/dist/parser-custom-interactions.js.map +1 -1
  18. package/dist/parser-item-metadata.d.ts.map +1 -1
  19. package/dist/parser-item-metadata.js +1 -0
  20. package/dist/parser-item-metadata.js.map +1 -1
  21. package/dist/parser-values.d.ts +5 -1
  22. package/dist/parser-values.d.ts.map +1 -1
  23. package/dist/parser-values.js +31 -5
  24. package/dist/parser-values.js.map +1 -1
  25. package/dist/parser.d.ts.map +1 -1
  26. package/dist/parser.js +65 -34
  27. package/dist/parser.js.map +1 -1
  28. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  29. package/dist/processing-evaluator-comparison.js +16 -6
  30. package/dist/processing-evaluator-comparison.js.map +1 -1
  31. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  32. package/dist/processing-evaluator-variable.js +1 -1
  33. package/dist/processing-evaluator-variable.js.map +1 -1
  34. package/dist/processing-mapping.d.ts.map +1 -1
  35. package/dist/processing-mapping.js +15 -7
  36. package/dist/processing-mapping.js.map +1 -1
  37. package/dist/processing-values.d.ts +4 -4
  38. package/dist/processing-values.d.ts.map +1 -1
  39. package/dist/processing-values.js +28 -14
  40. package/dist/processing-values.js.map +1 -1
  41. package/dist/processing-variables.d.ts +3 -1
  42. package/dist/processing-variables.d.ts.map +1 -1
  43. package/dist/processing-variables.js +38 -2
  44. package/dist/processing-variables.js.map +1 -1
  45. package/dist/qti-namespaces.d.ts +7 -0
  46. package/dist/qti-namespaces.d.ts.map +1 -0
  47. package/dist/qti-namespaces.js +7 -0
  48. package/dist/qti-namespaces.js.map +1 -0
  49. package/dist/qti-package-assets.d.ts +3 -2
  50. package/dist/qti-package-assets.d.ts.map +1 -1
  51. package/dist/qti-package-assets.js.map +1 -1
  52. package/dist/qti-package-items.d.ts +1 -0
  53. package/dist/qti-package-items.d.ts.map +1 -1
  54. package/dist/qti-package-items.js +55 -41
  55. package/dist/qti-package-items.js.map +1 -1
  56. package/dist/qti-package-manifest.d.ts +3 -2
  57. package/dist/qti-package-manifest.d.ts.map +1 -1
  58. package/dist/qti-package-manifest.js +4 -3
  59. package/dist/qti-package-manifest.js.map +1 -1
  60. package/dist/qti-package-metadata.d.ts +3 -2
  61. package/dist/qti-package-metadata.d.ts.map +1 -1
  62. package/dist/qti-package-metadata.js +8 -4
  63. package/dist/qti-package-metadata.js.map +1 -1
  64. package/dist/qti-package-plan.d.ts +41 -0
  65. package/dist/qti-package-plan.d.ts.map +1 -0
  66. package/dist/qti-package-plan.js +85 -0
  67. package/dist/qti-package-plan.js.map +1 -0
  68. package/dist/qti-package-stream.d.ts +38 -0
  69. package/dist/qti-package-stream.d.ts.map +1 -0
  70. package/dist/qti-package-stream.js +149 -0
  71. package/dist/qti-package-stream.js.map +1 -0
  72. package/dist/qti-package-xml.d.ts +4 -2
  73. package/dist/qti-package-xml.d.ts.map +1 -1
  74. package/dist/qti-package-xml.js +10 -5
  75. package/dist/qti-package-xml.js.map +1 -1
  76. package/dist/qti-package-zip.d.ts +17 -1
  77. package/dist/qti-package-zip.d.ts.map +1 -1
  78. package/dist/qti-package-zip.js +60 -5
  79. package/dist/qti-package-zip.js.map +1 -1
  80. package/dist/qti-package.d.ts +3 -3
  81. package/dist/qti-package.d.ts.map +1 -1
  82. package/dist/qti-package.js +23 -47
  83. package/dist/qti-package.js.map +1 -1
  84. package/dist/response-validation.d.ts +12 -2
  85. package/dist/response-validation.d.ts.map +1 -1
  86. package/dist/response-validation.js +122 -19
  87. package/dist/response-validation.js.map +1 -1
  88. package/dist/session.js +1 -1
  89. package/dist/session.js.map +1 -1
  90. package/dist/trusted-item-session.d.ts.map +1 -1
  91. package/dist/trusted-item-session.js +5 -3
  92. package/dist/trusted-item-session.js.map +1 -1
  93. package/dist/types.d.ts +2 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/validation-primitives.d.ts +5 -1
  96. package/dist/validation-primitives.d.ts.map +1 -1
  97. package/dist/validation-primitives.js +40 -2
  98. package/dist/validation-primitives.js.map +1 -1
  99. package/dist/xml.d.ts.map +1 -1
  100. package/dist/xml.js +28 -0
  101. package/dist/xml.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/attempt-state.ts +2 -26
  104. package/src/content-text.ts +45 -9
  105. package/src/index.ts +11 -0
  106. package/src/package-xml.ts +2 -0
  107. package/src/parser-custom-interactions.ts +1 -0
  108. package/src/parser-item-metadata.ts +1 -0
  109. package/src/parser-values.ts +31 -5
  110. package/src/parser.ts +122 -91
  111. package/src/processing-evaluator-comparison.ts +24 -5
  112. package/src/processing-evaluator-variable.ts +1 -0
  113. package/src/processing-mapping.ts +22 -6
  114. package/src/processing-values.ts +59 -14
  115. package/src/processing-variables.ts +47 -2
  116. package/src/qti-namespaces.ts +8 -0
  117. package/src/qti-package-assets.ts +3 -4
  118. package/src/qti-package-items.ts +101 -84
  119. package/src/qti-package-manifest.ts +15 -7
  120. package/src/qti-package-metadata.ts +19 -6
  121. package/src/qti-package-plan.ts +168 -0
  122. package/src/qti-package-stream.ts +254 -0
  123. package/src/qti-package-xml.ts +16 -3
  124. package/src/qti-package-zip.ts +128 -3
  125. package/src/qti-package.ts +30 -81
  126. package/src/response-validation.ts +185 -20
  127. package/src/session.ts +1 -0
  128. package/src/trusted-item-session.ts +5 -3
  129. package/src/types.ts +2 -0
  130. package/src/validation-primitives.ts +43 -3
  131. package/src/xml.ts +37 -0
@@ -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
- /** Optional raw DEFLATE inflater for ZIP entries compressed with method 8. */
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) return compressed;
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
- return inflateRaw(compressed, {
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) {
@@ -1,29 +1,15 @@
1
1
  import { pushPackageDiagnostic } from "./qti-package-paths.js";
2
- import { collectPackageAssets } from "./qti-package-assets.js";
3
- import {
4
- detectPackageShape,
5
- diagnoseManifestDependencyReferences,
6
- diagnosePrimaryResourceHrefs,
7
- indexManifestResources,
8
- isQtiAssessmentTestResource,
9
- isQtiItemResource,
10
- parseManifestResources,
11
- QTI_PACKAGE_MANIFEST_PATH,
12
- } from "./qti-package-manifest.js";
2
+ import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
3
+ import { buildPackageSummary, inspectPackageManifest } from "./qti-package-plan.js";
13
4
  import {
14
5
  itemReferencesForPackageShape,
15
6
  parseAssessmentTestPackageModel,
16
7
  parsePackageItems,
17
8
  } from "./qti-package-items.js";
18
- import {
19
- packageTitle,
20
- parseStandardAlignments,
21
- primaryTiming,
22
- uniqueStandards,
23
- } from "./qti-package-metadata.js";
24
9
  import type { QtiPackageParseResult } from "./qti-package-types.js";
25
- import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
10
+ import { parseXmlFiles } from "./qti-package-xml.js";
26
11
  import {
12
+ DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
27
13
  decodeUtf8,
28
14
  readQtiPackageZipEntries,
29
15
  type QtiPackageEntry,
@@ -31,7 +17,7 @@ import {
31
17
  } from "./qti-package-zip.js";
32
18
  import type { QtiDiagnostic } from "./types.js";
33
19
 
34
- export { decodeUtf8, readQtiPackageZipEntries };
20
+ export { DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS, decodeUtf8, readQtiPackageZipEntries };
35
21
  export { discoverQtiPackageContentAssets } from "./qti-package-assets.js";
36
22
  export { normalizePackagePath } from "./qti-package-paths.js";
37
23
  export { QTI_ITEM_RESOURCE_TYPE, QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
@@ -63,6 +49,7 @@ export type {
63
49
  QtiPackageInflateContext,
64
50
  QtiPackageInflateRaw,
65
51
  QtiPackageParseOptions,
52
+ QtiPackageResourceLimits,
66
53
  } from "./qti-package-zip.js";
67
54
 
68
55
  /** Parse a QTI ZIP package into a neutral manifest, item, asset, and diagnostic model. */
@@ -82,47 +69,18 @@ function parseQtiPackageEntries(
82
69
  ): QtiPackageParseResult {
83
70
  const entriesByPath = indexEntries(entries, diagnostics);
84
71
  const xmlFilesByPath = parseXmlFiles(entriesByPath);
85
- const manifestXml = xmlFilesByPath.get(QTI_PACKAGE_MANIFEST_PATH);
86
-
87
- if (!manifestXml) {
88
- pushPackageDiagnostic(
89
- diagnostics,
90
- "package.manifest.missing",
91
- "error",
92
- "QTI package does not contain imsmanifest.xml.",
93
- QTI_PACKAGE_MANIFEST_PATH,
94
- );
95
- } else {
96
- pushXmlDiagnostics(manifestXml, diagnostics);
97
- }
98
-
99
- const manifestRoot = manifestXml?.root?.localName === "manifest" ? manifestXml.root : undefined;
100
- if (manifestXml?.root && !manifestRoot) {
101
- pushPackageDiagnostic(
102
- diagnostics,
103
- "package.manifest.root",
104
- "error",
105
- `Expected imsmanifest.xml root manifest, found ${manifestXml.root.localName}.`,
106
- QTI_PACKAGE_MANIFEST_PATH,
107
- );
108
- }
109
-
110
- const manifestResourceRecords = manifestRoot
111
- ? parseManifestResources(manifestRoot, entriesByPath, diagnostics)
112
- : [];
113
- const manifestResources = manifestResourceRecords;
114
- const resourcesByIdentifier = indexManifestResources(manifestResourceRecords, diagnostics);
115
- diagnoseManifestDependencyReferences(manifestResourceRecords, resourcesByIdentifier, diagnostics);
116
-
117
- const itemResources = manifestResourceRecords.filter((resource) =>
118
- isQtiItemResource(resource.type),
119
- );
120
- const assessmentTestResources = manifestResourceRecords.filter((resource) =>
121
- isQtiAssessmentTestResource(resource.type),
72
+ const {
73
+ manifestRoot,
74
+ manifestResources,
75
+ resourcesByIdentifier,
76
+ itemResources,
77
+ assessmentTestResources,
78
+ packageShape,
79
+ } = inspectPackageManifest(
80
+ entriesByPath,
81
+ xmlFilesByPath.get(QTI_PACKAGE_MANIFEST_PATH),
82
+ diagnostics,
122
83
  );
123
- diagnosePrimaryResourceHrefs([...itemResources, ...assessmentTestResources], diagnostics);
124
-
125
- const packageShape = detectPackageShape(itemResources, assessmentTestResources, diagnostics);
126
84
  const assessmentTest = parseAssessmentTestPackageModel(
127
85
  packageShape,
128
86
  assessmentTestResources,
@@ -137,36 +95,27 @@ function parseQtiPackageEntries(
137
95
  diagnostics,
138
96
  );
139
97
  const items = parsePackageItems(itemReferences, entriesByPath, xmlFilesByPath, diagnostics);
140
- const assets = collectPackageAssets(
98
+ const timedItems = items.filter((item) => item.timing !== undefined);
99
+ const { itemCount: _itemCount, ...summary } = buildPackageSummary({
100
+ manifestRoot,
101
+ manifestResources,
141
102
  resourcesByIdentifier,
103
+ packageShape,
142
104
  assessmentTest,
143
- items,
105
+ assetItems: items,
106
+ titleItems: items,
107
+ timedItemCount: timedItems.length,
108
+ soleItemTiming: timedItems.length === 1 ? timedItems[0]?.timing : undefined,
109
+ itemStandards: items.flatMap((item) => item.standards),
144
110
  entriesByPath,
145
111
  diagnostics,
146
- );
147
- const manifestStandards = manifestRoot
148
- ? parseStandardAlignments(manifestRoot, QTI_PACKAGE_MANIFEST_PATH)
149
- : [];
150
- const standards = uniqueStandards([
151
- ...manifestStandards,
152
- ...(assessmentTest?.standards ?? []),
153
- ...items.flatMap((item) => item.standards),
154
- ]);
155
- const timing = primaryTiming(assessmentTest, items);
156
- const title = packageTitle(manifestRoot, assessmentTest, items);
112
+ itemCount: items.length,
113
+ });
157
114
 
158
115
  return {
159
- ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
160
- title,
116
+ ...summary,
161
117
  entries,
162
- packageShape,
163
118
  items,
164
- assets,
165
- manifestResources,
166
- assessmentTest,
167
- timing,
168
- standards,
169
- diagnostics,
170
119
  };
171
120
  }
172
121
 
@@ -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 { readQtiJsonValue } from "./value-format.js";
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
- return {
112
- ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
113
- diagnostics,
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)) continue;
150
- diagnostics.push({
151
- code: "response.undeclared",
152
- severity: "error",
153
- identifier,
154
- message: `Response ${identifier} is not declared by the assessment item.`,
155
- path: identifier,
156
- });
157
- continue;
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
- ): void {
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;
@@ -1,6 +1,6 @@
1
1
  import { parseQtiXml } from "./parser.js";
2
2
  import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
3
- import { validateQtiResponseVariables } from "./response-validation.js";
3
+ import { parseQtiResponseVariables } from "./response-validation.js";
4
4
  import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
5
5
  import type {
6
6
  QtiAttemptStateV1,
@@ -163,8 +163,9 @@ export function runTrustedItemSession(
163
163
 
164
164
  if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
165
165
 
166
+ let submission = input.submission;
166
167
  if (input.submissionValidation === "strict") {
167
- const validation = validateQtiResponseVariables({
168
+ const validation = parseQtiResponseVariables({
168
169
  item: parsedResult.parsed.document.item,
169
170
  responses: input.submission.trustedResponses ?? {},
170
171
  allowIncompleteResponses: input.allowIncompleteResponses,
@@ -173,12 +174,13 @@ export function runTrustedItemSession(
173
174
  if (!validation.ok) {
174
175
  return emptyTrustedItemSessionFailure(validation.diagnostics);
175
176
  }
177
+ submission = { ...input.submission, trustedResponses: validation.responses };
176
178
  }
177
179
 
178
180
  const applicationResult = applyTrustedResponseApplication(
179
181
  sessionResult.session,
180
182
  parsedResult.parsed,
181
- input.submission,
183
+ submission,
182
184
  input.allowedUndeclaredResponseIdentifiers,
183
185
  input.diagnosticPrefix,
184
186
  );