@longsightgroup/qti3-core 0.10.5 → 0.11.0

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 (229) hide show
  1. package/README.md +153 -4
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/catalog.d.ts +8 -0
  11. package/dist/catalog.d.ts.map +1 -1
  12. package/dist/catalog.js +10 -0
  13. package/dist/catalog.js.map +1 -1
  14. package/dist/diagnostics.d.ts +13 -0
  15. package/dist/diagnostics.d.ts.map +1 -0
  16. package/dist/diagnostics.js +28 -0
  17. package/dist/diagnostics.js.map +1 -0
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/item-scoring-expectation.d.ts +2 -0
  23. package/dist/item-scoring-expectation.d.ts.map +1 -1
  24. package/dist/item-scoring-expectation.js +8 -0
  25. package/dist/item-scoring-expectation.js.map +1 -1
  26. package/dist/item-submission-materialization.d.ts.map +1 -1
  27. package/dist/item-submission-materialization.js +2 -0
  28. package/dist/item-submission-materialization.js.map +1 -1
  29. package/dist/parser-processing.d.ts.map +1 -1
  30. package/dist/parser-processing.js +8 -18
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts.map +1 -1
  33. package/dist/parser-values.js +1 -3
  34. package/dist/parser-values.js.map +1 -1
  35. package/dist/parser.js +36 -6
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-equality.d.ts +10 -0
  38. package/dist/processing-equality.d.ts.map +1 -0
  39. package/dist/processing-equality.js +78 -0
  40. package/dist/processing-equality.js.map +1 -0
  41. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  42. package/dist/processing-evaluator-comparison.js +14 -9
  43. package/dist/processing-evaluator-comparison.js.map +1 -1
  44. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  45. package/dist/processing-evaluator-numeric.js +2 -1
  46. package/dist/processing-evaluator-numeric.js.map +1 -1
  47. package/dist/processing-evaluator-string.d.ts.map +1 -1
  48. package/dist/processing-evaluator-string.js +26 -10
  49. package/dist/processing-evaluator-string.js.map +1 -1
  50. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  51. package/dist/processing-evaluator-variable.js +9 -3
  52. package/dist/processing-evaluator-variable.js.map +1 -1
  53. package/dist/processing-evaluator.d.ts +5 -1
  54. package/dist/processing-evaluator.d.ts.map +1 -1
  55. package/dist/processing-evaluator.js +13 -4
  56. package/dist/processing-evaluator.js.map +1 -1
  57. package/dist/processing-expression-children.d.ts.map +1 -1
  58. package/dist/processing-expression-children.js +2 -3
  59. package/dist/processing-expression-children.js.map +1 -1
  60. package/dist/processing-expression-tags.d.ts +2 -1
  61. package/dist/processing-expression-tags.d.ts.map +1 -1
  62. package/dist/processing-expression-tags.js +2 -1
  63. package/dist/processing-expression-tags.js.map +1 -1
  64. package/dist/processing-mapping.d.ts.map +1 -1
  65. package/dist/processing-mapping.js +26 -16
  66. package/dist/processing-mapping.js.map +1 -1
  67. package/dist/processing-state.d.ts +2 -2
  68. package/dist/processing-state.d.ts.map +1 -1
  69. package/dist/processing-state.js +5 -2
  70. package/dist/processing-state.js.map +1 -1
  71. package/dist/processing-values.d.ts +1 -2
  72. package/dist/processing-values.d.ts.map +1 -1
  73. package/dist/processing-values.js +1 -3
  74. package/dist/processing-values.js.map +1 -1
  75. package/dist/processing-variables.d.ts.map +1 -1
  76. package/dist/processing-variables.js +10 -5
  77. package/dist/processing-variables.js.map +1 -1
  78. package/dist/qti-package-assets.d.ts.map +1 -1
  79. package/dist/qti-package-assets.js +16 -10
  80. package/dist/qti-package-assets.js.map +1 -1
  81. package/dist/qti-package-manifest.d.ts +1 -0
  82. package/dist/qti-package-manifest.d.ts.map +1 -1
  83. package/dist/qti-package-manifest.js +1 -0
  84. package/dist/qti-package-manifest.js.map +1 -1
  85. package/dist/qti-package-metadata.d.ts.map +1 -1
  86. package/dist/qti-package-metadata.js +11 -0
  87. package/dist/qti-package-metadata.js.map +1 -1
  88. package/dist/qti-package-plan.d.ts +1 -1
  89. package/dist/qti-package-plan.d.ts.map +1 -1
  90. package/dist/qti-package-types.d.ts +9 -0
  91. package/dist/qti-package-types.d.ts.map +1 -1
  92. package/dist/qti-package-xml.d.ts +6 -1
  93. package/dist/qti-package-xml.d.ts.map +1 -1
  94. package/dist/qti-package-xml.js +11 -6
  95. package/dist/qti-package-xml.js.map +1 -1
  96. package/dist/qti-package-zip.d.ts +13 -1
  97. package/dist/qti-package-zip.d.ts.map +1 -1
  98. package/dist/qti-package-zip.js +185 -54
  99. package/dist/qti-package-zip.js.map +1 -1
  100. package/dist/qti-package.d.ts +12 -5
  101. package/dist/qti-package.d.ts.map +1 -1
  102. package/dist/qti-package.js +63 -8
  103. package/dist/qti-package.js.map +1 -1
  104. package/dist/response-validation-policy.d.ts +3 -1
  105. package/dist/response-validation-policy.d.ts.map +1 -1
  106. package/dist/response-validation-policy.js +66 -6
  107. package/dist/response-validation-policy.js.map +1 -1
  108. package/dist/response-validation.d.ts +3 -1
  109. package/dist/response-validation.d.ts.map +1 -1
  110. package/dist/response-validation.js +32 -10
  111. package/dist/response-validation.js.map +1 -1
  112. package/dist/scoring-disposition-policy.d.ts.map +1 -1
  113. package/dist/scoring-disposition-policy.js +6 -2
  114. package/dist/scoring-disposition-policy.js.map +1 -1
  115. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  116. package/dist/serializer-processing-expressions.js +2 -10
  117. package/dist/serializer-processing-expressions.js.map +1 -1
  118. package/dist/server-scoring.d.ts.map +1 -1
  119. package/dist/server-scoring.js +1 -0
  120. package/dist/server-scoring.js.map +1 -1
  121. package/dist/session-builtins.d.ts +16 -0
  122. package/dist/session-builtins.d.ts.map +1 -0
  123. package/dist/session-builtins.js +58 -0
  124. package/dist/session-builtins.js.map +1 -0
  125. package/dist/session.d.ts +5 -1
  126. package/dist/session.d.ts.map +1 -1
  127. package/dist/session.js +63 -11
  128. package/dist/session.js.map +1 -1
  129. package/dist/support-evidence.d.ts.map +1 -1
  130. package/dist/support-evidence.js +16 -3
  131. package/dist/support-evidence.js.map +1 -1
  132. package/dist/support.d.ts.map +1 -1
  133. package/dist/support.js +72 -24
  134. package/dist/support.js.map +1 -1
  135. package/dist/text-response.d.ts +9 -0
  136. package/dist/text-response.d.ts.map +1 -0
  137. package/dist/text-response.js +102 -0
  138. package/dist/text-response.js.map +1 -0
  139. package/dist/trusted-item-session.d.ts +4 -1
  140. package/dist/trusted-item-session.d.ts.map +1 -1
  141. package/dist/trusted-item-session.js +12 -6
  142. package/dist/trusted-item-session.js.map +1 -1
  143. package/dist/types.d.ts +23 -3
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/validation-declaration-entries.d.ts.map +1 -1
  146. package/dist/validation-declaration-entries.js +11 -1
  147. package/dist/validation-declaration-entries.js.map +1 -1
  148. package/dist/validation-interactions.d.ts.map +1 -1
  149. package/dist/validation-interactions.js +102 -12
  150. package/dist/validation-interactions.js.map +1 -1
  151. package/dist/validation-item-children.d.ts +4 -0
  152. package/dist/validation-item-children.d.ts.map +1 -0
  153. package/dist/validation-item-children.js +45 -0
  154. package/dist/validation-item-children.js.map +1 -0
  155. package/dist/validation-processing.d.ts.map +1 -1
  156. package/dist/validation-processing.js +45 -12
  157. package/dist/validation-processing.js.map +1 -1
  158. package/dist/validation.d.ts.map +1 -1
  159. package/dist/validation.js +12 -0
  160. package/dist/validation.js.map +1 -1
  161. package/dist/value-guards.d.ts +4 -0
  162. package/dist/value-guards.d.ts.map +1 -0
  163. package/dist/value-guards.js +5 -0
  164. package/dist/value-guards.js.map +1 -0
  165. package/dist/xml-schema-regex-data.d.ts +48 -0
  166. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  167. package/dist/xml-schema-regex-data.js +403 -0
  168. package/dist/xml-schema-regex-data.js.map +1 -0
  169. package/dist/xml-schema-regex-parser.d.ts +31 -0
  170. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  171. package/dist/xml-schema-regex-parser.js +245 -0
  172. package/dist/xml-schema-regex-parser.js.map +1 -0
  173. package/dist/xml-schema-regex.d.ts +14 -0
  174. package/dist/xml-schema-regex.d.ts.map +1 -0
  175. package/dist/xml-schema-regex.js +114 -0
  176. package/dist/xml-schema-regex.js.map +1 -0
  177. package/package.json +1 -1
  178. package/src/adaptive-turn-materializer.ts +4 -3
  179. package/src/adaptive-turn.ts +1 -0
  180. package/src/attempt-state.ts +54 -1
  181. package/src/catalog.ts +29 -0
  182. package/src/diagnostics.ts +29 -0
  183. package/src/index.ts +16 -0
  184. package/src/item-scoring-expectation.ts +8 -0
  185. package/src/item-submission-materialization.ts +2 -0
  186. package/src/parser-processing.ts +15 -18
  187. package/src/parser-values.ts +1 -5
  188. package/src/parser.ts +38 -6
  189. package/src/processing-equality.ts +104 -0
  190. package/src/processing-evaluator-comparison.ts +19 -14
  191. package/src/processing-evaluator-numeric.ts +2 -6
  192. package/src/processing-evaluator-string.ts +25 -18
  193. package/src/processing-evaluator-variable.ts +8 -3
  194. package/src/processing-evaluator.ts +20 -7
  195. package/src/processing-expression-children.ts +2 -3
  196. package/src/processing-expression-tags.ts +2 -1
  197. package/src/processing-mapping.ts +31 -25
  198. package/src/processing-state.ts +6 -1
  199. package/src/processing-values.ts +2 -11
  200. package/src/processing-variables.ts +9 -7
  201. package/src/qti-package-assets.ts +20 -13
  202. package/src/qti-package-manifest.ts +1 -0
  203. package/src/qti-package-metadata.ts +11 -0
  204. package/src/qti-package-plan.ts +1 -1
  205. package/src/qti-package-types.ts +10 -0
  206. package/src/qti-package-xml.ts +11 -6
  207. package/src/qti-package-zip.ts +330 -97
  208. package/src/qti-package.ts +106 -7
  209. package/src/response-validation-policy.ts +76 -4
  210. package/src/response-validation.ts +51 -5
  211. package/src/scoring-disposition-policy.ts +7 -2
  212. package/src/serializer-processing-expressions.ts +8 -25
  213. package/src/server-scoring.ts +1 -0
  214. package/src/session-builtins.ts +75 -0
  215. package/src/session.ts +64 -8
  216. package/src/support-evidence.ts +16 -3
  217. package/src/support.ts +94 -24
  218. package/src/text-response.ts +106 -0
  219. package/src/trusted-item-session.ts +17 -5
  220. package/src/types.ts +32 -4
  221. package/src/validation-declaration-entries.ts +11 -1
  222. package/src/validation-interactions.ts +139 -14
  223. package/src/validation-item-children.ts +48 -0
  224. package/src/validation-processing.ts +47 -10
  225. package/src/validation.ts +14 -0
  226. package/src/value-guards.ts +6 -0
  227. package/src/xml-schema-regex-data.ts +404 -0
  228. package/src/xml-schema-regex-parser.ts +234 -0
  229. package/src/xml-schema-regex.ts +115 -0
