@longsightgroup/qti3-core 0.9.2 → 0.9.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.
Files changed (330) hide show
  1. package/README.md +108 -11
  2. package/dist/adaptive-turn-materializer.d.ts +2 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -1
  4. package/dist/adaptive-turn-materializer.js +25 -19
  5. package/dist/adaptive-turn-materializer.js.map +1 -1
  6. package/dist/adaptive-turn.d.ts +8 -1
  7. package/dist/adaptive-turn.d.ts.map +1 -1
  8. package/dist/adaptive-turn.js +15 -5
  9. package/dist/adaptive-turn.js.map +1 -1
  10. package/dist/assert-never.d.ts +3 -0
  11. package/dist/assert-never.d.ts.map +1 -0
  12. package/dist/assert-never.js +5 -0
  13. package/dist/assert-never.js.map +1 -0
  14. package/dist/attempt-state-constants.d.ts +6 -0
  15. package/dist/attempt-state-constants.d.ts.map +1 -0
  16. package/dist/attempt-state-constants.js +6 -0
  17. package/dist/attempt-state-constants.js.map +1 -0
  18. package/dist/attempt-state.d.ts +5 -0
  19. package/dist/attempt-state.d.ts.map +1 -0
  20. package/dist/attempt-state.js +190 -0
  21. package/dist/attempt-state.js.map +1 -0
  22. package/dist/catalog.js +1 -1
  23. package/dist/catalog.js.map +1 -1
  24. package/dist/content-text.d.ts.map +1 -1
  25. package/dist/content-text.js +3 -0
  26. package/dist/content-text.js.map +1 -1
  27. package/dist/custom-operators.d.ts +13 -0
  28. package/dist/custom-operators.d.ts.map +1 -0
  29. package/dist/custom-operators.js +2 -0
  30. package/dist/custom-operators.js.map +1 -0
  31. package/dist/delivery-preparation.d.ts +20 -0
  32. package/dist/delivery-preparation.d.ts.map +1 -0
  33. package/dist/delivery-preparation.js +71 -0
  34. package/dist/delivery-preparation.js.map +1 -0
  35. package/dist/delivery-redaction.d.ts +5 -0
  36. package/dist/delivery-redaction.d.ts.map +1 -1
  37. package/dist/delivery-redaction.js +27 -6
  38. package/dist/delivery-redaction.js.map +1 -1
  39. package/dist/delivery-security.js +2 -2
  40. package/dist/delivery-security.js.map +1 -1
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/item-scoring-expectation.d.ts +4 -0
  46. package/dist/item-scoring-expectation.d.ts.map +1 -0
  47. package/dist/item-scoring-expectation.js +6 -0
  48. package/dist/item-scoring-expectation.js.map +1 -0
  49. package/dist/item-submission-materialization.d.ts +25 -0
  50. package/dist/item-submission-materialization.d.ts.map +1 -0
  51. package/dist/item-submission-materialization.js +68 -0
  52. package/dist/item-submission-materialization.js.map +1 -0
  53. package/dist/parser-custom-interactions.js +1 -1
  54. package/dist/parser-custom-interactions.js.map +1 -1
  55. package/dist/parser-declarations.d.ts.map +1 -1
  56. package/dist/parser-declarations.js +4 -4
  57. package/dist/parser-declarations.js.map +1 -1
  58. package/dist/parser-item-metadata.d.ts.map +1 -1
  59. package/dist/parser-item-metadata.js +8 -7
  60. package/dist/parser-item-metadata.js.map +1 -1
  61. package/dist/parser-processing.js +2 -2
  62. package/dist/parser-processing.js.map +1 -1
  63. package/dist/parser-values.d.ts +5 -1
  64. package/dist/parser-values.d.ts.map +1 -1
  65. package/dist/parser-values.js +33 -0
  66. package/dist/parser-values.js.map +1 -1
  67. package/dist/processing-evaluator-boolean.d.ts +8 -0
  68. package/dist/processing-evaluator-boolean.d.ts.map +1 -0
  69. package/dist/processing-evaluator-boolean.js +47 -0
  70. package/dist/processing-evaluator-boolean.js.map +1 -0
  71. package/dist/processing-evaluator-collection.d.ts +12 -0
  72. package/dist/processing-evaluator-collection.d.ts.map +1 -0
  73. package/dist/processing-evaluator-collection.js +35 -0
  74. package/dist/processing-evaluator-collection.js.map +1 -0
  75. package/dist/processing-evaluator-comparison.d.ts +8 -0
  76. package/dist/processing-evaluator-comparison.d.ts.map +1 -0
  77. package/dist/processing-evaluator-comparison.js +88 -0
  78. package/dist/processing-evaluator-comparison.js.map +1 -0
  79. package/dist/processing-evaluator-custom.d.ts +8 -0
  80. package/dist/processing-evaluator-custom.d.ts.map +1 -0
  81. package/dist/processing-evaluator-custom.js +14 -0
  82. package/dist/processing-evaluator-custom.js.map +1 -0
  83. package/dist/processing-evaluator-geometry.d.ts +8 -0
  84. package/dist/processing-evaluator-geometry.d.ts.map +1 -0
  85. package/dist/processing-evaluator-geometry.js +21 -0
  86. package/dist/processing-evaluator-geometry.js.map +1 -0
  87. package/dist/processing-evaluator-numeric.d.ts +8 -0
  88. package/dist/processing-evaluator-numeric.d.ts.map +1 -0
  89. package/dist/processing-evaluator-numeric.js +112 -0
  90. package/dist/processing-evaluator-numeric.js.map +1 -0
  91. package/dist/processing-evaluator-string.d.ts +8 -0
  92. package/dist/processing-evaluator-string.d.ts.map +1 -0
  93. package/dist/processing-evaluator-string.js +33 -0
  94. package/dist/processing-evaluator-string.js.map +1 -0
  95. package/dist/processing-evaluator-variable.d.ts +8 -0
  96. package/dist/processing-evaluator-variable.d.ts.map +1 -0
  97. package/dist/processing-evaluator-variable.js +39 -0
  98. package/dist/processing-evaluator-variable.js.map +1 -0
  99. package/dist/processing-evaluator.d.ts +22 -0
  100. package/dist/processing-evaluator.d.ts.map +1 -0
  101. package/dist/processing-evaluator.js +137 -0
  102. package/dist/processing-evaluator.js.map +1 -0
  103. package/dist/processing-expression-children.d.ts +3 -0
  104. package/dist/processing-expression-children.d.ts.map +1 -0
  105. package/dist/processing-expression-children.js +68 -0
  106. package/dist/processing-expression-children.js.map +1 -0
  107. package/dist/processing-mapping.d.ts +13 -0
  108. package/dist/processing-mapping.d.ts.map +1 -0
  109. package/dist/processing-mapping.js +120 -0
  110. package/dist/processing-mapping.js.map +1 -0
  111. package/dist/processing-operators.d.ts +11 -0
  112. package/dist/processing-operators.d.ts.map +1 -0
  113. package/dist/processing-operators.js +178 -0
  114. package/dist/processing-operators.js.map +1 -0
  115. package/dist/processing-random.d.ts +2 -0
  116. package/dist/processing-random.d.ts.map +1 -0
  117. package/dist/processing-random.js +16 -0
  118. package/dist/processing-random.js.map +1 -0
  119. package/dist/processing-state.d.ts +8 -0
  120. package/dist/processing-state.d.ts.map +1 -0
  121. package/dist/processing-state.js +44 -0
  122. package/dist/processing-state.js.map +1 -0
  123. package/dist/processing-templates.d.ts +3 -0
  124. package/dist/processing-templates.d.ts.map +1 -0
  125. package/dist/processing-templates.js +12 -0
  126. package/dist/processing-templates.js.map +1 -0
  127. package/dist/processing-values.d.ts +15 -0
  128. package/dist/processing-values.d.ts.map +1 -0
  129. package/dist/processing-values.js +150 -0
  130. package/dist/processing-values.js.map +1 -0
  131. package/dist/processing-variables.d.ts +7 -0
  132. package/dist/processing-variables.d.ts.map +1 -0
  133. package/dist/processing-variables.js +40 -0
  134. package/dist/processing-variables.js.map +1 -0
  135. package/dist/qti-package-assets.d.ts +8 -0
  136. package/dist/qti-package-assets.d.ts.map +1 -0
  137. package/dist/qti-package-assets.js +173 -0
  138. package/dist/qti-package-assets.js.map +1 -0
  139. package/dist/qti-package-items.d.ts +14 -0
  140. package/dist/qti-package-items.d.ts.map +1 -0
  141. package/dist/qti-package-items.js +138 -0
  142. package/dist/qti-package-items.js.map +1 -0
  143. package/dist/qti-package-manifest.d.ts +13 -0
  144. package/dist/qti-package-manifest.d.ts.map +1 -0
  145. package/dist/qti-package-manifest.js +109 -0
  146. package/dist/qti-package-manifest.js.map +1 -0
  147. package/dist/qti-package-metadata.d.ts +8 -0
  148. package/dist/qti-package-metadata.d.ts.map +1 -0
  149. package/dist/qti-package-metadata.js +115 -0
  150. package/dist/qti-package-metadata.js.map +1 -0
  151. package/dist/qti-package-paths.d.ts +6 -0
  152. package/dist/qti-package-paths.d.ts.map +1 -0
  153. package/dist/qti-package-paths.js +32 -0
  154. package/dist/qti-package-paths.js.map +1 -0
  155. package/dist/qti-package-types.d.ts +98 -0
  156. package/dist/qti-package-types.d.ts.map +1 -0
  157. package/dist/qti-package-types.js +2 -0
  158. package/dist/qti-package-types.js.map +1 -0
  159. package/dist/qti-package-xml.d.ts +21 -0
  160. package/dist/qti-package-xml.d.ts.map +1 -0
  161. package/dist/qti-package-xml.js +68 -0
  162. package/dist/qti-package-xml.js.map +1 -0
  163. package/dist/qti-package-zip.d.ts +24 -0
  164. package/dist/qti-package-zip.d.ts.map +1 -0
  165. package/dist/qti-package-zip.js +185 -0
  166. package/dist/qti-package-zip.js.map +1 -0
  167. package/dist/qti-package.d.ts +7 -0
  168. package/dist/qti-package.d.ts.map +1 -0
  169. package/dist/qti-package.js +76 -0
  170. package/dist/qti-package.js.map +1 -0
  171. package/dist/response-input.d.ts +9 -0
  172. package/dist/response-input.d.ts.map +1 -0
  173. package/dist/response-input.js +9 -0
  174. package/dist/response-input.js.map +1 -0
  175. package/dist/response-validation-policy.d.ts +31 -0
  176. package/dist/response-validation-policy.d.ts.map +1 -0
  177. package/dist/response-validation-policy.js +158 -0
  178. package/dist/response-validation-policy.js.map +1 -0
  179. package/dist/response-validation.d.ts +23 -0
  180. package/dist/response-validation.d.ts.map +1 -0
  181. package/dist/response-validation.js +157 -0
  182. package/dist/response-validation.js.map +1 -0
  183. package/dist/scoring-disposition-policy.d.ts +8 -0
  184. package/dist/scoring-disposition-policy.d.ts.map +1 -0
  185. package/dist/scoring-disposition-policy.js +24 -0
  186. package/dist/scoring-disposition-policy.js.map +1 -0
  187. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  188. package/dist/serializer-processing-expressions.js +2 -8
  189. package/dist/serializer-processing-expressions.js.map +1 -1
  190. package/dist/serializer-processing-xml.js +2 -2
  191. package/dist/serializer-processing-xml.js.map +1 -1
  192. package/dist/serializer-processing.d.ts.map +1 -1
  193. package/dist/serializer-processing.js +3 -0
  194. package/dist/serializer-processing.js.map +1 -1
  195. package/dist/session.d.ts +5 -14
  196. package/dist/session.d.ts.map +1 -1
  197. package/dist/session.js +76 -1158
  198. package/dist/session.js.map +1 -1
  199. package/dist/shared-vocabulary-coverage-policy.js +1 -1
  200. package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
  201. package/dist/shared-vocabulary-generated-families.js +1 -1
  202. package/dist/shared-vocabulary-generated-families.js.map +1 -1
  203. package/dist/shared-vocabulary-support.js +3 -3
  204. package/dist/shared-vocabulary-support.js.map +1 -1
  205. package/dist/shared-vocabulary.d.ts.map +1 -1
  206. package/dist/shared-vocabulary.js +4 -3
  207. package/dist/shared-vocabulary.js.map +1 -1
  208. package/dist/support-evidence.d.ts +22 -0
  209. package/dist/support-evidence.d.ts.map +1 -0
  210. package/dist/support-evidence.js +93 -0
  211. package/dist/support-evidence.js.map +1 -0
  212. package/dist/support.d.ts.map +1 -1
  213. package/dist/support.js +30 -43
  214. package/dist/support.js.map +1 -1
  215. package/dist/template-presentation-materializer.d.ts +14 -0
  216. package/dist/template-presentation-materializer.d.ts.map +1 -0
  217. package/dist/template-presentation-materializer.js +143 -0
  218. package/dist/template-presentation-materializer.js.map +1 -0
  219. package/dist/trusted-item-session.d.ts +25 -8
  220. package/dist/trusted-item-session.d.ts.map +1 -1
  221. package/dist/trusted-item-session.js +57 -51
  222. package/dist/trusted-item-session.js.map +1 -1
  223. package/dist/trusted-session-materialization.d.ts +33 -0
  224. package/dist/trusted-session-materialization.d.ts.map +1 -0
  225. package/dist/trusted-session-materialization.js +26 -0
  226. package/dist/trusted-session-materialization.js.map +1 -0
  227. package/dist/tts.d.ts.map +1 -1
  228. package/dist/tts.js +16 -2
  229. package/dist/tts.js.map +1 -1
  230. package/dist/validation-geometry.d.ts +8 -0
  231. package/dist/validation-geometry.d.ts.map +1 -0
  232. package/dist/validation-geometry.js +187 -0
  233. package/dist/validation-geometry.js.map +1 -0
  234. package/dist/validation-interactions.d.ts +3 -0
  235. package/dist/validation-interactions.d.ts.map +1 -0
  236. package/dist/validation-interactions.js +644 -0
  237. package/dist/validation-interactions.js.map +1 -0
  238. package/dist/validation-primitives.d.ts +12 -0
  239. package/dist/validation-primitives.d.ts.map +1 -0
  240. package/dist/validation-primitives.js +53 -0
  241. package/dist/validation-primitives.js.map +1 -0
  242. package/dist/validation-processing.d.ts +4 -0
  243. package/dist/validation-processing.d.ts.map +1 -0
  244. package/dist/validation-processing.js +569 -0
  245. package/dist/validation-processing.js.map +1 -0
  246. package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
  247. package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
  248. package/dist/validation-shared-vocabulary-layout.js +102 -0
  249. package/dist/validation-shared-vocabulary-layout.js.map +1 -0
  250. package/dist/validation.d.ts.map +1 -1
  251. package/dist/validation.js +27 -1641
  252. package/dist/validation.js.map +1 -1
  253. package/dist/variable-snapshot.d.ts +9 -0
  254. package/dist/variable-snapshot.d.ts.map +1 -0
  255. package/dist/variable-snapshot.js +9 -0
  256. package/dist/variable-snapshot.js.map +1 -0
  257. package/dist/xml.js +1 -1
  258. package/dist/xml.js.map +1 -1
  259. package/package.json +1 -1
  260. package/src/adaptive-turn-materializer.ts +29 -16
  261. package/src/adaptive-turn.ts +29 -6
  262. package/src/assert-never.ts +4 -0
  263. package/src/attempt-state-constants.ts +5 -0
  264. package/src/attempt-state.ts +245 -0
  265. package/src/catalog.ts +1 -1
  266. package/src/content-text.ts +3 -0
  267. package/src/custom-operators.ts +12 -0
  268. package/src/delivery-preparation.ts +121 -0
  269. package/src/delivery-redaction.ts +37 -6
  270. package/src/delivery-security.ts +2 -2
  271. package/src/index.ts +67 -0
  272. package/src/item-scoring-expectation.ts +9 -0
  273. package/src/item-submission-materialization.ts +120 -0
  274. package/src/parser-custom-interactions.ts +1 -1
  275. package/src/parser-declarations.ts +4 -3
  276. package/src/parser-item-metadata.ts +8 -7
  277. package/src/parser-processing.ts +2 -2
  278. package/src/parser-values.ts +36 -0
  279. package/src/processing-evaluator-boolean.ts +48 -0
  280. package/src/processing-evaluator-collection.ts +50 -0
  281. package/src/processing-evaluator-comparison.ts +106 -0
  282. package/src/processing-evaluator-custom.ts +20 -0
  283. package/src/processing-evaluator-geometry.ts +26 -0
  284. package/src/processing-evaluator-numeric.ts +155 -0
  285. package/src/processing-evaluator-string.ts +60 -0
  286. package/src/processing-evaluator-variable.ts +81 -0
  287. package/src/processing-evaluator.ts +173 -0
  288. package/src/processing-expression-children.ts +69 -0
  289. package/src/processing-mapping.ts +153 -0
  290. package/src/processing-operators.ts +184 -0
  291. package/src/processing-random.ts +16 -0
  292. package/src/processing-state.ts +69 -0
  293. package/src/processing-templates.ts +12 -0
  294. package/src/processing-values.ts +162 -0
  295. package/src/processing-variables.ts +83 -0
  296. package/src/qti-package-assets.ts +256 -0
  297. package/src/qti-package-items.ts +240 -0
  298. package/src/qti-package-manifest.ts +210 -0
  299. package/src/qti-package-metadata.ts +143 -0
  300. package/src/qti-package-paths.ts +56 -0
  301. package/src/qti-package-types.ts +117 -0
  302. package/src/qti-package-xml.ts +97 -0
  303. package/src/qti-package-zip.ts +325 -0
  304. package/src/qti-package.ts +176 -0
  305. package/src/response-input.ts +19 -0
  306. package/src/response-validation-policy.ts +218 -0
  307. package/src/response-validation.ts +282 -0
  308. package/src/scoring-disposition-policy.ts +37 -0
  309. package/src/serializer-processing-expressions.ts +2 -12
  310. package/src/serializer-processing-xml.ts +2 -2
  311. package/src/serializer-processing.ts +3 -0
  312. package/src/session.ts +128 -2006
  313. package/src/shared-vocabulary-coverage-policy.ts +1 -1
  314. package/src/shared-vocabulary-generated-families.ts +1 -1
  315. package/src/shared-vocabulary-support.ts +3 -3
  316. package/src/shared-vocabulary.ts +3 -2
  317. package/src/support-evidence.ts +107 -0
  318. package/src/support.ts +59 -50
  319. package/src/template-presentation-materializer.ts +219 -0
  320. package/src/trusted-item-session.ts +86 -78
  321. package/src/trusted-session-materialization.ts +67 -0
  322. package/src/tts.ts +29 -15
  323. package/src/validation-geometry.ts +204 -0
  324. package/src/validation-interactions.ts +831 -0
  325. package/src/validation-primitives.ts +68 -0
  326. package/src/validation-processing.ts +785 -0
  327. package/src/validation-shared-vocabulary-layout.ts +117 -0
  328. package/src/validation.ts +44 -2077
  329. package/src/variable-snapshot.ts +20 -0
  330. package/src/xml.ts +1 -1
