@longsightgroup/qti3-core 0.9.1 → 0.9.3

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 (244) hide show
  1. package/README.md +59 -3
  2. package/dist/adaptive-turn-materializer.d.ts +14 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -0
  4. package/dist/adaptive-turn-materializer.js +56 -0
  5. package/dist/adaptive-turn-materializer.js.map +1 -0
  6. package/dist/adaptive-turn.d.ts +23 -0
  7. package/dist/adaptive-turn.d.ts.map +1 -0
  8. package/dist/adaptive-turn.js +60 -0
  9. package/dist/adaptive-turn.js.map +1 -0
  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-redaction.d.ts +50 -0
  32. package/dist/delivery-redaction.d.ts.map +1 -0
  33. package/dist/delivery-redaction.js +176 -0
  34. package/dist/delivery-redaction.js.map +1 -0
  35. package/dist/delivery-security.d.ts +3 -18
  36. package/dist/delivery-security.d.ts.map +1 -1
  37. package/dist/delivery-security.js +35 -189
  38. package/dist/delivery-security.js.map +1 -1
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/parser-custom-interactions.js +1 -1
  44. package/dist/parser-custom-interactions.js.map +1 -1
  45. package/dist/parser-declarations.d.ts.map +1 -1
  46. package/dist/parser-declarations.js +4 -4
  47. package/dist/parser-declarations.js.map +1 -1
  48. package/dist/parser-item-metadata.d.ts.map +1 -1
  49. package/dist/parser-item-metadata.js +8 -7
  50. package/dist/parser-item-metadata.js.map +1 -1
  51. package/dist/parser-processing.js +2 -2
  52. package/dist/parser-processing.js.map +1 -1
  53. package/dist/parser-values.d.ts +5 -1
  54. package/dist/parser-values.d.ts.map +1 -1
  55. package/dist/parser-values.js +33 -0
  56. package/dist/parser-values.js.map +1 -1
  57. package/dist/processing-evaluator-boolean.d.ts +8 -0
  58. package/dist/processing-evaluator-boolean.d.ts.map +1 -0
  59. package/dist/processing-evaluator-boolean.js +47 -0
  60. package/dist/processing-evaluator-boolean.js.map +1 -0
  61. package/dist/processing-evaluator-collection.d.ts +12 -0
  62. package/dist/processing-evaluator-collection.d.ts.map +1 -0
  63. package/dist/processing-evaluator-collection.js +35 -0
  64. package/dist/processing-evaluator-collection.js.map +1 -0
  65. package/dist/processing-evaluator-comparison.d.ts +8 -0
  66. package/dist/processing-evaluator-comparison.d.ts.map +1 -0
  67. package/dist/processing-evaluator-comparison.js +88 -0
  68. package/dist/processing-evaluator-comparison.js.map +1 -0
  69. package/dist/processing-evaluator-custom.d.ts +8 -0
  70. package/dist/processing-evaluator-custom.d.ts.map +1 -0
  71. package/dist/processing-evaluator-custom.js +14 -0
  72. package/dist/processing-evaluator-custom.js.map +1 -0
  73. package/dist/processing-evaluator-geometry.d.ts +8 -0
  74. package/dist/processing-evaluator-geometry.d.ts.map +1 -0
  75. package/dist/processing-evaluator-geometry.js +21 -0
  76. package/dist/processing-evaluator-geometry.js.map +1 -0
  77. package/dist/processing-evaluator-numeric.d.ts +8 -0
  78. package/dist/processing-evaluator-numeric.d.ts.map +1 -0
  79. package/dist/processing-evaluator-numeric.js +112 -0
  80. package/dist/processing-evaluator-numeric.js.map +1 -0
  81. package/dist/processing-evaluator-string.d.ts +8 -0
  82. package/dist/processing-evaluator-string.d.ts.map +1 -0
  83. package/dist/processing-evaluator-string.js +33 -0
  84. package/dist/processing-evaluator-string.js.map +1 -0
  85. package/dist/processing-evaluator-variable.d.ts +8 -0
  86. package/dist/processing-evaluator-variable.d.ts.map +1 -0
  87. package/dist/processing-evaluator-variable.js +39 -0
  88. package/dist/processing-evaluator-variable.js.map +1 -0
  89. package/dist/processing-evaluator.d.ts +22 -0
  90. package/dist/processing-evaluator.d.ts.map +1 -0
  91. package/dist/processing-evaluator.js +137 -0
  92. package/dist/processing-evaluator.js.map +1 -0
  93. package/dist/processing-expression-children.d.ts +3 -0
  94. package/dist/processing-expression-children.d.ts.map +1 -0
  95. package/dist/processing-expression-children.js +68 -0
  96. package/dist/processing-expression-children.js.map +1 -0
  97. package/dist/processing-mapping.d.ts +13 -0
  98. package/dist/processing-mapping.d.ts.map +1 -0
  99. package/dist/processing-mapping.js +120 -0
  100. package/dist/processing-mapping.js.map +1 -0
  101. package/dist/processing-operators.d.ts +11 -0
  102. package/dist/processing-operators.d.ts.map +1 -0
  103. package/dist/processing-operators.js +178 -0
  104. package/dist/processing-operators.js.map +1 -0
  105. package/dist/processing-random.d.ts +2 -0
  106. package/dist/processing-random.d.ts.map +1 -0
  107. package/dist/processing-random.js +16 -0
  108. package/dist/processing-random.js.map +1 -0
  109. package/dist/processing-state.d.ts +8 -0
  110. package/dist/processing-state.d.ts.map +1 -0
  111. package/dist/processing-state.js +44 -0
  112. package/dist/processing-state.js.map +1 -0
  113. package/dist/processing-templates.d.ts +3 -0
  114. package/dist/processing-templates.d.ts.map +1 -0
  115. package/dist/processing-templates.js +12 -0
  116. package/dist/processing-templates.js.map +1 -0
  117. package/dist/processing-values.d.ts +15 -0
  118. package/dist/processing-values.d.ts.map +1 -0
  119. package/dist/processing-values.js +150 -0
  120. package/dist/processing-values.js.map +1 -0
  121. package/dist/processing-variables.d.ts +7 -0
  122. package/dist/processing-variables.d.ts.map +1 -0
  123. package/dist/processing-variables.js +40 -0
  124. package/dist/processing-variables.js.map +1 -0
  125. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  126. package/dist/serializer-processing-expressions.js +2 -8
  127. package/dist/serializer-processing-expressions.js.map +1 -1
  128. package/dist/serializer-processing-xml.js +2 -2
  129. package/dist/serializer-processing-xml.js.map +1 -1
  130. package/dist/serializer-processing.d.ts.map +1 -1
  131. package/dist/serializer-processing.js +3 -0
  132. package/dist/serializer-processing.js.map +1 -1
  133. package/dist/server-scoring.d.ts +4 -14
  134. package/dist/server-scoring.d.ts.map +1 -1
  135. package/dist/server-scoring.js +19 -157
  136. package/dist/server-scoring.js.map +1 -1
  137. package/dist/session.d.ts +5 -14
  138. package/dist/session.d.ts.map +1 -1
  139. package/dist/session.js +76 -1158
  140. package/dist/session.js.map +1 -1
  141. package/dist/shared-vocabulary-coverage-policy.js +1 -1
  142. package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
  143. package/dist/shared-vocabulary-generated-families.js +1 -1
  144. package/dist/shared-vocabulary-generated-families.js.map +1 -1
  145. package/dist/shared-vocabulary-support.js +3 -3
  146. package/dist/shared-vocabulary-support.js.map +1 -1
  147. package/dist/shared-vocabulary.d.ts.map +1 -1
  148. package/dist/shared-vocabulary.js +4 -3
  149. package/dist/shared-vocabulary.js.map +1 -1
  150. package/dist/support-evidence.d.ts +22 -0
  151. package/dist/support-evidence.d.ts.map +1 -0
  152. package/dist/support-evidence.js +93 -0
  153. package/dist/support-evidence.js.map +1 -0
  154. package/dist/support.d.ts.map +1 -1
  155. package/dist/support.js +38 -47
  156. package/dist/support.js.map +1 -1
  157. package/dist/trusted-item-session.d.ts +49 -0
  158. package/dist/trusted-item-session.d.ts.map +1 -0
  159. package/dist/trusted-item-session.js +261 -0
  160. package/dist/trusted-item-session.js.map +1 -0
  161. package/dist/tts.d.ts.map +1 -1
  162. package/dist/tts.js +16 -2
  163. package/dist/tts.js.map +1 -1
  164. package/dist/validation-geometry.d.ts +8 -0
  165. package/dist/validation-geometry.d.ts.map +1 -0
  166. package/dist/validation-geometry.js +187 -0
  167. package/dist/validation-geometry.js.map +1 -0
  168. package/dist/validation-interactions.d.ts +3 -0
  169. package/dist/validation-interactions.d.ts.map +1 -0
  170. package/dist/validation-interactions.js +644 -0
  171. package/dist/validation-interactions.js.map +1 -0
  172. package/dist/validation-primitives.d.ts +11 -0
  173. package/dist/validation-primitives.d.ts.map +1 -0
  174. package/dist/validation-primitives.js +48 -0
  175. package/dist/validation-primitives.js.map +1 -0
  176. package/dist/validation-processing.d.ts +4 -0
  177. package/dist/validation-processing.d.ts.map +1 -0
  178. package/dist/validation-processing.js +569 -0
  179. package/dist/validation-processing.js.map +1 -0
  180. package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
  181. package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
  182. package/dist/validation-shared-vocabulary-layout.js +102 -0
  183. package/dist/validation-shared-vocabulary-layout.js.map +1 -0
  184. package/dist/validation.d.ts.map +1 -1
  185. package/dist/validation.js +27 -1641
  186. package/dist/validation.js.map +1 -1
  187. package/dist/xml.js +1 -1
  188. package/dist/xml.js.map +1 -1
  189. package/package.json +4 -2
  190. package/src/adaptive-turn-materializer.ts +85 -0
  191. package/src/adaptive-turn.ts +109 -0
  192. package/src/assert-never.ts +4 -0
  193. package/src/attempt-state-constants.ts +5 -0
  194. package/src/attempt-state.ts +245 -0
  195. package/src/catalog.ts +1 -1
  196. package/src/content-text.ts +3 -0
  197. package/src/custom-operators.ts +12 -0
  198. package/src/delivery-redaction.ts +268 -0
  199. package/src/delivery-security.ts +59 -245
  200. package/src/index.ts +9 -0
  201. package/src/parser-custom-interactions.ts +1 -1
  202. package/src/parser-declarations.ts +4 -3
  203. package/src/parser-item-metadata.ts +8 -7
  204. package/src/parser-processing.ts +2 -2
  205. package/src/parser-values.ts +36 -0
  206. package/src/processing-evaluator-boolean.ts +48 -0
  207. package/src/processing-evaluator-collection.ts +50 -0
  208. package/src/processing-evaluator-comparison.ts +106 -0
  209. package/src/processing-evaluator-custom.ts +20 -0
  210. package/src/processing-evaluator-geometry.ts +26 -0
  211. package/src/processing-evaluator-numeric.ts +155 -0
  212. package/src/processing-evaluator-string.ts +60 -0
  213. package/src/processing-evaluator-variable.ts +81 -0
  214. package/src/processing-evaluator.ts +173 -0
  215. package/src/processing-expression-children.ts +69 -0
  216. package/src/processing-mapping.ts +153 -0
  217. package/src/processing-operators.ts +184 -0
  218. package/src/processing-random.ts +16 -0
  219. package/src/processing-state.ts +69 -0
  220. package/src/processing-templates.ts +12 -0
  221. package/src/processing-values.ts +162 -0
  222. package/src/processing-variables.ts +83 -0
  223. package/src/serializer-processing-expressions.ts +2 -12
  224. package/src/serializer-processing-xml.ts +2 -2
  225. package/src/serializer-processing.ts +3 -0
  226. package/src/server-scoring.ts +30 -226
  227. package/src/session.ts +128 -2006
  228. package/src/shared-vocabulary-coverage-policy.ts +1 -1
  229. package/src/shared-vocabulary-generated-families.ts +1 -1
  230. package/src/shared-vocabulary-support.ts +3 -3
  231. package/src/shared-vocabulary.ts +3 -2
  232. package/src/support-evidence.ts +107 -0
  233. package/src/support.ts +68 -54
  234. package/src/trusted-item-session.ts +429 -0
  235. package/src/tts.ts +29 -15
  236. package/src/validation-geometry.ts +204 -0
  237. package/src/validation-interactions.ts +831 -0
  238. package/src/validation-primitives.ts +63 -0
  239. package/src/validation-processing.ts +785 -0
  240. package/src/validation-shared-vocabulary-layout.ts +117 -0
  241. package/src/validation.ts +44 -2077
  242. package/src/xml.ts +1 -1
  243. package/src/interaction-test-fixtures.ts +0 -44
  244. package/src/serializer-processing.fixtures.ts +0 -469