@@ -1,26 +1,42 @@
1
- import { pushPackageDiagnostic } from "./qti-package-paths.js";
1
+ import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
2
2
  import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
3
- import { buildPackageSummary, inspectPackageManifest } from "./qti-package-plan.js";
3
+ import {
4
+ buildPackageSummary,
5
+ failedPackageSummary,
6
+ inspectPackageManifest,
7
+ } from "./qti-package-plan.js";
4
8
  import {
5
9
  itemReferencesForPackageShape,
6
10
  parseAssessmentTestPackageModel,
7
11
  parsePackageItems,
8
12
  } from "./qti-package-items.js";
9
13
  import type { QtiPackageParseResult } from "./qti-package-types.js";
10
- import { parseXmlFiles } from "./qti-package-xml.js";
14
+ import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
11
15
  import {
12
16
  DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
13
17
  decodeUtf8,
14
18
  readQtiPackageZipEntries,
19
+ readQtiPackageZipEntriesAsync,
20
+ resolveResourceLimits,
15
21
  type QtiPackageEntry,
16
22
  type QtiPackageParseOptions,
17
23
  } from "./qti-package-zip.js";
18
24
  import type { QtiDiagnostic } from "./types.js";
19
25
 
20
- export { DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS, decodeUtf8, readQtiPackageZipEntries };
26
+ export {
27
+ DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
28
+ decodeUtf8,
29
+ readQtiPackageZipEntries,
30
+ readQtiPackageZipEntriesAsync,
31
+ };
21
32
  export { discoverQtiPackageContentAssets } from "./qti-package-assets.js";