@@ -0,0 +1,218 @@
1
+ import type { QtiChoice, QtiDiagnostic, QtiInteraction, QtiValue } from "./types.js";
2
+ import { parseNonNegativeInteger } from "./validation-primitives.js";
3
+ import { qtiValueToIdentifierList } from "./value-format.js";
4
+
5
+ export interface QtiResponseValidationPolicy {
6
+ checkMinimum: boolean;
7
+ checkMaximum: boolean;
8
+ checkMatchMax: boolean;
9
+ }
10
+
11
+ export function responseIsEmpty(value: QtiValue): boolean {
12
+ return value === null || value === "" || (Array.isArray(value) && value.length === 0);
13
+ }
14
+
15
+ export function responseCount(value: QtiValue): number {
16
+ return responseIsEmpty(value) ? 0 : Array.isArray(value) ? value.length : 1;
17
+ }
18
+
19
+ /** Order and graphic order ignore max-choices unless min-choices is authored. */
20
+ export function orderSubsetLimitsActive(interaction: QtiInteraction): boolean {
21
+ return (
22
+ (interaction.type === "order" || interaction.type === "graphicOrder") &&
23
+ interaction.attributes["min-choices"] !== undefined
24
+ );
25
+ }
26
+
27
+ export function responseLimitAttribute(
28
+ interaction: QtiInteraction,
29
+ choiceKey: "min-choices" | "max-choices",
30
+ associationKey: "min-associations" | "max-associations",
31
+ ): string | undefined {
32
+ if (interaction.type === "order" || interaction.type === "graphicOrder") {
33
+ return interaction.attributes[choiceKey];
34
+ }
35
+ return interaction.attributes[choiceKey] ?? interaction.attributes[associationKey];
36
+ }
37
+
38
+ export function minimumRequiredResponses(interaction: QtiInteraction | undefined): number {
39
+ if (!interaction) return 1;
40
+ if (interaction.type === "media") return minimumMediaPlays(interaction);
41
+ const explicit = responseLimitAttribute(interaction, "min-choices", "min-associations");
42
+ if (explicit === undefined) return 1;
43
+ return parseNonNegativeInteger(explicit) ?? 1;
44
+ }
45
+
46
+ export function maximumAllowedResponses(
47
+ interaction: QtiInteraction | undefined,
48
+ ): number | undefined {
49
+ if (!interaction) return undefined;
50
+ if (interaction.type === "media") return maximumMediaPlays(interaction);
51
+ if (
52
+ (interaction.type === "order" || interaction.type === "graphicOrder") &&
53
+ !orderSubsetLimitsActive(interaction)
54
+ ) {
55
+ return undefined;
56
+ }
57
+ const explicit = responseLimitAttribute(interaction, "max-choices", "max-associations");
58
+ if (explicit === undefined) return undefined;
59
+ const parsed = parseNonNegativeInteger(explicit);
60
+ return parsed === undefined || parsed <= 0 ? undefined : parsed;
61
+ }
62
+
63
+ export function associationMaximumResponses(interaction: QtiInteraction): number | undefined {
64
+ return interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
65
+ }
66
+
67
+ export function mediaPlayCount(value: QtiValue): number {
68
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
69
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
70
+ }
71
+
72
+ function minimumMediaPlays(interaction: QtiInteraction): number {
73
+ return parseNonNegativeInteger(interaction.attributes["min-plays"]) ?? 0;
74
+ }
75
+
76
+ function maximumMediaPlays(interaction: QtiInteraction): number | undefined {
77
+ const parsed = parseNonNegativeInteger(interaction.attributes["max-plays"]);
78
+ return parsed === undefined || parsed <= 0 ? undefined : parsed;
79
+ }
80
+
81
+ export { maximumMediaPlays, minimumMediaPlays };
82
+
83
+ export function responseValidationPolicy(
84
+ declaration: { readonly correctResponse: QtiValue | null },
85
+ interaction: QtiInteraction | undefined,
86
+ ): QtiResponseValidationPolicy {
87
+ const authoredMinimum =
88
+ interaction === undefined
89
+ ? undefined
90
+ : responseLimitAttribute(interaction, "min-choices", "min-associations");
91
+ const validatesMinimum =
92
+ declaration.correctResponse !== null ||
93
+ interaction?.type === "media" ||
94
+ authoredMinimum !== undefined;
95
+ const maximum = maximumAllowedResponses(interaction);
96
+ if (
97
+ declaration.correctResponse === null &&
98
+ interaction?.type !== "media" &&
99
+ !validatesMinimum &&
100
+ maximum === undefined
101
+ ) {
102
+ return { checkMinimum: false, checkMaximum: false, checkMatchMax: false };
103
+ }
104
+ return {
105
+ checkMinimum: validatesMinimum,
106
+ checkMaximum: maximum !== undefined,
107
+ checkMatchMax: interaction !== undefined,
108
+ };
109
+ }
110
+
111
+ export function requiredResponseDiagnostic(
112
+ responseIdentifier: string,
113
+ interaction: QtiInteraction | undefined,
114
+ minimum: number,
115
+ ): QtiDiagnostic {
116
+ return {
117
+ code: "response.required",
118
+ severity: "error",
119
+ message:
120
+ interaction?.attributes["data-min-selections-message"] ??
121
+ (interaction?.type === "media"
122
+ ? `${responseIdentifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
123
+ : minimum === 1
124
+ ? `${responseIdentifier} requires a response.`
125
+ : `${responseIdentifier} requires at least ${minimum} responses.`),
126
+ path: responseIdentifier,
127
+ };
128
+ }
129
+
130
+ export function maximumResponseDiagnostic(
131
+ responseIdentifier: string,
132
+ interaction: QtiInteraction | undefined,
133
+ maximum: number,
134
+ ): QtiDiagnostic {
135
+ return {
136
+ code: "response.maximum",
137
+ severity: "error",
138
+ message:
139
+ interaction?.attributes["data-max-selections-message"] ??
140
+ (interaction?.type === "media"
141
+ ? `${responseIdentifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
142
+ : `${responseIdentifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
143
+ path: responseIdentifier,
144
+ };
145
+ }
146
+
147
+ export function parseUnlimitedMaximum(value: string | undefined): number | undefined {
148
+ if (value === undefined) return undefined;
149
+ const parsed = parseNonNegativeInteger(value);
150
+ return parsed === undefined || parsed <= 0 ? undefined : parsed;
151
+ }
152
+
153
+ export function choiceMatchMaximum(choice: QtiChoice): number | undefined {
154
+ return parseUnlimitedMaximum(choice.attributes["match-max"]);
155
+ }
156
+
157
+ export type DirectedPairUseSide = "source" | "target" | "either";
158
+
159
+ export function directedPairChoiceUseCount(
160
+ choice: QtiChoice,
161
+ selectedPairs: string[],
162
+ side: DirectedPairUseSide = "either",
163
+ ): number {
164
+ return selectedPairs.reduce((count, pair) => {
165
+ const [source = "", target = ""] = pair.split(/\s+/);
166
+ if (side === "source") return source === choice.identifier ? count + 1 : count;
167
+ if (side === "target") return target === choice.identifier ? count + 1 : count;
168
+ return count + (source === choice.identifier ? 1 : 0) + (target === choice.identifier ? 1 : 0);
169
+ }, 0);
170
+ }
171
+
172
+ export function choiceMatchLimitExceeded(choice: QtiChoice, useCount: number): boolean {
173
+ const maximum = choiceMatchMaximum(choice);
174
+ return maximum !== undefined && useCount > maximum;
175
+ }
176
+
177
+ export function wouldExceedChoiceMatchMaximum(
178
+ choice: QtiChoice,
179
+ selectedPairs: string[],
180
+ side: DirectedPairUseSide = "either",
181
+ ): boolean {
182
+ return choiceMatchLimitExceeded(
183
+ choice,
184
+ directedPairChoiceUseCount(choice, selectedPairs, side) + 1,
185
+ );
186
+ }
187
+
188
+ function responseChoiceIdentifiers(response: QtiValue): string[] {
189
+ return qtiValueToIdentifierList(response).flatMap((value) => value.split(/\s+/).filter(Boolean));
190
+ }
191
+
192
+ export function matchMaxDiagnostics(
193
+ responseIdentifier: string,
194
+ interaction: QtiInteraction,
195
+ response: QtiValue,
196
+ ): QtiDiagnostic[] {
197
+ const identifiers = responseChoiceIdentifiers(response);
198
+ if (identifiers.length === 0) return [];
199
+ const counts = new Map<string, number>();
200
+ for (const identifier of identifiers) {
201
+ counts.set(identifier, (counts.get(identifier) ?? 0) + 1);
202
+ }
203
+
204
+ const diagnostics: QtiDiagnostic[] = [];
205
+ for (const choice of interaction.choices) {
206
+ const maximum = choiceMatchMaximum(choice);
207
+ if (maximum === undefined) continue;
208
+ const count = counts.get(choice.identifier) ?? 0;
209
+ if (count <= maximum) continue;
210
+ diagnostics.push({
211
+ code: "response.matchMax",
212
+ severity: "error",
213
+ message: `${choice.text || choice.identifier} may be used at most ${maximum} time${maximum === 1 ? "" : "s"}.`,
214
+ path: responseIdentifier,
215
+ });
216
+ }
217
+ return diagnostics;
218
+ }
@@ -0,0 +1,282 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiCardinality,
4
+ QtiDiagnostic,
5
+ QtiInteraction,
6
+ QtiResponseDeclaration,
7
+ QtiValue,
8
+ } from "./types.js";
9
+ import { assertNever } from "./assert-never.js";
10
+ import { isNullResponse, isRecordValue } from "./processing-values.js";
11
+ import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
12
+ import {
13
+ matchMaxDiagnostics,
14
+ maximumAllowedResponses,
15
+ maximumResponseDiagnostic,
16
+ mediaPlayCount,
17
+ minimumRequiredResponses,
18
+ requiredResponseDiagnostic,
19
+ responseCount,
20
+ responseValidationPolicy,
21
+ } from "./response-validation-policy.js";
22
+ import { readQtiJsonValue } from "./value-format.js";
23
+
24
+ export type { QtiNamedResponseInput as QtiResponseVariableInput } from "./response-input.js";
25
+ export type QtiResponseVariablesInput = Record<string, unknown> | readonly QtiNamedResponseInput[];
26
+
27
+ export type QtiResponseValidationDiagnosticCode =
28
+ | "response.required"
29
+ | "response.maximum"
30
+ | "response.matchMax"
31
+ | "response.cardinality"
32
+ | "response.undeclared"
33
+ | "response.identifier.required"
34
+ | "response.value.invalid";
35
+
36
+ export interface QtiResponseValidationDiagnostic extends QtiDiagnostic {
37
+ code: QtiResponseValidationDiagnosticCode;
38
+ identifier?: string | undefined;
39
+ }
40
+
41
+ export interface QtiResponseValidationResult {
42
+ ok: boolean;
43
+ diagnostics: QtiResponseValidationDiagnostic[];
44
+ }
45
+
46
+ export interface QtiResponseValidationInput {
47
+ item: QtiAssessmentItem;
48
+ responses: QtiResponseVariablesInput;
49
+ allowIncompleteResponses?: boolean | undefined;
50
+ allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
51
+ responseIdentifiers?: Iterable<string> | undefined;
52
+ }
53
+
54
+ /** Validate submitted response variables against a parsed QTI assessment item. */
55
+ export function validateQtiResponseVariables(
56
+ input: QtiResponseValidationInput,
57
+ ): QtiResponseValidationResult {
58
+ const diagnostics: QtiResponseValidationDiagnostic[] = [];
59
+ const declaredIdentifiers = new Set(
60
+ input.item.responseDeclarations.map((declaration) => declaration.identifier),
61
+ );
62
+ const allowedUndeclaredIdentifiers = new Set(input.allowedUndeclaredResponseIdentifiers ?? []);
63
+ const responses = ingestSubmittedResponses(
64
+ input.responses,
65
+ declaredIdentifiers,
66
+ allowedUndeclaredIdentifiers,
67
+ diagnostics,
68
+ );
69
+ const scopedResponseIdentifiers = input.responseIdentifiers
70
+ ? new Set(input.responseIdentifiers)
71
+ : undefined;
72
+ const interactionsByResponse = indexInteractionsByResponse(input.item.interactions);
73
+
74
+ for (const declaration of input.item.responseDeclarations) {
75
+ if (
76
+ scopedResponseIdentifiers !== undefined &&
77
+ !scopedResponseIdentifiers.has(declaration.identifier)
78
+ ) {
79
+ continue;
80
+ }
81
+
82
+ const value = responses.get(declaration.identifier);
83
+ if (value !== undefined) {
84
+ validateResponseCardinality(declaration, value, diagnostics);
85
+ }
86
+
87
+ const interactions = interactionsByResponse.get(declaration.identifier);
88
+ if (interactions === undefined) {
89
+ validateDeclarationResponse(
90
+ declaration,
91
+ undefined,
92
+ value,
93
+ input.allowIncompleteResponses,
94
+ diagnostics,
95
+ );
96
+ continue;
97
+ }
98
+
99
+ for (const interaction of interactions) {
100
+ validateDeclarationResponse(
101
+ declaration,
102
+ interaction,
103
+ value,
104
+ input.allowIncompleteResponses,
105
+ diagnostics,
106
+ );
107
+ }
108
+ }
109
+
110
+ return {
111
+ ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
112
+ diagnostics,
113
+ };
114
+ }
115
+
116
+ function ingestSubmittedResponses(
117
+ input: QtiResponseVariablesInput,
118
+ declaredIdentifiers: ReadonlySet<string>,
119
+ allowedUndeclaredIdentifiers: ReadonlySet<string>,
120
+ diagnostics: QtiResponseValidationDiagnostic[],
121
+ ): Map<string, QtiValue> {
122
+ const responses = new Map<string, QtiValue>();
123
+
124
+ for (const entry of listNamedResponseInputs(input)) {
125
+ const identifier = entry.identifier.trim();
126
+ if (!identifier) {
127
+ diagnostics.push({
128
+ code: "response.identifier.required",
129
+ severity: "error",
130
+ message: "Response identifiers must be non-empty strings.",
131
+ });
132
+ continue;
133
+ }
134
+
135
+ const value = readQtiJsonValue(entry.value);
136
+ if (value === undefined) {
137
+ diagnostics.push({
138
+ code: "response.value.invalid",
139
+ severity: "error",
140
+ identifier,
141
+ message: `Response ${identifier} is not a supported QTI value.`,
142
+ path: identifier,
143
+ });
144
+ continue;
145
+ }
146
+
147
+ if (!declaredIdentifiers.has(identifier)) {
148
+ if (allowedUndeclaredIdentifiers.has(identifier)) continue;
149
+ diagnostics.push({
150
+ code: "response.undeclared",
151
+ severity: "error",
152
+ identifier,
153
+ message: `Response ${identifier} is not declared by the assessment item.`,
154
+ path: identifier,
155
+ });
156
+ continue;
157
+ }
158
+
159
+ responses.set(identifier, value);
160
+ }
161
+
162
+ return responses;
163
+ }
164
+
165
+ function indexInteractionsByResponse(
166
+ interactions: readonly QtiInteraction[],
167
+ ): Map<string, QtiInteraction[]> {
168
+ const indexed = new Map<string, QtiInteraction[]>();
169
+ for (const interaction of interactions) {
170
+ const identifier = interaction.responseIdentifier;
171
+ if (!identifier) continue;
172
+ const bucket = indexed.get(identifier);
173
+ if (bucket) {
174
+ bucket.push(interaction);
175
+ } else {
176
+ indexed.set(identifier, [interaction]);
177
+ }
178
+ }
179
+ return indexed;
180
+ }
181
+
182
+ function validateResponseCardinality(
183
+ declaration: QtiResponseDeclaration,
184
+ value: QtiValue,
185
+ diagnostics: QtiResponseValidationDiagnostic[],
186
+ ): void {
187
+ if (value === null) return;
188
+
189
+ if (matchesCardinality(declaration.cardinality, value)) return;
190
+ diagnostics.push({
191
+ code: "response.cardinality",
192
+ severity: "error",
193
+ identifier: declaration.identifier,
194
+ message: `Response ${declaration.identifier} must match ${declaration.cardinality} cardinality.`,
195
+ path: declaration.identifier,
196
+ source: declaration.source,
197
+ });
198
+ }
199
+
200
+ function matchesCardinality(cardinality: QtiCardinality, value: QtiValue): boolean {
201
+ switch (cardinality) {
202
+ case "single":
203
+ return !Array.isArray(value) && !isRecordValue(value);
204
+ case "multiple":
205
+ case "ordered":
206
+ return Array.isArray(value);
207
+ case "record":
208
+ return isRecordValue(value);
209
+ }
210
+ return assertNever(cardinality);
211
+ }
212
+
213
+ function validateDeclarationResponse(
214
+ declaration: QtiResponseDeclaration,
215
+ interaction: QtiInteraction | undefined,
216
+ value: QtiValue | undefined,
217
+ allowIncompleteResponses: boolean | undefined,
218
+ diagnostics: QtiResponseValidationDiagnostic[],
219
+ ): void {
220
+ const policy = responseValidationPolicy(declaration, interaction);
221
+ if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
222
+
223
+ const effectiveValue = value ?? null;
224
+ const count =
225
+ interaction?.type === "media" ? mediaPlayCount(effectiveValue) : responseCount(effectiveValue);
226
+
227
+ if (policy.checkMinimum && !allowIncompleteResponses) {
228
+ const minimum = minimumRequiredResponses(interaction);
229
+ if (count < minimum) {
230
+ diagnostics.push(
231
+ attachResponseIdentifier(
232
+ declaration.identifier,
233
+ requiredResponseDiagnostic(declaration.identifier, interaction, minimum),
234
+ ),
235
+ );
236
+ }
237
+ }
238
+
239
+ if (policy.checkMaximum && value !== undefined && !isNullResponse(value)) {
240
+ const maximum = maximumAllowedResponses(interaction);
241
+ if (maximum !== undefined && count > maximum) {
242
+ diagnostics.push(
243
+ attachResponseIdentifier(
244
+ declaration.identifier,
245
+ maximumResponseDiagnostic(declaration.identifier, interaction, maximum),
246
+ ),
247
+ );
248
+ }
249
+ }
250
+
251
+ if (policy.checkMatchMax && interaction && value !== undefined && !isNullResponse(value)) {
252
+ diagnostics.push(
253
+ ...matchMaxDiagnostics(declaration.identifier, interaction, value).map((diagnostic) =>
254
+ attachResponseIdentifier(declaration.identifier, diagnostic),
255
+ ),
256
+ );
257
+ }
258
+ }
259
+
260
+ function attachResponseIdentifier(
261
+ identifier: string,
262
+ diagnostic: QtiDiagnostic,
263
+ ): QtiResponseValidationDiagnostic {
264
+ if (!isResponseValidationDiagnosticCode(diagnostic.code)) {
265
+ throw new Error(`Unexpected response validation code: ${diagnostic.code}`);
266
+ }
267
+ return { ...diagnostic, identifier, code: diagnostic.code };
268
+ }
269
+
270
+ function isResponseValidationDiagnosticCode(
271
+ code: string,
272
+ ): code is QtiResponseValidationDiagnosticCode {
273
+ return (
274
+ code === "response.required" ||
275
+ code === "response.maximum" ||
276
+ code === "response.matchMax" ||
277
+ code === "response.cardinality" ||
278
+ code === "response.undeclared" ||
279
+ code === "response.identifier.required" ||
280
+ code === "response.value.invalid"
281
+ );
282
+ }
@@ -0,0 +1,37 @@
1
+ import type { QtiAssessmentItem } from "./types.js";
2
+ import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
3
+
4
+ export type QtiItemSubmissionScoringDisposition =
5
+ | "scored"
6
+ | "manual-scoring-required"
7
+ | "unscored-reference"
8
+ | "invalid";
9
+
10
+ const MANUALLY_SCORED_INTERACTION_TYPES = new Set<string>([
11
+ "custom",
12
+ "drawing",
13
+ "extendedText",
14
+ "portableCustom",
15
+ "upload",
16
+ ]);
17
+
18
+ /** Interaction types that typically require human review even without response processing. */
19
+ export function itemHasManuallyScoredInteractions(item: QtiAssessmentItem): boolean {
20
+ return item.interactions.some((interaction) =>
21
+ MANUALLY_SCORED_INTERACTION_TYPES.has(interaction.type),
22
+ );
23
+ }
24
+
25
+ export function itemNeedsScoringFollowUpWhenUnscored(item: QtiAssessmentItem): boolean {
26
+ return itemExpectsAutomatedScore(item) || itemHasManuallyScoredInteractions(item);
27
+ }
28
+
29
+ /** Default generic disposition taxonomy shipped by qti3-core for submission materialization. */
30
+ export function classifyQtiItemScoringDisposition(
31
+ item: QtiAssessmentItem,
32
+ score: number | null,
33
+ ): Exclude<QtiItemSubmissionScoringDisposition, "invalid"> {
34
+ if (score !== null) return "scored";
35
+ if (itemNeedsScoringFollowUpWhenUnscored(item)) return "manual-scoring-required";
36
+ return "unscored-reference";
37
+ }
@@ -11,6 +11,7 @@ import {
11
11
  type UnaryExpressionType,
12
12
  } from "./processing-expression-tags.js";
13
13
  import { escapeXmlText } from "./xml.js";
14
+ import { assertNever } from "./assert-never.js";
14
15
  import {
15
16
  addSerializerDiagnostic,
16
17
  knownAttributesWithBagFallback,
@@ -274,7 +275,7 @@ export function serializeExpression(
274
275
  indent,
275
276
  );
276
277
  default:
277
- return unsupportedExpression(expression, context);
278
+ return assertNever(expression);
278
279
  }
279
280
  }
280
281
 
@@ -443,14 +444,3 @@ function isSerializableBaseValue(value: QtiValue): value is string | number | bo
443
444
  (typeof value === "number" && Number.isFinite(value))
444
445
  );
445
446
  }
446
-
447
- function unsupportedExpression(
448
- expression: QtiProcessingExpression,
449
- context: SerializationContext,
450
- ): string[] {
451
- addSerializerDiagnostic(context, "responseProcessing.serialize.unsupportedExpression", {
452
- message: `Unsupported response-processing expression type: ${expression.type}.`,
453
- source: expression.source,
454
- });
455
- return [];
456
- }
@@ -41,13 +41,13 @@ export function knownAttributesWithBagFallback(
41
41
  });
42
42
  const extras = Object.entries(bag ?? {})
43
43
  .filter(([name]) => !knownNames.has(name))
44
- .sort(([left], [right]) => left.localeCompare(right));
44
+ .toSorted(([left], [right]) => left.localeCompare(right));
45
45
  return [...resolved, ...extras];
46
46
  }
47
47
 
48
48
  export function sortedBagAttributes(bag: Record<string, string> | undefined): XmlAttribute[] {
49
49
  return Object.entries(bag ?? {})
50
- .sort(([left], [right]) => left.localeCompare(right))
50
+ .toSorted(([left], [right]) => left.localeCompare(right))
51
51
  .map(([name, value]) => [name, value] as const);
52
52
  }
53
53
 
@@ -13,6 +13,7 @@ import type {
13
13
  QtiResponseProcessing,
14
14
  QtiResponseRule,
15
15
  } from "./types.js";
16
+ import { assertNever } from "./assert-never.js";
16
17
 
17
18
  export interface QtiSerializeResponseProcessingResult {
18
19
  ok: boolean;
@@ -114,6 +115,8 @@ function serializeResponseRule(
114
115
  rule.rules.flatMap((child) => serializeResponseRule(child, context, indent + 1)),
115
116
  indent,
116
117
  );
118
+ default:
119
+ return assertNever(rule);
117
120
  }
118
121
  }
119
122