@@ -0,0 +1,785 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiDiagnostic,
4
+ QtiProcessingExpression,
5
+ QtiResponseCondition,
6
+ QtiResponseRule,
7
+ QtiSetOutcomeValue,
8
+ QtiTemplateRule,
9
+ } from "./types.js";
10
+ import { expressionChildren } from "./processing-expression-children.js";
11
+ import { MATH_OPERATOR_NAMES, STATS_OPERATOR_NAMES } from "./processing-operators.js";
12
+ import {
13
+ responseProcessingTemplateKind,
14
+ type ResponseProcessingTemplateKind,
15
+ } from "./processing-templates.js";
16
+ import { isBaseType, isFiniteNumber, isInteger } from "./validation-primitives.js";
17
+ import { COMPLETION_STATUS } from "./attempt-state-constants.js";
18
+
19
+ export function validateResponseProcessingTemplate(
20
+ item: QtiAssessmentItem,
21
+ diagnostics: QtiDiagnostic[],
22
+ ): void {
23
+ const template = item.responseProcessing?.template;
24
+ if (!template) return;
25
+ const templateKind = responseProcessingTemplateKind(template);
26
+ if (templateKind) {
27
+ validateBuiltInResponseProcessingTemplate(item, templateKind, diagnostics);
28
+ return;
29
+ }
30
+ diagnostics.push({
31
+ code: "processing.template.unsupported",
32
+ severity: "error",
33
+ message: `qti-response-processing template ${template} is not currently supported.`,
34
+ path: item.source?.path,
35
+ source: item.source,
36
+ });
37
+ }
38
+
39
+ function validateBuiltInResponseProcessingTemplate(
40
+ item: QtiAssessmentItem,
41
+ templateKind: ResponseProcessingTemplateKind,
42
+ diagnostics: QtiDiagnostic[],
43
+ ): void {
44
+ const response = item.responseDeclarations.find(
45
+ (declaration) => declaration.identifier === "RESPONSE",
46
+ );
47
+ const score = item.outcomeDeclarations.find((declaration) => declaration.identifier === "SCORE");
48
+ if (!response) {
49
+ diagnostics.push({
50
+ code: "processing.template.responseIdentifier",
51
+ severity: "error",
52
+ message:
53
+ "Built-in response-processing templates require a response declaration named RESPONSE.",
54
+ path: item.source?.path,
55
+ source: item.source,
56
+ });
57
+ }
58
+ if (!score) {
59
+ diagnostics.push({
60
+ code: "processing.template.scoreIdentifier",
61
+ severity: "error",
62
+ message: "Built-in response-processing templates require an outcome declaration named SCORE.",
63
+ path: item.source?.path,
64
+ source: item.source,
65
+ });
66
+ } else if (score.cardinality !== "single" || score.baseType !== "float") {
67
+ diagnostics.push({
68
+ code: "processing.template.scoreDeclaration",
69
+ severity: "error",
70
+ message:
71
+ "Built-in response-processing templates require SCORE to be single cardinality with base-type float.",
72
+ path: score.source?.path,
73
+ source: score.source,
74
+ });
75
+ }
76
+ const responseInteractions = item.interactions.filter(
77
+ (interaction) => interaction.responseIdentifier === "RESPONSE",
78
+ );
79
+ if (responseInteractions.length !== 1 || item.interactions.length !== 1) {
80
+ diagnostics.push({
81
+ code: "processing.template.singleInteraction",
82
+ severity: "error",
83
+ message:
84
+ "Built-in response-processing templates require a single interaction bound to RESPONSE.",
85
+ path: item.source?.path,
86
+ source: item.source,
87
+ });
88
+ }
89
+ if (templateKind === "mapResponse" && response && !response.mapping) {
90
+ diagnostics.push({
91
+ code: "processing.template.mapping",
92
+ severity: "error",
93
+ message: "The map_response template requires RESPONSE to define qti-mapping.",
94
+ path: response.source?.path,
95
+ source: response.source,
96
+ });
97
+ }
98
+ if (templateKind === "mapResponsePoint" && response && !response.areaMapping) {
99
+ diagnostics.push({
100
+ code: "processing.template.areaMapping",
101
+ severity: "error",
102
+ message: "The map_response_point template requires RESPONSE to define qti-area-mapping.",
103
+ path: response.source?.path,
104
+ source: response.source,
105
+ });
106
+ }
107
+ }
108
+
109
+ export function validateProcessingReferences(
110
+ item: QtiAssessmentItem,
111
+ diagnostics: QtiDiagnostic[],
112
+ ): void {
113
+ const responses = new Set(item.responseDeclarations.map((declaration) => declaration.identifier));
114
+ const outcomes = new Set(item.outcomeDeclarations.map((declaration) => declaration.identifier));
115
+ outcomes.add(COMPLETION_STATUS);
116
+ const templates = new Set(item.templateDeclarations.map((declaration) => declaration.identifier));
117
+ const variables = new Set([...responses, ...outcomes, ...templates]);
118
+
119
+ for (const rule of item.templateProcessing?.rules ?? []) {
120
+ validateTemplateRule(rule, responses, outcomes, templates, variables, diagnostics);
121
+ }
122
+
123
+ for (const rule of item.responseProcessing?.rules ?? []) {
124
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
125
+ }
126
+ }
127
+
128
+ function validateResponseCondition(
129
+ condition: QtiResponseCondition,
130
+ outcomes: Set<string>,
131
+ responses: Set<string>,
132
+ variables: Set<string>,
133
+ diagnostics: QtiDiagnostic[],
134
+ ): void {
135
+ validateExpressionReferences(condition.ifExpression, responses, variables, diagnostics);
136
+ for (const rule of condition.thenRules) {
137
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
138
+ }
139
+ for (const branch of condition.elseIfs) {
140
+ validateExpressionReferences(branch.expression, responses, variables, diagnostics);
141
+ for (const rule of branch.rules) {
142
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
143
+ }
144
+ }
145
+ for (const rule of condition.elseRules) {
146
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
147
+ }
148
+ }
149
+
150
+ function validateTemplateRule(
151
+ rule: QtiTemplateRule,
152
+ responses: Set<string>,
153
+ outcomes: Set<string>,
154
+ templates: Set<string>,
155
+ variables: Set<string>,
156
+ diagnostics: QtiDiagnostic[],
157
+ ): void {
158
+ if (rule.type === "exitTemplate") return;
159
+ if (rule.type === "templateConstraint") {
160
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
161
+ return;
162
+ }
163
+
164
+ if (rule.type === "templateCondition") {
165
+ validateExpressionReferences(rule.ifExpression, responses, variables, diagnostics);
166
+ for (const branchRule of rule.thenRules) {
167
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
168
+ }
169
+ for (const branch of rule.elseIfs) {
170
+ validateExpressionReferences(branch.expression, responses, variables, diagnostics);
171
+ for (const branchRule of branch.rules) {
172
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
173
+ }
174
+ }
175
+ for (const branchRule of rule.elseRules) {
176
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (rule.type === "setTemplateValue") {
182
+ validateProcessingIdentifier(
183
+ rule.identifier,
184
+ "processing.templateTarget",
185
+ rule.source,
186
+ diagnostics,
187
+ );
188
+ if (rule.identifier && !templates.has(rule.identifier)) {
189
+ diagnostics.push({
190
+ code: "processing.templateTarget.reference",
191
+ severity: "error",
192
+ message: `qti-set-template-value references missing template declaration ${rule.identifier}.`,
193
+ path: rule.source?.path,
194
+ source: rule.source,
195
+ });
196
+ }
197
+ }
198
+
199
+ if (rule.type === "setDefaultValue") {
200
+ validateProcessingIdentifier(
201
+ rule.identifier,
202
+ "processing.defaultTarget",
203
+ rule.source,
204
+ diagnostics,
205
+ );
206
+ if (rule.identifier && !responses.has(rule.identifier) && !outcomes.has(rule.identifier)) {
207
+ diagnostics.push({
208
+ code: "processing.defaultTarget.reference",
209
+ severity: "error",
210
+ message: `qti-set-default-value references missing response or outcome declaration ${rule.identifier}.`,
211
+ path: rule.source?.path,
212
+ source: rule.source,
213
+ });
214
+ }
215
+ }
216
+
217
+ if (rule.type === "setCorrectResponse") {
218
+ validateProcessingIdentifier(
219
+ rule.identifier,
220
+ "processing.correctResponse",
221
+ rule.source,
222
+ diagnostics,
223
+ );
224
+ if (rule.identifier && !responses.has(rule.identifier)) {
225
+ diagnostics.push({
226
+ code: "processing.correctResponse.reference",
227
+ severity: "error",
228
+ message: `qti-set-correct-response references missing response declaration ${rule.identifier}.`,
229
+ path: rule.source?.path,
230
+ source: rule.source,
231
+ });
232
+ }
233
+ }
234
+
235
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
236
+ }
237
+
238
+ function validateResponseRule(
239
+ rule: QtiResponseRule,
240
+ outcomes: Set<string>,
241
+ responses: Set<string>,
242
+ variables: Set<string>,
243
+ diagnostics: QtiDiagnostic[],
244
+ ): void {
245
+ if (rule.type === "exitResponse") return;
246
+ if (rule.type === "responseCondition") {
247
+ validateResponseCondition(rule.condition, outcomes, responses, variables, diagnostics);
248
+ return;
249
+ }
250
+ if (rule.type === "responseProcessingFragment") {
251
+ for (const childRule of rule.rules) {
252
+ validateResponseRule(childRule, outcomes, responses, variables, diagnostics);
253
+ }
254
+ return;
255
+ }
256
+ if (rule.type === "lookupOutcomeValue") {
257
+ validateLookupOutcomeRule(rule, outcomes, responses, variables, diagnostics);
258
+ return;
259
+ }
260
+ validateSetOutcomeRule(rule, outcomes, responses, variables, diagnostics);
261
+ }
262
+
263
+ function validateLookupOutcomeRule(
264
+ rule: Extract<QtiResponseRule, { type: "lookupOutcomeValue" }>,
265
+ outcomes: Set<string>,
266
+ responses: Set<string>,
267
+ variables: Set<string>,
268
+ diagnostics: QtiDiagnostic[],
269
+ ): void {
270
+ validateProcessingIdentifier(
271
+ rule.identifier,
272
+ "processing.lookupOutcomeTarget",
273
+ rule.source,
274
+ diagnostics,
275
+ );
276
+ if (rule.identifier && !outcomes.has(rule.identifier)) {
277
+ diagnostics.push({
278
+ code: "processing.lookupOutcomeTarget.reference",
279
+ severity: "error",
280
+ message: `qti-lookup-outcome-value references missing outcome declaration ${rule.identifier}.`,
281
+ path: rule.source?.path,
282
+ source: rule.source,
283
+ });
284
+ }
285
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
286
+ }
287
+
288
+ function validateSetOutcomeRule(
289
+ rule: QtiSetOutcomeValue,
290
+ outcomes: Set<string>,
291
+ responses: Set<string>,
292
+ variables: Set<string>,
293
+ diagnostics: QtiDiagnostic[],
294
+ ): void {
295
+ validateProcessingIdentifier(
296
+ rule.identifier,
297
+ "processing.outcomeTarget",
298
+ rule.source,
299
+ diagnostics,
300
+ );
301
+ if (rule.identifier && !outcomes.has(rule.identifier)) {
302
+ diagnostics.push({
303
+ code: "processing.outcomeTarget.reference",
304
+ severity: "error",
305
+ message: `qti-set-outcome-value references missing outcome declaration ${rule.identifier}.`,
306
+ path: rule.source?.path,
307
+ source: rule.source,
308
+ });
309
+ }
310
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
311
+ }
312
+
313
+ function validateExpressionReferences(
314
+ expression: QtiProcessingExpression | undefined,
315
+ responses: Set<string>,
316
+ variables: Set<string>,
317
+ diagnostics: QtiDiagnostic[],
318
+ ): void {
319
+ if (!expression) return;
320
+
321
+ if (expression.type === "variable" || expression.type === "isNull") {
322
+ validateProcessingIdentifier(
323
+ expression.identifier,
324
+ "processing.variable",
325
+ expression.source,
326
+ diagnostics,
327
+ );
328
+ if (expression.identifier && !variables.has(expression.identifier)) {
329
+ diagnostics.push({
330
+ code: "processing.variable.reference",
331
+ severity: "error",
332
+ message: `Processing expression references missing variable ${expression.identifier}.`,
333
+ path: expression.source?.path,
334
+ source: expression.source,
335
+ });
336
+ }
337
+ }
338
+
339
+ if (expression.type === "matchCorrect") {
340
+ validateProcessingIdentifier(
341
+ expression.identifier,
342
+ "processing.response",
343
+ expression.source,
344
+ diagnostics,
345
+ );
346
+ if (expression.identifier && !responses.has(expression.identifier)) {
347
+ diagnostics.push({
348
+ code: "processing.response.reference",
349
+ severity: "error",
350
+ message: `Processing expression references missing response declaration ${expression.identifier}.`,
351
+ path: expression.source?.path,
352
+ source: expression.source,
353
+ });
354
+ }
355
+ validateProcessingIdentifier(
356
+ expression.correctIdentifier,
357
+ "processing.correct",
358
+ expression.source,
359
+ diagnostics,
360
+ );
361
+ if (expression.correctIdentifier && !responses.has(expression.correctIdentifier)) {
362
+ diagnostics.push({
363
+ code: "processing.correct.reference",
364
+ severity: "error",
365
+ message: `Processing expression references missing correct response declaration ${expression.correctIdentifier}.`,
366
+ path: expression.source?.path,
367
+ source: expression.source,
368
+ });
369
+ }
370
+ }
371
+
372
+ if (expression.type === "mapResponse" || expression.type === "mapResponsePoint") {
373
+ validateProcessingIdentifier(
374
+ expression.identifier,
375
+ "processing.response",
376
+ expression.source,
377
+ diagnostics,
378
+ );
379
+ if (expression.identifier && !responses.has(expression.identifier)) {
380
+ diagnostics.push({
381
+ code: "processing.response.reference",
382
+ severity: "error",
383
+ message: `Processing expression references missing response declaration ${expression.identifier}.`,
384
+ path: expression.source?.path,
385
+ source: expression.source,
386
+ });
387
+ }
388
+ }
389
+
390
+ if (expression.type === "correct") {
391
+ validateProcessingIdentifier(
392
+ expression.identifier,
393
+ "processing.correct",
394
+ expression.source,
395
+ diagnostics,
396
+ );
397
+ if (expression.identifier && !responses.has(expression.identifier)) {
398
+ diagnostics.push({
399
+ code: "processing.correct.reference",
400
+ severity: "error",
401
+ message: `Processing expression references missing correct response declaration ${expression.identifier}.`,
402
+ path: expression.source?.path,
403
+ source: expression.source,
404
+ });
405
+ }
406
+ }
407
+
408
+ if (expression.type === "default") {
409
+ validateProcessingIdentifier(
410
+ expression.identifier,
411
+ "processing.variable",
412
+ expression.source,
413
+ diagnostics,
414
+ );
415
+ if (expression.identifier && !variables.has(expression.identifier)) {
416
+ diagnostics.push({
417
+ code: "processing.variable.reference",
418
+ severity: "error",
419
+ message: `Processing expression references missing variable ${expression.identifier}.`,
420
+ path: expression.source?.path,
421
+ source: expression.source,
422
+ });
423
+ }
424
+ }
425
+
426
+ if (expression.type === "randomInteger") {
427
+ validateRandomIntegerExpression(expression, diagnostics);
428
+ }
429
+
430
+ if (expression.type === "randomFloat") {
431
+ validateRandomFloatExpression(expression, diagnostics);
432
+ }
433
+
434
+ if (expression.type === "baseValue") {
435
+ validateBaseValueExpression(expression, diagnostics);
436
+ }
437
+
438
+ if (expression.type === "equalRounded") {
439
+ validateRounding(
440
+ "qti-equal-rounded",
441
+ expression.roundingMode,
442
+ expression.figures,
443
+ diagnostics,
444
+ expression.source,
445
+ );
446
+ }
447
+
448
+ if (expression.type === "mathConstant" && !mathConstantNames.has(expression.name)) {
449
+ diagnostics.push({
450
+ code: "processing.mathConstant.name",
451
+ severity: "error",
452
+ message: `qti-math-constant has unsupported name ${expression.name}.`,
453
+ path: expression.source?.path,
454
+ source: expression.source,
455
+ });
456
+ }
457
+
458
+ if (expression.type === "mathOperator" && !mathOperatorNames.has(expression.name)) {
459
+ diagnostics.push({
460
+ code: "processing.mathOperator.name",
461
+ severity: "error",
462
+ message: `qti-math-operator has unsupported name ${expression.name}.`,
463
+ path: expression.source?.path,
464
+ source: expression.source,
465
+ });
466
+ }
467
+
468
+ if (expression.type === "statsOperator" && !statsOperatorNames.has(expression.name)) {
469
+ diagnostics.push({
470
+ code: "processing.statsOperator.name",
471
+ severity: "error",
472
+ message: `qti-stats-operator has unsupported name ${expression.name}.`,
473
+ path: expression.source?.path,
474
+ source: expression.source,
475
+ });
476
+ }
477
+
478
+ if (expression.type === "repeat") {
479
+ validateRepeatExpression(expression, variables, diagnostics);
480
+ }
481
+
482
+ if (expression.type === "inside") {
483
+ validateInsideExpression(expression, diagnostics);
484
+ }
485
+
486
+ if (expression.type === "fieldValue") {
487
+ validateProcessingIdentifier(
488
+ expression.fieldIdentifier,
489
+ "processing.fieldValue.fieldIdentifier",
490
+ expression.source,
491
+ diagnostics,
492
+ );
493
+ }
494
+
495
+ for (const child of expressionChildren(expression)) {
496
+ validateExpressionReferences(child, responses, variables, diagnostics);
497
+ }
498
+ }
499
+
500
+ const mathConstantNames = new Set(["pi", "e"]);
501
+ const mathOperatorNames = new Set<string>(MATH_OPERATOR_NAMES);
502
+ const statsOperatorNames = new Set<string>(STATS_OPERATOR_NAMES);
503
+
504
+ function validateRounding(
505
+ qtiName: string,
506
+ roundingMode: string,
507
+ figures: number,
508
+ diagnostics: QtiDiagnostic[],
509
+ source: QtiDiagnostic["source"],
510
+ ): void {
511
+ if (roundingMode !== "decimalPlaces" && roundingMode !== "significantFigures") {
512
+ diagnostics.push({
513
+ code: "processing.roundingMode",
514
+ severity: "error",
515
+ message: `${qtiName} requires rounding-mode decimalPlaces or significantFigures.`,
516
+ path: source?.path,
517
+ source,
518
+ });
519
+ }
520
+ const validFigures =
521
+ Number.isInteger(figures) && (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0);
522
+ if (!validFigures) {
523
+ diagnostics.push({
524
+ code: "processing.roundingFigures",
525
+ severity: "error",
526
+ message: `${qtiName} requires valid figures for its rounding mode.`,
527
+ path: source?.path,
528
+ source,
529
+ });
530
+ }
531
+ }
532
+
533
+ function validateRepeatExpression(
534
+ expression: Extract<QtiProcessingExpression, { type: "repeat" }>,
535
+ variables: Set<string>,
536
+ diagnostics: QtiDiagnostic[],
537
+ ): void {
538
+ if (isInteger(expression.numberRepeats)) return;
539
+ validateProcessingIdentifier(
540
+ expression.numberRepeats,
541
+ "processing.repeat.numberRepeats",
542
+ expression.source,
543
+ diagnostics,
544
+ );
545
+ if (expression.numberRepeats && !variables.has(expression.numberRepeats)) {
546
+ diagnostics.push({
547
+ code: "processing.repeat.numberRepeats.reference",
548
+ severity: "error",
549
+ message: `qti-repeat references missing template or outcome variable ${expression.numberRepeats}.`,
550
+ path: expression.source?.path,
551
+ source: expression.source,
552
+ });
553
+ }
554
+ }
555
+
556
+ function validateInsideExpression(
557
+ expression: Extract<QtiProcessingExpression, { type: "inside" }>,
558
+ diagnostics: QtiDiagnostic[],
559
+ ): void {
560
+ const rawShape = expression.attributes.shape;
561
+ if (rawShape === undefined) {
562
+ diagnostics.push({
563
+ code: "processing.inside.shape.required",
564
+ severity: "error",
565
+ message: "qti-inside requires shape.",
566
+ path: expression.source?.path,
567
+ source: expression.source,
568
+ });
569
+ } else if (
570
+ rawShape !== "circle" &&
571
+ rawShape !== "rect" &&
572
+ rawShape !== "poly" &&
573
+ rawShape !== "default"
574
+ ) {
575
+ diagnostics.push({
576
+ code: "processing.inside.shape",
577
+ severity: "error",
578
+ message: `qti-inside has unsupported shape ${rawShape}.`,
579
+ path: expression.source?.path,
580
+ source: expression.source,
581
+ });
582
+ }
583
+
584
+ const expectedCoordCount =
585
+ rawShape === "circle" ? 3 : rawShape === "rect" ? 4 : rawShape === "default" ? 0 : undefined;
586
+ if (expectedCoordCount !== undefined && expression.coords.length !== expectedCoordCount) {
587
+ diagnostics.push({
588
+ code: "processing.inside.coords",
589
+ severity: "error",
590
+ message: `qti-inside shape ${rawShape} requires ${expectedCoordCount} coordinates.`,
591
+ path: expression.source?.path,
592
+ source: expression.source,
593
+ });
594
+ }
595
+ if (rawShape === "poly" && (expression.coords.length < 6 || expression.coords.length % 2 !== 0)) {
596
+ diagnostics.push({
597
+ code: "processing.inside.coords",
598
+ severity: "error",
599
+ message: "qti-inside poly requires an even number of at least 6 coordinates.",
600
+ path: expression.source?.path,
601
+ source: expression.source,
602
+ });
603
+ }
604
+ }
605
+
606
+ function validateBaseValueExpression(
607
+ expression: Extract<QtiProcessingExpression, { type: "baseValue" }>,
608
+ diagnostics: QtiDiagnostic[],
609
+ ): void {
610
+ if (!expression.baseType) {
611
+ diagnostics.push({
612
+ code: "processing.baseValue.baseType.required",
613
+ severity: "error",
614
+ message: "qti-base-value requires base-type.",
615
+ path: expression.source?.path,
616
+ source: expression.source,
617
+ });
618
+ return;
619
+ }
620
+ if (!isBaseType(expression.baseType)) {
621
+ diagnostics.push({
622
+ code: "processing.baseValue.baseType",
623
+ severity: "error",
624
+ message: `qti-base-value has unsupported base-type ${expression.baseType}.`,
625
+ path: expression.source?.path,
626
+ source: expression.source,
627
+ });
628
+ return;
629
+ }
630
+ const value = expression.rawValue ?? "";
631
+ if (
632
+ (expression.baseType === "integer" && !isInteger(value)) ||
633
+ (expression.baseType === "float" && !isFiniteNumber(value))
634
+ ) {
635
+ diagnostics.push({
636
+ code: "processing.baseValue.numeric",
637
+ severity: "error",
638
+ message: `qti-base-value requires ${expression.baseType} content, got ${value}.`,
639
+ path: expression.source?.path,
640
+ source: expression.source,
641
+ });
642
+ }
643
+ if (expression.baseType === "boolean" && value !== "true" && value !== "false") {
644
+ diagnostics.push({
645
+ code: "processing.baseValue.boolean",
646
+ severity: "error",
647
+ message: `qti-base-value requires boolean content, got ${value}.`,
648
+ path: expression.source?.path,
649
+ source: expression.source,
650
+ });
651
+ }
652
+ }
653
+
654
+ function validateRandomIntegerExpression(
655
+ expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
656
+ diagnostics: QtiDiagnostic[],
657
+ ): void {
658
+ validateRandomIntegerAttribute(expression, "min", diagnostics);
659
+ validateRandomIntegerAttribute(expression, "max", diagnostics);
660
+
661
+ if (expression.attributes.step !== undefined) {
662
+ validateRandomIntegerAttribute(expression, "step", diagnostics);
663
+ if (isInteger(expression.attributes.step) && Number(expression.attributes.step) <= 0) {
664
+ diagnostics.push({
665
+ code: "processing.randomInteger.step",
666
+ severity: "error",
667
+ message: "qti-random-integer requires step to be greater than 0.",
668
+ path: expression.source?.path,
669
+ source: expression.source,
670
+ });
671
+ }
672
+ }
673
+
674
+ const min = expression.attributes.min;
675
+ const max = expression.attributes.max;
676
+ if (
677
+ min !== undefined &&
678
+ max !== undefined &&
679
+ isInteger(min) &&
680
+ isInteger(max) &&
681
+ Number(min) > Number(max)
682
+ ) {
683
+ diagnostics.push({
684
+ code: "processing.randomInteger.bounds",
685
+ severity: "error",
686
+ message: "qti-random-integer requires min to be less than or equal to max.",
687
+ path: expression.source?.path,
688
+ source: expression.source,
689
+ });
690
+ }
691
+ }
692
+
693
+ function validateRandomIntegerAttribute(
694
+ expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
695
+ attribute: "min" | "max" | "step",
696
+ diagnostics: QtiDiagnostic[],
697
+ ): void {
698
+ const value = expression.attributes[attribute];
699
+ if (value === undefined) {
700
+ diagnostics.push({
701
+ code: "processing.randomInteger.attribute",
702
+ severity: "error",
703
+ message: `qti-random-integer requires ${attribute}.`,
704
+ path: expression.source?.path,
705
+ source: expression.source,
706
+ });
707
+ return;
708
+ }
709
+ if (isInteger(value)) return;
710
+ diagnostics.push({
711
+ code: "processing.randomInteger.integer",
712
+ severity: "error",
713
+ message: `qti-random-integer requires integer ${attribute}, got ${value}.`,
714
+ path: expression.source?.path,
715
+ source: expression.source,
716
+ });
717
+ }
718
+
719
+ function validateRandomFloatExpression(
720
+ expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
721
+ diagnostics: QtiDiagnostic[],
722
+ ): void {
723
+ validateRandomFloatAttribute(expression, "max", diagnostics);
724
+ if (expression.attributes.min !== undefined)
725
+ validateRandomFloatAttribute(expression, "min", diagnostics);
726
+
727
+ const min = expression.attributes.min ?? "0";
728
+ const max = expression.attributes.max;
729
+ if (
730
+ max !== undefined &&
731
+ isFiniteNumber(min) &&
732
+ isFiniteNumber(max) &&
733
+ Number(min) > Number(max)
734
+ ) {
735
+ diagnostics.push({
736
+ code: "processing.randomFloat.bounds",
737
+ severity: "error",
738
+ message: "qti-random-float requires min to be less than or equal to max.",
739
+ path: expression.source?.path,
740
+ source: expression.source,
741
+ });
742
+ }
743
+ }
744
+
745
+ function validateRandomFloatAttribute(
746
+ expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
747
+ attribute: "min" | "max",
748
+ diagnostics: QtiDiagnostic[],
749
+ ): void {
750
+ const value = expression.attributes[attribute];
751
+ if (value === undefined) {
752
+ diagnostics.push({
753
+ code: "processing.randomFloat.attribute",
754
+ severity: "error",
755
+ message: `qti-random-float requires ${attribute}.`,
756
+ path: expression.source?.path,
757
+ source: expression.source,
758
+ });
759
+ return;
760
+ }
761
+ if (isFiniteNumber(value)) return;
762
+ diagnostics.push({
763
+ code: "processing.randomFloat.numeric",
764
+ severity: "error",
765
+ message: `qti-random-float requires numeric ${attribute}, got ${value}.`,
766
+ path: expression.source?.path,
767
+ source: expression.source,
768
+ });
769
+ }
770
+
771
+ function validateProcessingIdentifier(
772
+ identifier: string,
773
+ code: string,
774
+ source: QtiDiagnostic["source"],
775
+ diagnostics: QtiDiagnostic[],
776
+ ): void {
777
+ if (identifier.trim().length > 0) return;
778
+ diagnostics.push({
779
+ code,
780
+ severity: "error",
781
+ message: "Processing rule requires a non-empty identifier.",
782
+ path: source?.path,
783
+ source,
784
+ });
785
+ }