22
33
  export { normalizePackagePath } from "./qti-package-paths.js";
23
- export { QTI_ITEM_RESOURCE_TYPE, QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
34
+ export { scopeDiagnosticToPackagePath } from "./qti-package-xml.js";
35
+ export {
36
+ QTI_ITEM_RESOURCE_TYPE,
37
+ QTI_PACKAGE_MANIFEST_PATH,
38
+ isQtiItemResource,
39
+ } from "./qti-package-manifest.js";
24
40
  export type { QtiPackageEntry };
25
41
 
26
42
  export type {
@@ -35,6 +51,7 @@ export type {
35
51
  QtiPackageItem,
36
52
  QtiPackageItemSource,
37
53
  QtiPackageParseResult,
54
+ QtiPackageXmlFileSummary,
38
55
  QtiPackageShape,
39
56
  QtiItemSessionControl,
40
57
  QtiStandardAlignment,
@@ -48,6 +65,8 @@ export type {
48
65
  export type {
49
66
  QtiPackageInflateContext,
50
67
  QtiPackageInflateRaw,
68
+ QtiPackageInflateRawAsync,
69
+ QtiPackageZipAsyncOptions,
51
70
  QtiPackageParseOptions,
52
71
  QtiPackageResourceLimits,
53
72
  } from "./qti-package-zip.js";
@@ -59,12 +78,82 @@ export function parseQtiPackage(
59
78
  ): QtiPackageParseResult {
60
79
  const diagnostics: QtiDiagnostic[] = [];
61
80
  const entries = readQtiPackageZipEntries(bytes, options, diagnostics);
62
- return parseQtiPackageEntries(entries, options, diagnostics);
81
+ return parseQtiPackageEntries(entries, diagnostics);
82
+ }
83
+
84
+ /**
85
+ * Parse already extracted or restored entries with the same package semantics as ZIP input.
86
+ * Paths must be unique and canonical. Entry count and expanded byte limits apply;
87
+ * compression-ratio limits are irrelevant after extraction. Inspect ok before use.
88
+ */
89
+ export function parseQtiPackageFromEntries(
90
+ entries: readonly QtiPackageEntry[],
91
+ options: Pick<QtiPackageParseOptions, "limits"> = {},
92
+ ): QtiPackageParseResult {
93
+ const diagnostics: QtiDiagnostic[] = [];
94
+ const limits = resolveResourceLimits(options.limits, diagnostics);
95
+ if (limits) {
96
+ if (entries.length > limits.maxEntries) {
97
+ pushPackageDiagnostic(
98
+ diagnostics,
99
+ "package.entries.limit",
100
+ "error",
101
+ "Package contains more entries than allowed.",
102
+ );
103
+ } else {
104
+ let total = 0;
105
+ const paths = new Set<string>();
106
+ for (const entry of entries) {
107
+ const path = normalizePackagePath(entry.path, "entry", diagnostics);
108
+ if (path === undefined) break;
109
+ if (!path || path !== entry.path || path.includes("\\") || path.includes("\0")) {
110
+ pushPackageDiagnostic(
111
+ diagnostics,
112
+ "package.entry.path",
113
+ "error",
114
+ "Package entry paths must be canonical and package-relative.",
115
+ entry.path,
116
+ );
117
+ break;
118
+ }
119
+ if (paths.has(path)) {
120
+ pushPackageDiagnostic(
121
+ diagnostics,
122
+ "package.entry.duplicate",
123
+ "error",
124
+ `QTI package contains duplicate entry ${path}.`,
125
+ path,
126
+ );
127
+ break;
128
+ }
129
+ paths.add(path);
130
+ total += entry.bytes.byteLength;
131
+ if (
132
+ entry.bytes.byteLength > limits.maxEntryUncompressedBytes ||
133
+ !Number.isSafeInteger(total) ||
134
+ total > limits.maxTotalUncompressedBytes
135
+ ) {
136
+ pushPackageDiagnostic(
137
+ diagnostics,
138
+ "package.bytes.limit",
139
+ "error",
140
+ "Package entry or total size exceeds its configured budget.",
141
+ path,
142
+ );
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ if (diagnostics.length) {
149
+ const { itemCount: _itemCount, ...summary } = failedPackageSummary(diagnostics);
150
+ return { ...summary, entries: [], items: [], xmlFiles: [] };
151
+ }
152
+ return parseQtiPackageEntries(entries, diagnostics);
63
153
  }
64
154
 
65
155
  function parseQtiPackageEntries(
66
156
  entries: readonly QtiPackageEntry[],
67
- options: QtiPackageParseOptions,
68
157
  diagnostics: QtiDiagnostic[],
69
158
  ): QtiPackageParseResult {
70
159
  const entriesByPath = indexEntries(entries, diagnostics);
@@ -116,6 +205,16 @@ function parseQtiPackageEntries(
116
205
  ...summary,
117
206
  entries,
118
207
  items,
208
+ xmlFiles: [...xmlFilesByPath.values()].map((file) => {
209
+ const xmlDiagnostics: QtiDiagnostic[] = [];
210
+ pushXmlDiagnostics(file, xmlDiagnostics);
211
+ return {
212
+ path: file.path,
213
+ rootLocalName: file.root?.localName,
214
+ rootNamespaceUri: file.root?.uri,
215
+ diagnostics: xmlDiagnostics,
216
+ };
217
+ }),
119
218
  };
120
219
  }
121
220
 
@@ -31,6 +31,8 @@ export function responseLimitAttribute(
31
31
  choiceKey: "min-choices" | "max-choices",
32
32
  associationKey: "min-associations" | "max-associations",
33
33
  ): string | undefined {
34
+ if (interaction.type === "extendedText")
35
+ return interaction.attributes[choiceKey === "min-choices" ? "min-strings" : "max-strings"];
34
36
  if (interaction.type === "order" || interaction.type === "graphicOrder") {
35
37
  return interaction.attributes[choiceKey];
36
38
  }
@@ -42,7 +44,7 @@ function interactionRequiresResponse(interaction: QtiInteraction): boolean {
42
44
  }
43
45
 
44
46
  export function minimumRequiredResponses(interaction: QtiInteraction | undefined): number {
45
- if (!interaction) return 1;
47
+ if (!interaction) return 0;
46
48
  if (interaction.type === "media") return minimumMediaPlays(interaction);
47
49
  const explicit = responseLimitAttribute(interaction, "min-choices", "min-associations");
48
50
  if (explicit === undefined) return interactionRequiresResponse(interaction) ? 1 : 0;
@@ -54,6 +56,10 @@ export function maximumAllowedResponses(
54
56
  ): number | undefined {
55
57
  if (!interaction) return undefined;
56
58
  if (interaction.type === "media") return maximumMediaPlays(interaction);
59
+ if (interaction.type === "extendedText") {
60
+ const maximum = interaction.attributes["max-strings"];
61
+ return maximum === undefined ? undefined : parseNonNegativeInteger(maximum);
62
+ }
57
63
  if (
58
64
  (interaction.type === "order" || interaction.type === "graphicOrder") &&
59
65
  !orderSubsetLimitsActive(interaction)
@@ -61,7 +67,32 @@ export function maximumAllowedResponses(
61
67
  return undefined;
62
68
  }
63
69
  const explicit = responseLimitAttribute(interaction, "max-choices", "max-associations");
64
- if (explicit === undefined) return undefined;
70
+ if (explicit === undefined) {
71
+ switch (interaction.type) {
72
+ case "associate":
73
+ case "match":
74
+ case "gapMatch":
75
+ case "graphicAssociate":
76
+ case "graphicGapMatch":
77
+ case "choice":
78
+ case "hottext":
79
+ case "hotspot":
80
+ case "positionObject":
81
+ return 1;
82
+ case "custom":
83
+ case "drawing":
84
+ case "endAttempt":
85
+ case "graphicOrder":
86
+ case "inlineChoice":
87
+ case "order":
88
+ case "portableCustom":
89
+ case "selectPoint":
90
+ case "slider":
91
+ case "textEntry":
92
+ case "upload":
93
+ return undefined;
94
+ }
95
+ }
65
96
  const parsed = parseNonNegativeInteger(explicit);
66
97
  return parsed === undefined || parsed <= 0 ? undefined : parsed;
67
98
  }
@@ -90,6 +121,7 @@ export { maximumMediaPlays, minimumMediaPlays };
90
121
  export function responseValidationPolicy(
91
122
  declaration: { readonly correctResponse: QtiValue | null },
92
123
  interaction: QtiInteraction | undefined,
124
+ requireScoredResponses = false,
93
125
  ): QtiResponseValidationPolicy {
94
126
  const authoredMinimum =
95
127
  interaction === undefined
@@ -97,16 +129,20 @@ export function responseValidationPolicy(
97
129
  : responseLimitAttribute(interaction, "min-choices", "min-associations");
98
130
  const minimum = interaction === undefined ? undefined : minimumRequiredResponses(interaction);
99
131
  const validatesMinimum =
100
- declaration.correctResponse !== null ||
132
+ (requireScoredResponses && declaration.correctResponse !== null) ||
101
133
  interaction?.type === "media" ||
102
134
  authoredMinimum !== undefined ||
103
135
  (minimum !== undefined && minimum > 0);
104
136
  const maximum = maximumAllowedResponses(interaction);
137
+ const hasMatchMaximum = interaction?.choices.some(
138
+ (choice) => choiceMatchMaximum(choice) !== undefined,
139
+ );
105
140
  if (
106
141
  declaration.correctResponse === null &&
107
142
  interaction?.type !== "media" &&
108
143
  !validatesMinimum &&
109
- maximum === undefined
144
+ maximum === undefined &&
145
+ !hasMatchMaximum
110
146
  ) {
111
147
  return { checkMinimum: false, checkMaximum: false, checkMatchMax: false };
112
148
  }
@@ -225,3 +261,39 @@ export function matchMaxDiagnostics(
225
261
  }
226
262
  return diagnostics;
227
263
  }
264
+
265
+ /** Enforce authored per-choice minimum uses when a response is finalized. */
266
+ export function matchMinDiagnostics(
267
+ responseIdentifier: string,
268
+ interaction: QtiInteraction,
269
+ response: QtiValue,
270
+ ): QtiDiagnostic[] {
271
+ if (
272
+ !["associate", "graphicAssociate", "match", "gapMatch", "graphicGapMatch"].includes(
273
+ interaction.type,
274
+ )
275
+ )
276
+ return [];
277
+ const pairs = qtiValueToIdentifierList(response);
278
+ const diagnostics: QtiDiagnostic[] = [];
279
+ for (const choice of interaction.choices) {
280
+ const minimum = parseNonNegativeInteger(choice.attributes["match-min"] ?? "0") ?? 0;
281
+ if (minimum === 0) continue;
282
+ const side =
283
+ choice.role === "matchSource" || choice.role === "gapChoice"
284
+ ? "source"
285
+ : choice.role === "matchTarget" ||
286
+ (interaction.type === "graphicGapMatch" && choice.role === "hotspot")
287
+ ? "target"
288
+ : "either";
289
+ if (directedPairChoiceUseCount(choice, pairs, side) >= minimum) continue;
290
+ diagnostics.push({
291
+ code: "response.matchMin",
292
+ severity: "error",
293
+ message: `${choice.text || choice.identifier} must be used at least ${minimum} time${minimum === 1 ? "" : "s"}.`,
294
+ path: responseIdentifier,
295
+ source: choice.source,
296
+ });
297
+ }
298
+ return diagnostics;
299
+ }
@@ -8,11 +8,14 @@ import type {
8
8
  QtiScalarValue,
9
9
  QtiValue,
10
10
  } from "./types.js";
11
+ import { isQtiTextResponseRecord, qtiTextResponseString } from "./text-response.js";
11
12
  import { assertNever } from "./assert-never.js";
12
- import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
13
+ import { isNullResponse, valueContainer } from "./processing-values.js";
14
+ import { isRecordValue } from "./value-guards.js";
13
15
  import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
14
16
  import {
15
17
  matchMaxDiagnostics,
18
+ matchMinDiagnostics,
16
19
  maximumAllowedResponses,
17
20
  maximumResponseDiagnostic,
18
21
  mediaPlayCount,
@@ -33,6 +36,7 @@ export type QtiResponseValidationDiagnosticCode =
33
36
  | "response.required"
34
37
  | "response.maximum"
35
38
  | "response.matchMax"
39
+ | "response.matchMin"
36
40
  | "response.cardinality"
37
41
  | "response.baseType"
38
42
  | "response.domain"
@@ -65,6 +69,8 @@ export interface QtiResponseValidationInput {
65
69
  item: QtiAssessmentItem;
66
70
  responses: QtiResponseVariablesInput;
67
71
  allowIncompleteResponses?: boolean | undefined;
72
+ /** Host policy: require a scored response when no minimum is authored; defaults to false. */
73
+ requireScoredResponses?: boolean | undefined;
68
74
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
69
75
  responseIdentifiers?: Iterable<string> | undefined;
70
76
  }
@@ -113,6 +119,17 @@ export function parseQtiResponseVariables(
113
119
  if (cardinalityMatches && parsedValue !== undefined) {
114
120
  responses.set(declaration.identifier, parsedValue);
115
121
  validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
122
+ if (
123
+ declaration.cardinality === "record" &&
124
+ parsedValue !== null &&
125
+ interactions?.some(
126
+ (interaction) =>
127
+ interaction.type === "textEntry" || interaction.type === "extendedText",
128
+ ) &&
129
+ !isQtiTextResponseRecord(parsedValue)
130
+ ) {
131
+ pushResponseDomainDiagnostic(declaration, parsedValue, diagnostics);
132
+ }
116
133
  }
117
134
  }
118
135
 
@@ -122,6 +139,7 @@ export function parseQtiResponseVariables(
122
139
  undefined,
123
140
  value,
124
141
  input.allowIncompleteResponses,
142
+ input.requireScoredResponses,
125
143
  diagnostics,
126
144
  );
127
145
  continue;
@@ -133,6 +151,7 @@ export function parseQtiResponseVariables(
133
151
  interaction,
134
152
  value,
135
153
  input.allowIncompleteResponses,
154
+ input.requireScoredResponses,
136
155
  diagnostics,
137
156
  );
138
157
  }
@@ -379,17 +398,37 @@ function validateDeclarationResponse(
379
398
  interaction: QtiInteraction | undefined,
380
399
  value: QtiValue | undefined,
381
400
  allowIncompleteResponses: boolean | undefined,
401
+ requireScoredResponses: boolean | undefined,
382
402
  diagnostics: QtiResponseValidationDiagnostic[],
383
403
  ): void {
384
- const policy = responseValidationPolicy(declaration, interaction);
404
+ if (interaction && !allowIncompleteResponses) {
405
+ diagnostics.push(
406
+ ...matchMinDiagnostics(declaration.identifier, interaction, value ?? null).map((diagnostic) =>
407
+ attachResponseIdentifier(declaration.identifier, diagnostic),
408
+ ),
409
+ );
410
+ }
411
+ const policy = responseValidationPolicy(declaration, interaction, requireScoredResponses);
385
412
  if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
386
413
 
387
414
  const effectiveValue = value ?? null;
388
415
  const count =
389
- interaction?.type === "media" ? mediaPlayCount(effectiveValue) : responseCount(effectiveValue);
416
+ interaction?.type === "extendedText" || interaction?.type === "textEntry"
417
+ ? Array.isArray(effectiveValue)
418
+ ? effectiveValue.filter((entry) => entry !== "").length
419
+ : qtiTextResponseString(effectiveValue) === ""
420
+ ? 0
421
+ : 1
422
+ : interaction?.type === "media"
423
+ ? mediaPlayCount(effectiveValue)
424
+ : responseCount(effectiveValue);
390
425
 
391
426
  if (policy.checkMinimum && !allowIncompleteResponses) {
392
- const minimum = effectiveMinimumRequiredResponses(declaration, interaction);
427
+ const minimum = effectiveMinimumRequiredResponses(
428
+ declaration,
429
+ interaction,
430
+ requireScoredResponses,
431
+ );
393
432
  if (count < minimum) {
394
433
  diagnostics.push(
395
434
  attachResponseIdentifier(
@@ -424,9 +463,15 @@ function validateDeclarationResponse(
424
463
  function effectiveMinimumRequiredResponses(
425
464
  declaration: { readonly correctResponse: QtiValue | null },
426
465
  interaction: QtiInteraction | undefined,
466
+ requireScoredResponses: boolean | undefined,
427
467
  ): number {
428
468
  const minimum = minimumRequiredResponses(interaction);
429
- if (declaration.correctResponse === null || hasAuthoredMinimum(interaction)) return minimum;
469
+ if (
470
+ !requireScoredResponses ||
471
+ declaration.correctResponse === null ||
472
+ hasAuthoredMinimum(interaction)
473
+ )
474
+ return minimum;
430
475
  return Math.max(minimum, 1);
431
476
  }
432
477
 
@@ -453,6 +498,7 @@ function isResponseValidationDiagnosticCode(
453
498
  code === "response.required" ||
454
499
  code === "response.maximum" ||
455
500
  code === "response.matchMax" ||
501
+ code === "response.matchMin" ||
456
502
  code === "response.cardinality" ||
457
503
  code === "response.baseType" ||
458
504
  code === "response.domain" ||
@@ -1,5 +1,5 @@
1
1
  import type { QtiAssessmentItem } from "./types.js";
2
- import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
2
+ import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
3
3
 
4
4
  export type QtiItemSubmissionScoringDisposition =
5
5
  | "scored"
@@ -23,7 +23,11 @@ export function itemHasManuallyScoredInteractions(item: QtiAssessmentItem): bool
23
23
  }
24
24
 
25
25
  export function itemNeedsScoringFollowUpWhenUnscored(item: QtiAssessmentItem): boolean {
26
- return itemExpectsAutomatedScore(item) || itemHasManuallyScoredInteractions(item);
26
+ return (
27
+ itemHasExternalScore(item) ||
28
+ itemExpectsAutomatedScore(item) ||
29
+ itemHasManuallyScoredInteractions(item)
30
+ );
27
31
  }
28
32
 
29
33
  /** Default generic disposition taxonomy shipped by qti3-core for submission materialization. */
@@ -31,6 +35,7 @@ export function classifyQtiItemScoringDisposition(
31
35
  item: QtiAssessmentItem,
32
36
  score: number | null,
33
37
  ): Exclude<QtiItemSubmissionScoringDisposition, "invalid"> {
38
+ if (itemHasExternalScore(item)) return "manual-scoring-required";
34
39
  if (score !== null) return "scored";
35
40
  if (itemNeedsScoringFollowUpWhenUnscored(item)) return "manual-scoring-required";
36
41
  return "unscored-reference";
@@ -79,8 +79,6 @@ export function serializeExpression(
79
79
  return serializeBaseValue(expression, context, indent);
80
80
  case "null":
81
81
  return renderElement("qti-null", [], [], indent);
82
- case "isNull":
83
- return serializeIsNull(expression, context, indent);
84
82
  case "matchCorrect":
85
83
  return serializeMatchCorrect(expression, context, indent);
86
84
  case "randomInteger":
@@ -104,13 +102,6 @@ export function serializeExpression(
104
102
  [],
105
103
  indent,
106
104
  );
107
- case "random":
108
- return renderElement(
109
- "qti-random",
110
- [],
111
- expression.values.flatMap((value) => serializeExpression(value, context, indent + 1)),
112
- indent,
113
- );
114
105
  case "index":
115
106
  return renderExpressionContainer(
116
107
  "qti-index",
@@ -143,6 +134,14 @@ export function serializeExpression(
143
134
  );
144
135
  case "not":
145
136
  return renderExpressionContainer("qti-not", [], [expression.expression], context, indent);
137
+ case "equal":
138
+ return renderExpressionContainer(
139
+ "qti-equal",
140
+ sortedBagAttributes(expression.attributes),
141
+ [expression.left, expression.right],
142
+ context,
143
+ indent,
144
+ );
146
145
  case "equalRounded":
147
146
  return renderExpressionContainer(
148
147
  "qti-equal-rounded",
@@ -337,22 +336,6 @@ function baseValueText(
337
336
  return qtiValueToString(expression.value);
338
337
  }
339
338
 
340
- function serializeIsNull(
341
- expression: Extract<QtiProcessingExpression, { type: "isNull" }>,
342
- context: SerializationContext,
343
- indent: number,
344
- ): string[] {
345
- if (!requireIdentifier(expression.identifier, "qti-is-null", expression.source, context)) {
346
- return [];
347
- }
348
- return renderElement(
349
- "qti-is-null",
350
- [],
351
- renderElement("qti-variable", [["identifier", expression.identifier]], [], indent + 1),
352
- indent,
353
- );
354
- }
355
-
356
339
  function serializeMatchCorrect(
357
340
  expression: Extract<QtiProcessingExpression, { type: "matchCorrect" }>,
358
341
  context: SerializationContext,
@@ -28,6 +28,7 @@ export function scoreQtiItemServerSide(input: QtiServerScoringInput): QtiServerS
28
28
  itemXml: input.itemXml,
29
29
  diagnosticPrefix: "serverScoring",
30
30
  allowedUndeclaredResponseIdentifiers: input.allowedUndeclaredResponseIdentifiers,
31
+ sessionEnvironment: input.sessionEnvironment,
31
32
  submission: {
32
33
  trustedResponses: input.trustedResponses,
33
34
  trustedInteractionStates: input.trustedInteractionStates,
@@ -0,0 +1,75 @@
1
+ import type { QtiBuiltInVariables, QtiDiagnostic, QtiSessionContext, QtiValue } from "./types.js";
2
+
3
+ export interface QtiSessionEnvironment {
4
+ /** Host context, resolved before template processing. Unspecified fields are empty strings. */
5
+ context?: Partial<QtiSessionContext> | undefined;
6
+ /** Accumulated item-session seconds supplied by a scoring host. */
7
+ duration?: number | undefined;
8
+ /** Monotonic milliseconds supplied by a delivery host; suspension time is excluded. */
9
+ now?: (() => number) | undefined;
10
+ }
11
+
12
+ export function createSessionBuiltIns(
13
+ prior: QtiBuiltInVariables | undefined,
14
+ options: QtiSessionEnvironment,
15
+ report: (diagnostic: QtiDiagnostic) => void,
16
+ ) {
17
+ const state: QtiBuiltInVariables = {
18
+ numAttempts: prior?.numAttempts ?? 0,
19
+ attemptInProgress: prior?.attemptInProgress ?? false,
20
+ duration: options.duration ?? prior?.duration ?? (options.now ? 0 : null),
21
+ context: {
22
+ candidateIdentifier:
23
+ options.context?.candidateIdentifier ?? prior?.context.candidateIdentifier ?? "",
24
+ testIdentifier: options.context?.testIdentifier ?? prior?.context.testIdentifier ?? "",
25
+ environmentIdentifier:
26
+ options.context?.environmentIdentifier ?? prior?.context.environmentIdentifier ?? "",
27
+ },
28
+ };
29
+ let previousTime = options.now?.();
30
+ let elapsed = state.duration;
31
+ let timingValid = elapsed === null || (Number.isFinite(elapsed) && elapsed >= 0);
32
+ if (!timingValid) state.duration = elapsed = null;
33
+
34
+ function captureTime(active: boolean): void {
35
+ if (!options.now) return;
36
+ const current = options.now();
37
+ if (
38
+ !Number.isFinite(current) ||
39
+ previousTime === undefined ||
40
+ !Number.isFinite(previousTime) ||
41
+ current < previousTime
42
+ ) {
43
+ timingValid = false;
44
+ elapsed = state.duration = null;
45
+ } else if (active && elapsed !== null) {
46
+ elapsed += (current - previousTime) / 1000;
47
+ // Preserve clock precision across suspension and restoration.
48
+ state.duration = elapsed;
49
+ }
50
+ previousTime = current;
51
+ }
52
+
53
+ function duration(): QtiValue {
54
+ if (state.duration !== null && timingValid) return Math.floor(state.duration * 1000) / 1000;
55
+ report({
56
+ code: timingValid ? "session.duration.unavailable" : "session.duration.invalid",
57
+ severity: "error",
58
+ message: timingValid
59
+ ? "Reading duration requires host-supplied accumulated seconds or a monotonic clock."
60
+ : "Duration requires finite non-negative seconds and a monotonic finite clock.",
61
+ });
62
+ return null;
63
+ }
64
+
65
+ return { state, captureTime, duration };
66
+ }
67
+
68
+ export function builtInVariableBaseType(
69
+ identifier: string,
70
+ ): "integer" | "float" | "identifier" | undefined {
71
+ if (identifier === "numAttempts") return "integer";
72
+ if (identifier === "duration") return "float";
73
+ if (identifier === "completionStatus") return "identifier";
74
+ return undefined;
75
+ }