@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
@@ -1,7 +1,8 @@
1
1
  import { parseQtiXml } from "./parser.js";
2
+ import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
3
+ import { validateQtiResponseVariables } from "./response-validation.js";
2
4
  import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
3
5
  import type {
4
- QtiAssessmentItem,
5
6
  QtiAttemptStateV1,
6
7
  QtiAttemptStatus,
7
8
  QtiDiagnostic,
@@ -10,20 +11,20 @@ import type {
10
11
  QtiValue,
11
12
  } from "./types.js";
12
13
  import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
14
+ import {
15
+ listNamedResponseInputs,
16
+ type QtiNamedResponseInput,
17
+ type QtiNamedResponsesInput,
18
+ } from "./response-input.js";
13
19
 
14
- export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn";
20
+ export type QtiTrustedResponseInput = QtiNamedResponseInput;
15
21
 
16
- export interface QtiTrustedResponseInput {
17
- identifier: string;
18
- value: unknown;
19
- }
22
+ export type QtiTrustedResponsesInput = QtiNamedResponsesInput;
23
+
24
+ export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn" | "itemSubmission";
20
25
 
21
- export type QtiTrustedResponsesInput =
22
- | Record<string, unknown>
23
- | readonly QtiTrustedResponseInput[]
24
- | undefined;
26
+ export type QtiTrustedSubmissionValidation = "trusted" | "strict";
25
27
 
26
- /** Trusted candidate responses and portable custom interaction state for one application. */
27
28
  export interface QtiTrustedResponseApplication {
28
29
  trustedResponses?: QtiTrustedResponsesInput;
29
30
  trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
@@ -33,7 +34,7 @@ export interface QtiTrustedItemParseOptions {
33
34
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
34
35
  }
35
36
 
36
- interface QtiParsedTrustedItem {
37
+ export interface QtiTrustedItemDocument {
37
38
  document: QtiDocument;
38
39
  diagnostics: QtiDiagnostic[];
39
40
  responseIdentifiers: Set<string>;
@@ -43,6 +44,7 @@ export type QtiTrustedItemScoringPolicy = "always" | "onSubmission";
43
44
 
44
45
  export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
45
46
  itemXml: string;
47
+ parsedItem?: QtiTrustedItemDocument | undefined;
46
48
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
47
49
  submission: QtiTrustedResponseApplication;
48
50
  priorState?: QtiAttemptStateV1 | null | undefined;
@@ -50,6 +52,12 @@ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
50
52
  attemptStatus?: QtiAttemptStatus | undefined;
51
53
  scoring: QtiTrustedItemScoringPolicy;
52
54
  requireNumericScore: boolean;
55
+ /**
56
+ * `strict` rejects undeclared and malformed responses before trusted application.
57
+ * `trusted` keeps the legacy warning path for undeclared identifiers.
58
+ */
59
+ submissionValidation?: QtiTrustedSubmissionValidation | undefined;
60
+ allowIncompleteResponses?: boolean | undefined;
53
61
  }
54
62
 
55
63
  export interface RunTrustedItemSessionSuccess {
@@ -86,19 +94,58 @@ export function readPriorAttemptState(
86
94
  {
87
95
  code: `${diagnosticPrefix}.state.value`,
88
96
  severity: "error",
89
- message: "Prior adaptive turn state is not a valid qti3.attempt-state.v1 value.",
97
+ message: "Prior attempt state is not a valid qti3.attempt-state.v1 value.",
90
98
  },
91
99
  ],
92
100
  };
93
101
  }
94
102
 
103
+ export function parseTrustedItemDocument(
104
+ itemXml: string,
105
+ allowedUndeclaredResponseIdentifiers: readonly string[] = [],
106
+ ): { ok: true; parsed: QtiTrustedItemDocument } | { ok: false; diagnostics: QtiDiagnostic[] } {
107
+ let parsed: ReturnType<typeof parseQtiXml>;
108
+ try {
109
+ parsed = parseQtiXml(itemXml);
110
+ } catch (error) {
111
+ return {
112
+ ok: false,
113
+ diagnostics: [
114
+ {
115
+ code: "xml.parse",
116
+ severity: "error",
117
+ message: error instanceof Error ? error.message : String(error),
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
124
+ const diagnostics = parsed.diagnostics.filter(
125
+ (diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
126
+ );
127
+ if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
128
+ return { ok: false, diagnostics };
129
+ }
130
+
131
+ return {
132
+ ok: true,
133
+ parsed: {
134
+ document: parsed.document,
135
+ diagnostics,
136
+ responseIdentifiers: new Set(
137
+ parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
138
+ ),
139
+ },
140
+ };
141
+ }
142
+
95
143
  export function runTrustedItemSession(
96
144
  input: RunTrustedItemSessionInput,
97
145
  ): RunTrustedItemSessionResult {
98
- const parsedResult = parseTrustedItemXml(
99
- input.itemXml,
100
- input.allowedUndeclaredResponseIdentifiers,
101
- );
146
+ const parsedResult = input.parsedItem
147
+ ? { ok: true as const, parsed: input.parsedItem }
148
+ : parseTrustedItemDocument(input.itemXml, input.allowedUndeclaredResponseIdentifiers);
102
149
  if (!parsedResult.ok) {
103
150
  return emptyTrustedItemSessionFailure(parsedResult.diagnostics);
104
151
  }
@@ -107,6 +154,7 @@ export function runTrustedItemSession(
107
154
  parsedResult.parsed,
108
155
  input.priorState,
109
156
  input.diagnosticPrefix,
157
+ input.allowedUndeclaredResponseIdentifiers,
110
158
  parsedResult.parsed.diagnostics,
111
159
  );
112
160
  if (!sessionResult.ok) {
@@ -115,6 +163,18 @@ export function runTrustedItemSession(
115
163
 
116
164
  if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
117
165
 
166
+ if (input.submissionValidation === "strict") {
167
+ const validation = validateQtiResponseVariables({
168
+ item: parsedResult.parsed.document.item,
169
+ responses: input.submission.trustedResponses ?? {},
170
+ allowIncompleteResponses: input.allowIncompleteResponses,
171
+ allowedUndeclaredResponseIdentifiers: input.allowedUndeclaredResponseIdentifiers,
172
+ });
173
+ if (!validation.ok) {
174
+ return emptyTrustedItemSessionFailure(validation.diagnostics);
175
+ }
176
+ }
177
+
118
178
  const applicationResult = applyTrustedResponseApplication(
119
179
  sessionResult.session,
120
180
  parsedResult.parsed,
@@ -127,9 +187,7 @@ export function runTrustedItemSession(
127
187
  return emptyTrustedItemSessionFailure(diagnostics);
128
188
  }
129
189
 
130
- const shouldScore =
131
- input.scoring === "always" ||
132
- (input.scoring === "onSubmission" && applicationResult.appliedSubmission);
190
+ const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
133
191
 
134
192
  let outcomes = sessionResult.session.serialize().outcomes;
135
193
  let state = sessionResult.session.serialize();
@@ -165,7 +223,7 @@ export function runTrustedItemSession(
165
223
 
166
224
  if (
167
225
  input.requireNumericScore &&
168
- shouldRequireNumericScore(parsedResult.parsed.document.item) &&
226
+ itemExpectsAutomatedScore(parsedResult.parsed.document.item) &&
169
227
  score === null
170
228
  ) {
171
229
  return emptyTrustedItemSessionFailure(
@@ -210,56 +268,19 @@ function emptyTrustedItemSessionFailure(
210
268
  };
211
269
  }
212
270
 
213
- function parseTrustedItemXml(
214
- itemXml: string,
215
- allowedUndeclaredResponseIdentifiers: readonly string[] = [],
216
- ): { ok: true; parsed: QtiParsedTrustedItem } | { ok: false; diagnostics: QtiDiagnostic[] } {
217
- let parsed: ReturnType<typeof parseQtiXml>;
218
- try {
219
- parsed = parseQtiXml(itemXml);
220
- } catch (error) {
221
- return {
222
- ok: false,
223
- diagnostics: [
224
- {
225
- code: "xml.parse",
226
- severity: "error",
227
- message: error instanceof Error ? error.message : String(error),
228
- },
229
- ],
230
- };
231
- }
232
-
233
- const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
234
- const diagnostics = parsed.diagnostics.filter(
235
- (diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
236
- );
237
- if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
238
- return { ok: false, diagnostics };
239
- }
240
-
241
- return {
242
- ok: true,
243
- parsed: {
244
- document: parsed.document,
245
- diagnostics,
246
- responseIdentifiers: new Set(
247
- parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
248
- ),
249
- },
250
- };
251
- }
252
-
253
271
  function createTrustedItemSession(
254
- parsed: QtiParsedTrustedItem,
272
+ parsed: QtiTrustedItemDocument,
255
273
  priorState: QtiAttemptStateV1 | null | undefined,
256
274
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
275
+ allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
257
276
  existingDiagnostics: QtiDiagnostic[] = [],
258
277
  ): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
259
278
  try {
260
279
  return {
261
280
  ok: true,
262
- session: createItemSession(parsed.document, priorState ?? undefined),
281
+ session: createItemSession(parsed.document, priorState ?? undefined, {
282
+ allowedUndeclaredResponseIdentifiers,
283
+ }),
263
284
  };
264
285
  } catch (error) {
265
286
  return {
@@ -278,7 +299,7 @@ function createTrustedItemSession(
278
299
 
279
300
  function applyTrustedResponseApplication(
280
301
  session: QtiItemSession,
281
- parsed: QtiParsedTrustedItem,
302
+ parsed: QtiTrustedItemDocument,
282
303
  submission: QtiTrustedResponseApplication,
283
304
  allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
284
305
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
@@ -287,7 +308,7 @@ function applyTrustedResponseApplication(
287
308
  const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers ?? []);
288
309
  let appliedSubmission = false;
289
310
 
290
- for (const response of normalizeResponseInputs(submission.trustedResponses)) {
311
+ for (const response of listNamedResponseInputs(submission.trustedResponses)) {
291
312
  const identifier = response.identifier.trim();
292
313
  if (!identifier) {
293
314
  diagnostics.push({
@@ -369,13 +390,6 @@ function scoreTrustedItemSession(
369
390
  }
370
391
  }
371
392
 
372
- function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
373
- return (
374
- Boolean(item.responseProcessing) ||
375
- item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
376
- );
377
- }
378
-
379
393
  function readNumericScore(value: QtiValue | undefined): number | null {
380
394
  if (typeof value === "number" && Number.isFinite(value)) return value;
381
395
  if (typeof value === "string") {
@@ -409,12 +423,6 @@ function stripUndeclaredResponses(
409
423
  };
410
424
  }
411
425
 
412
- function normalizeResponseInputs(responses: QtiTrustedResponsesInput): QtiTrustedResponseInput[] {
413
- if (!responses) return [];
414
- if (Array.isArray(responses)) return [...responses];
415
- return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
416
- }
417
-
418
426
  function isAllowedUndeclaredVariableReference(
419
427
  diagnostic: QtiDiagnostic,
420
428
  allowedUndeclaredResponseIdentifiers: Set<string>,
@@ -0,0 +1,67 @@
1
+ import {
2
+ classifyQtiItemScoringDisposition,
3
+ type QtiItemSubmissionScoringDisposition,
4
+ } from "./scoring-disposition-policy.js";
5
+ import type { QtiAssessmentItem, QtiAttemptStateV1, QtiDiagnostic, QtiValue } from "./types.js";
6
+ import { snapshotQtiVariableDeclarations, type QtiVariableSnapshot } from "./variable-snapshot.js";
7
+
8
+ export interface QtiTrustedSessionMaterialization {
9
+ diagnostics: QtiDiagnostic[];
10
+ state: QtiAttemptStateV1;
11
+ responses: Record<string, QtiValue>;
12
+ outcomes: Record<string, QtiValue>;
13
+ score: number | null;
14
+ candidateSafeXml?: string | undefined;
15
+ }
16
+
17
+ export interface QtiItemSubmissionMaterializationSuccess {
18
+ ok: true;
19
+ diagnostics: QtiDiagnostic[];
20
+ state: QtiAttemptStateV1;
21
+ responseVariables: QtiVariableSnapshot[];
22
+ outcomeVariables: QtiVariableSnapshot[];
23
+ score: number | null;
24
+ scoringDisposition: Exclude<QtiItemSubmissionScoringDisposition, "invalid">;
25
+ candidateSafeXml?: string | undefined;
26
+ }
27
+
28
+ export interface QtiItemSubmissionMaterializationFailure {
29
+ ok: false;
30
+ diagnostics: QtiDiagnostic[];
31
+ state?: QtiAttemptStateV1 | undefined;
32
+ responseVariables: QtiVariableSnapshot[];
33
+ outcomeVariables: QtiVariableSnapshot[];
34
+ score: null;
35
+ scoringDisposition: "invalid";
36
+ }
37
+
38
+ export function buildItemSubmissionMaterializationSuccess(
39
+ item: QtiAssessmentItem,
40
+ result: QtiTrustedSessionMaterialization,
41
+ ): QtiItemSubmissionMaterializationSuccess {
42
+ return {
43
+ ok: true,
44
+ diagnostics: result.diagnostics,
45
+ state: result.state,
46
+ responseVariables: snapshotQtiVariableDeclarations(item.responseDeclarations, result.responses),
47
+ outcomeVariables: snapshotQtiVariableDeclarations(item.outcomeDeclarations, result.outcomes),
48
+ score: result.score,
49
+ scoringDisposition: classifyQtiItemScoringDisposition(item, result.score),
50
+ candidateSafeXml: result.candidateSafeXml,
51
+ };
52
+ }
53
+
54
+ export function buildItemSubmissionMaterializationFailure(
55
+ diagnostics: QtiDiagnostic[],
56
+ state?: QtiAttemptStateV1,
57
+ ): QtiItemSubmissionMaterializationFailure {
58
+ return {
59
+ ok: false,
60
+ diagnostics,
61
+ state,
62
+ responseVariables: [],
63
+ outcomeVariables: [],
64
+ score: null,
65
+ scoringDisposition: "invalid",
66
+ };
67
+ }
package/src/tts.ts CHANGED
@@ -103,10 +103,17 @@ interface SegmentInput {
103
103
  }
104
104
 
105
105
  const dataSsmlFunctionNames = new Set(["break", "phoneme", "prosody", "say-as", "sub"]);
106
- const breakStrengths = new Set(["medium", "none", "strong", "weak", "x-strong", "x-weak"]);
107
- const phonemeAlphabets = new Set(["ipa", "x-sampa"]);
108
- const phonemeTypes = new Set(["default", "ruby"]);
109
- const sayAsInterpretations = new Set([
106
+ const breakStrengths = new Set<QtiDataSsmlBreakStrength>([
107
+ "medium",
108
+ "none",
109
+ "strong",
110
+ "weak",
111
+ "x-strong",
112
+ "x-weak",
113
+ ]);
114
+ const phonemeAlphabets = new Set<NonNullable<QtiDataSsmlPhoneme["alphabet"]>>(["ipa", "x-sampa"]);
115
+ const phonemeTypes = new Set<NonNullable<QtiDataSsmlPhoneme["type"]>>(["default", "ruby"]);
116
+ const sayAsInterpretations = new Set<QtiDataSsmlSayAs["interpret-as"]>([
110
117
  "cardinal",
111
118
  "characters",
112
119
  "date",
@@ -200,7 +207,7 @@ function validateBreak(value: Record<string, unknown>, errors: string[]): QtiDat
200
207
  const result: QtiDataSsmlBreak = {};
201
208
  const strength = optionalEnum(value, "strength", "break.strength", breakStrengths, errors);
202
209
  const time = optionalString(value, "time", "break.time", errors);
203
- if (strength) result.strength = strength as QtiDataSsmlBreakStrength;
210
+ if (strength) result.strength = strength;
204
211
  if (time !== undefined) result.time = time;
205
212
  return result;
206
213
  }
@@ -215,8 +222,8 @@ function validatePhoneme(
215
222
  const type = optionalEnum(value, "type", "phoneme.type", phonemeTypes, errors);
216
223
  if (!ph) return undefined;
217
224
  const result: QtiDataSsmlPhoneme = { ph };
218
- if (alphabet) result.alphabet = alphabet as QtiDataSsmlPhoneme["alphabet"];
219
- if (type) result.type = type as QtiDataSsmlPhoneme["type"];
225
+ if (alphabet) result.alphabet = alphabet;
226
+ if (type) result.type = type;
220
227
  return result;
221
228
  }
222
229
 
@@ -241,7 +248,7 @@ function validateSayAs(
241
248
  errors,
242
249
  );
243
250
  if (!interpretation) return undefined;
244
- return { "interpret-as": interpretation as QtiDataSsmlSayAs["interpret-as"] };
251
+ return { "interpret-as": interpretation };
245
252
  }
246
253
 
247
254
  function validateSub(value: Record<string, unknown>, errors: string[]): QtiDataSsmlSub | undefined {
@@ -520,29 +527,36 @@ function requiredString(
520
527
  return optionalString(value, name, path, errors);
521
528
  }
522
529
 
523
- function optionalEnum(
530
+ function isAllowedEnum<T extends string>(value: string, allowed: ReadonlySet<T>): value is T {
531
+ for (const entry of allowed) {
532
+ if (entry === value) return true;
533
+ }
534
+ return false;
535
+ }
536
+
537
+ function optionalEnum<T extends string>(
524
538
  value: Record<string, unknown>,
525
539
  name: string,
526
540
  path: string,
527
- allowed: Set<string>,
541
+ allowed: ReadonlySet<T>,
528
542
  errors: string[],
529
- ): string | undefined {
543
+ ): T | undefined {
530
544
  const property = optionalString(value, name, path, errors);
531
545
  if (property === undefined) return undefined;
532
- if (!allowed.has(property)) {
546
+ if (!isAllowedEnum(property, allowed)) {
533
547
  errors.push(`${path} has unsupported value "${property}".`);
534
548
  return undefined;
535
549
  }
536
550
  return property;
537
551
  }
538
552
 
539
- function requiredEnum(
553
+ function requiredEnum<T extends string>(
540
554
  value: Record<string, unknown>,
541
555
  name: string,
542
556
  path: string,
543
- allowed: Set<string>,
557
+ allowed: ReadonlySet<T>,
544
558
  errors: string[],
545
- ): string | undefined {
559
+ ): T | undefined {
546
560
  if (!Object.hasOwn(value, name)) {
547
561
  errors.push(`${path} is required.`);
548
562
  return undefined;
@@ -0,0 +1,204 @@
1
+ import type { QtiChoice, QtiDiagnostic, QtiInteraction } from "./types.js";
2
+ import { assertNever } from "./assert-never.js";
3
+ import { numericTuple3, numericTuple4 } from "./parser-values.js";
4
+ import { isFiniteNumber } from "./validation-primitives.js";
5
+
6
+ export function validateHotspotGeometry(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
7
+ if (choice.qtiName !== "qti-hotspot-choice" && choice.qtiName !== "qti-associable-hotspot") {
8
+ return;
9
+ }
10
+
11
+ const shape = choice.attributes.shape;
12
+ const coords = choice.attributes.coords;
13
+ if (!shape) {
14
+ diagnostics.push({
15
+ code: "choice.shape.required",
16
+ severity: "error",
17
+ message: `${choice.qtiName} ${choice.identifier} requires shape.`,
18
+ path: choice.source?.path,
19
+ source: choice.source,
20
+ });
21
+ } else if (!isHotspotShape(shape)) {
22
+ diagnostics.push({
23
+ code: "choice.shape",
24
+ severity: "error",
25
+ message: `${choice.qtiName} ${choice.identifier} has unsupported shape ${shape}.`,
26
+ path: choice.source?.path,
27
+ source: choice.source,
28
+ });
29
+ }
30
+
31
+ if (!coords) {
32
+ diagnostics.push({
33
+ code: "choice.coords.required",
34
+ severity: "error",
35
+ message: `${choice.qtiName} ${choice.identifier} requires coords.`,
36
+ path: choice.source?.path,
37
+ source: choice.source,
38
+ });
39
+ return;
40
+ }
41
+
42
+ const values = coords.split(",").map((value) => value.trim());
43
+ if (values.length === 0 || values.some((value) => value.length === 0 || !isFiniteNumber(value))) {
44
+ diagnostics.push({
45
+ code: "choice.coords",
46
+ severity: "error",
47
+ message: `${choice.qtiName} ${choice.identifier} requires comma-separated numeric coords.`,
48
+ path: choice.source?.path,
49
+ source: choice.source,
50
+ });
51
+ return;
52
+ }
53
+
54
+ if (shape && isHotspotShape(shape) && !hasValidShapeCoordinateCount(shape, values.map(Number))) {
55
+ diagnostics.push({
56
+ code: "choice.coords.shape",
57
+ severity: "error",
58
+ message: `${choice.qtiName} ${choice.identifier} shape ${shape} has invalid coords arity.`,
59
+ path: choice.source?.path,
60
+ source: choice.source,
61
+ });
62
+ }
63
+ }
64
+
65
+ export function validateGraphicHotspotObjectDimensions(
66
+ interaction: QtiInteraction,
67
+ diagnostics: QtiDiagnostic[],
68
+ ): void {
69
+ if (!usesGraphicHotspots(interaction)) return;
70
+ const hotspotChoices = interaction.choices.filter(isHotspotChoice);
71
+ if (hotspotChoices.length === 0) return;
72
+
73
+ const width = positiveDimension(interaction.object?.width);
74
+ const height = positiveDimension(interaction.object?.height);
75
+ if (width === undefined || height === undefined) {
76
+ diagnostics.push({
77
+ code: "interaction.graphicObjectDimensions",
78
+ severity: "warning",
79
+ message: `${interaction.qtiName} should declare object width and height so hotspot coords map to the rendered image.`,
80
+ path: interaction.object?.source?.path ?? interaction.source?.path,
81
+ source: interaction.object?.source ?? interaction.source,
82
+ });
83
+ return;
84
+ }
85
+
86
+ for (const choice of hotspotChoices) {
87
+ const bounds = hotspotBounds(choice);
88
+ if (!bounds) continue;
89
+ if (bounds.left >= 0 && bounds.top >= 0 && bounds.right <= width && bounds.bottom <= height) {
90
+ continue;
91
+ }
92
+ diagnostics.push({
93
+ code: "choice.coords.bounds",
94
+ severity: "warning",
95
+ message: `${choice.qtiName} ${choice.identifier} coords extend outside the ${width} by ${height} object image.`,
96
+ path: choice.source?.path,
97
+ source: choice.source,
98
+ });
99
+ }
100
+ }
101
+
102
+ function usesGraphicHotspots(interaction: QtiInteraction): boolean {
103
+ return (
104
+ interaction.type === "graphicOrder" ||
105
+ interaction.type === "graphicAssociate" ||
106
+ interaction.type === "graphicGapMatch" ||
107
+ interaction.type === "hotspot"
108
+ );
109
+ }
110
+
111
+ function isHotspotChoice(choice: QtiChoice): boolean {
112
+ return choice.qtiName === "qti-hotspot-choice" || choice.qtiName === "qti-associable-hotspot";
113
+ }
114
+
115
+ function positiveDimension(value: string | undefined): number | undefined {
116
+ if (!value || value.trim().endsWith("%")) return undefined;
117
+ const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
118
+ if (!match?.[1]) return undefined;
119
+ const parsed = Number(match[1]);
120
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
121
+ }
122
+
123
+ function hotspotBounds(
124
+ choice: QtiChoice,
125
+ ): { left: number; top: number; right: number; bottom: number } | undefined {
126
+ const shape = choice.attributes.shape;
127
+ const coords = choice.attributes.coords;
128
+ if (!shape || !coords || !isHotspotShape(shape) || !isNumericCsv(coords)) return undefined;
129
+ const values = numericCsv(coords);
130
+ if (!hasValidShapeCoordinateCount(shape, values)) return undefined;
131
+
132
+ if (shape === "default") return undefined;
133
+ if (shape === "circle") {
134
+ const tuple = numericTuple3(values);
135
+ if (!tuple) return undefined;
136
+ const [x, y, radius] = tuple;
137
+ return { left: x - radius, top: y - radius, right: x + radius, bottom: y + radius };
138
+ }
139
+ if (shape === "ellipse") {
140
+ const tuple = numericTuple4(values);
141
+ if (!tuple) return undefined;
142
+ const [x, y, radiusX, radiusY] = tuple;
143
+ return { left: x - radiusX, top: y - radiusY, right: x + radiusX, bottom: y + radiusY };
144
+ }
145
+ if (shape === "rect") {
146
+ const tuple = numericTuple4(values);
147
+ if (!tuple) return undefined;
148
+ const [left, top, right, bottom] = tuple;
149
+ return { left, top, right, bottom };
150
+ }
151
+
152
+ const xs = values.filter((_, index) => index % 2 === 0);
153
+ const ys = values.filter((_, index) => index % 2 === 1);
154
+ return {
155
+ left: Math.min(...xs),
156
+ top: Math.min(...ys),
157
+ right: Math.max(...xs),
158
+ bottom: Math.max(...ys),
159
+ };
160
+ }
161
+
162
+ type QtiHotspotShape = "circle" | "default" | "ellipse" | "poly" | "rect";
163
+
164
+ function isHotspotShape(value: string): value is QtiHotspotShape {
165
+ return (
166
+ value === "circle" ||
167
+ value === "default" ||
168
+ value === "ellipse" ||
169
+ value === "poly" ||
170
+ value === "rect"
171
+ );
172
+ }
173
+
174
+ export function isAreaShape(value: string): boolean {
175
+ return value === "circle" || value === "default" || value === "poly" || value === "rect";
176
+ }
177
+
178
+ export function isNumericCsv(value: string): boolean {
179
+ return value
180
+ .split(",")
181
+ .map((part) => part.trim())
182
+ .every((part) => part.length > 0 && isFiniteNumber(part));
183
+ }
184
+
185
+ export function numericCsv(value: string): number[] {
186
+ return value.split(",").map((part) => Number(part.trim()));
187
+ }
188
+
189
+ export function hasValidShapeCoordinateCount(shape: string, coords: number[]): boolean {
190
+ if (!isHotspotShape(shape)) return false;
191
+ switch (shape) {
192
+ case "circle":
193
+ return coords.length === 3;
194
+ case "ellipse":
195
+ case "rect":
196
+ return coords.length === 4;
197
+ case "poly":
198
+ return coords.length >= 6 && coords.length % 2 === 0;
199
+ case "default":
200
+ return true;
201
+ default:
202
+ return assertNever(shape);
203
+ }
204
+ }