@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
package/src/validation.ts CHANGED
@@ -3,31 +3,40 @@ import type {
3
3
  QtiBaseType,
4
4
  QtiCatalogCard,
5
5
  QtiCatalogCardEntry,
6
- QtiCardinality,
7
- QtiChoice,
8
- QtiElementContent,
9
6
  QtiContentNode,
10
7
  QtiDiagnostic,
11
8
  QtiDocument,
12
- QtiInteraction,
13
9
  QtiOutcomeDeclaration,
14
- QtiProcessingExpression,
15
- QtiResponseCondition,
16
10
  QtiResponseDeclaration,
17
- QtiResponseRule,
18
- QtiSetOutcomeValue,
19
11
  QtiTemplateDeclaration,
20
- QtiTemplateRule,
21
12
  QtiValue,
22
13
  QtiValidationResult,
23
14
  } from "./types.js";
24
- import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interaction-validation.js";
25
- import { isValidQtiPatternMask } from "./pattern-mask.js";
15
+ import { assertNever } from "./assert-never.js";
26
16
  import { validateQtiDataSsmlMetadata } from "./tts.js";
27
- import { qtiValueToStringList } from "./value-format.js";
28
17
  import { validateCompanionMaterials } from "./validation-companion-materials.js";
29
-
30
- const BUILT_IN_COMPLETION_STATUS = "completionStatus";
18
+ import {
19
+ hasValidShapeCoordinateCount,
20
+ isAreaShape,
21
+ isNumericCsv,
22
+ numericCsv,
23
+ } from "./validation-geometry.js";
24
+ import {
25
+ isBaseType,
26
+ isCardinality,
27
+ isFiniteNumber,
28
+ isInteger,
29
+ isPair,
30
+ isPoint,
31
+ requireIdentifier,
32
+ } from "./validation-primitives.js";
33
+ import { validateItemBodySharedVocabulary } from "./validation-shared-vocabulary-layout.js";
34
+ import { validateInteractions } from "./validation-interactions.js";
35
+ import {
36
+ validateProcessingReferences,
37
+ validateResponseProcessingTemplate,
38
+ } from "./validation-processing.js";
39
+ import { COMPLETION_STATUS } from "./attempt-state-constants.js";
31
40
 
32
41
  export function validateAssessmentItem(document: QtiDocument): QtiValidationResult {
33
42
  const diagnostics: QtiDiagnostic[] = [];
@@ -54,37 +63,6 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
54
63
  };
55
64
  }
56
65
 
57
- function validateResponseProcessingTemplate(
58
- item: QtiAssessmentItem,
59
- diagnostics: QtiDiagnostic[],
60
- ): void {
61
- const template = item.responseProcessing?.template;
62
- if (!template) return;
63
- const templateKind = responseProcessingTemplateKind(template);
64
- if (templateKind) {
65
- validateBuiltInResponseProcessingTemplate(item, templateKind, diagnostics);
66
- return;
67
- }
68
- diagnostics.push({
69
- code: "processing.template.unsupported",
70
- severity: "error",
71
- message: `qti-response-processing template ${template} is not currently supported.`,
72
- path: item.source?.path,
73
- source: item.source,
74
- });
75
- }
76
-
77
- function responseProcessingTemplateKind(
78
- template: string,
79
- ): "matchCorrect" | "mapResponse" | "mapResponsePoint" | undefined {
80
- const path = template.split(/[?#]/, 1)[0] ?? "";
81
- const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
82
- if (name === "match_correct") return "matchCorrect";
83
- if (name === "map_response") return "mapResponse";
84
- if (name === "map_response_point") return "mapResponsePoint";
85
- return undefined;
86
- }
87
-
88
66
  function validateAssessmentItemRoot(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
89
67
  if (!item.attributes.title?.trim()) {
90
68
  diagnostics.push({
@@ -115,76 +93,6 @@ function validateAssessmentItemRoot(item: QtiAssessmentItem, diagnostics: QtiDia
115
93
  }
116
94
  }
117
95
 
118
- function validateBuiltInResponseProcessingTemplate(
119
- item: QtiAssessmentItem,
120
- templateKind: "matchCorrect" | "mapResponse" | "mapResponsePoint",
121
- diagnostics: QtiDiagnostic[],
122
- ): void {
123
- const response = item.responseDeclarations.find(
124
- (declaration) => declaration.identifier === "RESPONSE",
125
- );
126
- const score = item.outcomeDeclarations.find((declaration) => declaration.identifier === "SCORE");
127
- if (!response) {
128
- diagnostics.push({
129
- code: "processing.template.responseIdentifier",
130
- severity: "error",
131
- message:
132
- "Built-in response-processing templates require a response declaration named RESPONSE.",
133
- path: item.source?.path,
134
- source: item.source,
135
- });
136
- }
137
- if (!score) {
138
- diagnostics.push({
139
- code: "processing.template.scoreIdentifier",
140
- severity: "error",
141
- message: "Built-in response-processing templates require an outcome declaration named SCORE.",
142
- path: item.source?.path,
143
- source: item.source,
144
- });
145
- } else if (score.cardinality !== "single" || score.baseType !== "float") {
146
- diagnostics.push({
147
- code: "processing.template.scoreDeclaration",
148
- severity: "error",
149
- message:
150
- "Built-in response-processing templates require SCORE to be single cardinality with base-type float.",
151
- path: score.source?.path,
152
- source: score.source,
153
- });
154
- }
155
- const responseInteractions = item.interactions.filter(
156
- (interaction) => interaction.responseIdentifier === "RESPONSE",
157
- );
158
- if (responseInteractions.length !== 1 || item.interactions.length !== 1) {
159
- diagnostics.push({
160
- code: "processing.template.singleInteraction",
161
- severity: "error",
162
- message:
163
- "Built-in response-processing templates require a single interaction bound to RESPONSE.",
164
- path: item.source?.path,
165
- source: item.source,
166
- });
167
- }
168
- if (templateKind === "mapResponse" && response && !response.mapping) {
169
- diagnostics.push({
170
- code: "processing.template.mapping",
171
- severity: "error",
172
- message: "The map_response template requires RESPONSE to define qti-mapping.",
173
- path: response.source?.path,
174
- source: response.source,
175
- });
176
- }
177
- if (templateKind === "mapResponsePoint" && response && !response.areaMapping) {
178
- diagnostics.push({
179
- code: "processing.template.areaMapping",
180
- severity: "error",
181
- message: "The map_response_point template requires RESPONSE to define qti-area-mapping.",
182
- path: response.source?.path,
183
- source: response.source,
184
- });
185
- }
186
- }
187
-
188
96
  function isXmlBoolean(value: string): boolean {
189
97
  return value === "true" || value === "false" || value === "1" || value === "0";
190
98
  }
@@ -200,90 +108,6 @@ function validateItemBody(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[])
200
108
  });
201
109
  }
202
110
 
203
- function validateItemBodySharedVocabulary(
204
- item: QtiAssessmentItem,
205
- diagnostics: QtiDiagnostic[],
206
- ): void {
207
- validateContentSharedVocabulary(item.body, diagnostics);
208
- }
209
-
210
- function validateContentSharedVocabulary(
211
- nodes: QtiContentNode[],
212
- diagnostics: QtiDiagnostic[],
213
- ): void {
214
- for (const node of nodes) {
215
- if (node.kind !== "element") continue;
216
- validateLayoutVocabularyClasses(node, diagnostics);
217
- if (sharedClassNames(node.attributes).includes("qti-layout-row")) {
218
- validateLayoutRow(node, diagnostics);
219
- }
220
- validateContentSharedVocabulary(node.children, diagnostics);
221
- }
222
- }
223
-
224
- function validateLayoutVocabularyClasses(
225
- node: QtiElementContent,
226
- diagnostics: QtiDiagnostic[],
227
- ): void {
228
- const classNames = sharedClassNames(node.attributes);
229
- for (const className of classNames) {
230
- if (isLayoutColumnClassName(className) && layoutColumnValue(className) === undefined) {
231
- diagnostics.push({
232
- code: "item.sharedVocabulary.layoutColumnInvalid",
233
- severity: "warning",
234
- message: `Shared vocabulary class ${className} is not supported; expected qti-layout-col1 through qti-layout-col12, or dashed qti-layout-col-1 through qti-layout-col-12.`,
235
- path: node.source?.path,
236
- source: node.source,
237
- });
238
- }
239
- if (isLayoutOffsetClassName(className) && layoutOffsetValue(className) === undefined) {
240
- diagnostics.push({
241
- code: "item.sharedVocabulary.layoutOffsetInvalid",
242
- severity: "warning",
243
- message: `Shared vocabulary class ${className} is not supported; expected qti-layout-offset1 through qti-layout-offset11, or dashed qti-layout-offset-1 through qti-layout-offset-11.`,
244
- path: node.source?.path,
245
- source: node.source,
246
- });
247
- }
248
- }
249
- }
250
-
251
- function validateLayoutRow(node: QtiElementContent, diagnostics: QtiDiagnostic[]): void {
252
- let totalColumns = 0;
253
- for (const child of node.children) {
254
- if (child.kind !== "element") continue;
255
- const classNames = sharedClassNames(child.attributes);
256
- const column = firstLayoutColumnValue(classNames);
257
- if (column === undefined) continue;
258
- totalColumns += (firstLayoutOffsetValue(classNames) ?? 0) + column;
259
- }
260
- if (totalColumns <= 12) return;
261
- diagnostics.push({
262
- code: "item.sharedVocabulary.layoutRowOverflow",
263
- severity: "warning",
264
- message: `qti-layout-row column groupings plus offsets total ${totalColumns}; the QTI shared vocabulary grid allows at most twelve columns per row.`,
265
- path: node.source?.path,
266
- source: node.source,
267
- });
268
- }
269
-
270
- function requireIdentifier(
271
- elementName: string,
272
- identifier: string | undefined,
273
- diagnostics: QtiDiagnostic[],
274
- source?: QtiDiagnostic["source"],
275
- ): void {
276
- if (!identifier || identifier.trim().length === 0) {
277
- diagnostics.push({
278
- code: "identifier.required",
279
- severity: "error",
280
- message: `${elementName} requires a non-empty identifier.`,
281
- path: source?.path,
282
- source,
283
- });
284
- }
285
- }
286
-
287
111
  function validateDeclarationIdentifiers(
288
112
  item: QtiAssessmentItem,
289
113
  diagnostics: QtiDiagnostic[],
@@ -302,7 +126,7 @@ function validateDeclarationIdentifiers(
302
126
  );
303
127
  validateDeclarationRequiredAttributes(declaration, diagnostics);
304
128
  validateDeclarationValueMetadata(declaration, diagnostics);
305
- if (declaration.kind === "outcome" && declaration.identifier === BUILT_IN_COMPLETION_STATUS) {
129
+ if (declaration.kind === "outcome" && declaration.identifier === COMPLETION_STATUS) {
306
130
  diagnostics.push({
307
131
  code: "declaration.outcome.builtIn",
308
132
  severity: "error",
@@ -408,6 +232,8 @@ function isValidDeclarationBaseValue(value: string, baseType: QtiBaseType): bool
408
232
  case "file":
409
233
  case "uri":
410
234
  return true;
235
+ default:
236
+ return assertNever(baseType);
411
237
  }
412
238
  }
413
239
 
@@ -855,1891 +681,32 @@ function validateCatalogHtmlContent(nodes: QtiContentNode[], diagnostics: QtiDia
855
681
  }
856
682
  }
857
683
 
858
- function validateProcessingReferences(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
859
- const responses = new Set(item.responseDeclarations.map((declaration) => declaration.identifier));
860
- const outcomes = new Set(item.outcomeDeclarations.map((declaration) => declaration.identifier));
861
- outcomes.add(BUILT_IN_COMPLETION_STATUS);
862
- const templates = new Set(item.templateDeclarations.map((declaration) => declaration.identifier));
863
- const variables = new Set([...responses, ...outcomes, ...templates]);
864
-
865
- for (const rule of item.templateProcessing?.rules ?? []) {
866
- validateTemplateRule(rule, responses, outcomes, templates, variables, diagnostics);
867
- }
868
-
869
- for (const rule of item.responseProcessing?.rules ?? []) {
870
- validateResponseRule(rule, outcomes, responses, variables, diagnostics);
871
- }
872
- }
873
-
874
- function validateResponseCondition(
875
- condition: QtiResponseCondition,
876
- outcomes: Set<string>,
877
- responses: Set<string>,
878
- variables: Set<string>,
879
- diagnostics: QtiDiagnostic[],
880
- ): void {
881
- validateExpressionReferences(condition.ifExpression, responses, variables, diagnostics);
882
- for (const rule of condition.thenRules) {
883
- validateResponseRule(rule, outcomes, responses, variables, diagnostics);
884
- }
885
- for (const branch of condition.elseIfs) {
886
- validateExpressionReferences(branch.expression, responses, variables, diagnostics);
887
- for (const rule of branch.rules) {
888
- validateResponseRule(rule, outcomes, responses, variables, diagnostics);
889
- }
890
- }
891
- for (const rule of condition.elseRules) {
892
- validateResponseRule(rule, outcomes, responses, variables, diagnostics);
893
- }
894
- }
895
-
896
- function validateTemplateRule(
897
- rule: QtiTemplateRule,
898
- responses: Set<string>,
899
- outcomes: Set<string>,
900
- templates: Set<string>,
901
- variables: Set<string>,
902
- diagnostics: QtiDiagnostic[],
903
- ): void {
904
- if (rule.type === "exitTemplate") return;
905
- if (rule.type === "templateConstraint") {
906
- validateExpressionReferences(rule.expression, responses, variables, diagnostics);
907
- return;
908
- }
909
-
910
- if (rule.type === "templateCondition") {
911
- validateExpressionReferences(rule.ifExpression, responses, variables, diagnostics);
912
- for (const branchRule of rule.thenRules) {
913
- validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
914
- }
915
- for (const branch of rule.elseIfs) {
916
- validateExpressionReferences(branch.expression, responses, variables, diagnostics);
917
- for (const branchRule of branch.rules) {
918
- validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
919
- }
920
- }
921
- for (const branchRule of rule.elseRules) {
922
- validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
923
- }
924
- return;
925
- }
926
-
927
- if (rule.type === "setTemplateValue") {
928
- validateProcessingIdentifier(
929
- rule.identifier,
930
- "processing.templateTarget",
931
- rule.source,
932
- diagnostics,
933
- );
934
- if (rule.identifier && !templates.has(rule.identifier)) {
935
- diagnostics.push({
936
- code: "processing.templateTarget.reference",
937
- severity: "error",
938
- message: `qti-set-template-value references missing template declaration ${rule.identifier}.`,
939
- path: rule.source?.path,
940
- source: rule.source,
941
- });
942
- }
943
- }
944
-
945
- if (rule.type === "setDefaultValue") {
946
- validateProcessingIdentifier(
947
- rule.identifier,
948
- "processing.defaultTarget",
949
- rule.source,
950
- diagnostics,
951
- );
952
- if (rule.identifier && !responses.has(rule.identifier) && !outcomes.has(rule.identifier)) {
953
- diagnostics.push({
954
- code: "processing.defaultTarget.reference",
955
- severity: "error",
956
- message: `qti-set-default-value references missing response or outcome declaration ${rule.identifier}.`,
957
- path: rule.source?.path,
958
- source: rule.source,
959
- });
960
- }
961
- }
962
-
963
- if (rule.type === "setCorrectResponse") {
964
- validateProcessingIdentifier(
965
- rule.identifier,
966
- "processing.correctResponse",
967
- rule.source,
968
- diagnostics,
969
- );
970
- if (rule.identifier && !responses.has(rule.identifier)) {
971
- diagnostics.push({
972
- code: "processing.correctResponse.reference",
973
- severity: "error",
974
- message: `qti-set-correct-response references missing response declaration ${rule.identifier}.`,
975
- path: rule.source?.path,
976
- source: rule.source,
977
- });
978
- }
979
- }
980
-
981
- validateExpressionReferences(rule.expression, responses, variables, diagnostics);
982
- }
983
-
984
- function validateResponseRule(
985
- rule: QtiResponseRule,
986
- outcomes: Set<string>,
987
- responses: Set<string>,
988
- variables: Set<string>,
989
- diagnostics: QtiDiagnostic[],
990
- ): void {
991
- if (rule.type === "exitResponse") return;
992
- if (rule.type === "responseCondition") {
993
- validateResponseCondition(rule.condition, outcomes, responses, variables, diagnostics);
994
- return;
995
- }
996
- if (rule.type === "responseProcessingFragment") {
997
- for (const childRule of rule.rules) {
998
- validateResponseRule(childRule, outcomes, responses, variables, diagnostics);
999
- }
1000
- return;
1001
- }
1002
- if (rule.type === "lookupOutcomeValue") {
1003
- validateLookupOutcomeRule(rule, outcomes, responses, variables, diagnostics);
1004
- return;
1005
- }
1006
- validateSetOutcomeRule(rule, outcomes, responses, variables, diagnostics);
1007
- }
1008
-
1009
- function validateLookupOutcomeRule(
1010
- rule: Extract<QtiResponseRule, { type: "lookupOutcomeValue" }>,
1011
- outcomes: Set<string>,
1012
- responses: Set<string>,
1013
- variables: Set<string>,
1014
- diagnostics: QtiDiagnostic[],
1015
- ): void {
1016
- validateProcessingIdentifier(
1017
- rule.identifier,
1018
- "processing.lookupOutcomeTarget",
1019
- rule.source,
1020
- diagnostics,
1021
- );
1022
- if (rule.identifier && !outcomes.has(rule.identifier)) {
1023
- diagnostics.push({
1024
- code: "processing.lookupOutcomeTarget.reference",
1025
- severity: "error",
1026
- message: `qti-lookup-outcome-value references missing outcome declaration ${rule.identifier}.`,
1027
- path: rule.source?.path,
1028
- source: rule.source,
1029
- });
1030
- }
1031
- validateExpressionReferences(rule.expression, responses, variables, diagnostics);
1032
- }
1033
-
1034
- function validateSetOutcomeRule(
1035
- rule: QtiSetOutcomeValue,
1036
- outcomes: Set<string>,
1037
- responses: Set<string>,
1038
- variables: Set<string>,
1039
- diagnostics: QtiDiagnostic[],
1040
- ): void {
1041
- validateProcessingIdentifier(
1042
- rule.identifier,
1043
- "processing.outcomeTarget",
1044
- rule.source,
1045
- diagnostics,
684
+ function validateModalFeedback(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
685
+ const outcomeIdentifiers = new Set(
686
+ item.outcomeDeclarations.map((declaration) => declaration.identifier),
1046
687
  );
1047
- if (rule.identifier && !outcomes.has(rule.identifier)) {
1048
- diagnostics.push({
1049
- code: "processing.outcomeTarget.reference",
1050
- severity: "error",
1051
- message: `qti-set-outcome-value references missing outcome declaration ${rule.identifier}.`,
1052
- path: rule.source?.path,
1053
- source: rule.source,
1054
- });
1055
- }
1056
- validateExpressionReferences(rule.expression, responses, variables, diagnostics);
1057
- }
1058
-
1059
- function validateExpressionReferences(
1060
- expression: QtiProcessingExpression | undefined,
1061
- responses: Set<string>,
1062
- variables: Set<string>,
1063
- diagnostics: QtiDiagnostic[],
1064
- ): void {
1065
- if (!expression) return;
1066
-
1067
- if (expression.type === "variable" || expression.type === "isNull") {
1068
- validateProcessingIdentifier(
1069
- expression.identifier,
1070
- "processing.variable",
1071
- expression.source,
1072
- diagnostics,
1073
- );
1074
- if (expression.identifier && !variables.has(expression.identifier)) {
1075
- diagnostics.push({
1076
- code: "processing.variable.reference",
1077
- severity: "error",
1078
- message: `Processing expression references missing variable ${expression.identifier}.`,
1079
- path: expression.source?.path,
1080
- source: expression.source,
1081
- });
1082
- }
1083
- }
1084
-
1085
- if (expression.type === "matchCorrect") {
1086
- validateProcessingIdentifier(
1087
- expression.identifier,
1088
- "processing.response",
1089
- expression.source,
1090
- diagnostics,
1091
- );
1092
- if (expression.identifier && !responses.has(expression.identifier)) {
1093
- diagnostics.push({
1094
- code: "processing.response.reference",
1095
- severity: "error",
1096
- message: `Processing expression references missing response declaration ${expression.identifier}.`,
1097
- path: expression.source?.path,
1098
- source: expression.source,
1099
- });
1100
- }
1101
- validateProcessingIdentifier(
1102
- expression.correctIdentifier,
1103
- "processing.correct",
1104
- expression.source,
1105
- diagnostics,
1106
- );
1107
- if (expression.correctIdentifier && !responses.has(expression.correctIdentifier)) {
1108
- diagnostics.push({
1109
- code: "processing.correct.reference",
1110
- severity: "error",
1111
- message: `Processing expression references missing correct response declaration ${expression.correctIdentifier}.`,
1112
- path: expression.source?.path,
1113
- source: expression.source,
1114
- });
1115
- }
1116
- }
1117
-
1118
- if (expression.type === "mapResponse" || expression.type === "mapResponsePoint") {
1119
- validateProcessingIdentifier(
1120
- expression.identifier,
1121
- "processing.response",
1122
- expression.source,
1123
- diagnostics,
1124
- );
1125
- if (expression.identifier && !responses.has(expression.identifier)) {
1126
- diagnostics.push({
1127
- code: "processing.response.reference",
1128
- severity: "error",
1129
- message: `Processing expression references missing response declaration ${expression.identifier}.`,
1130
- path: expression.source?.path,
1131
- source: expression.source,
1132
- });
1133
- }
1134
- }
1135
-
1136
- if (expression.type === "correct") {
1137
- validateProcessingIdentifier(
1138
- expression.identifier,
1139
- "processing.correct",
1140
- expression.source,
1141
- diagnostics,
1142
- );
1143
- if (expression.identifier && !responses.has(expression.identifier)) {
688
+ const seen = new Set<string>();
689
+ for (const feedback of item.modalFeedback) {
690
+ requireIdentifier("qti-modal-feedback", feedback.identifier, diagnostics, feedback.source);
691
+ const key = `${feedback.outcomeIdentifier}\n${feedback.identifier}`;
692
+ if (seen.has(key)) {
1144
693
  diagnostics.push({
1145
- code: "processing.correct.reference",
694
+ code: "feedback.identifier.duplicate",
1146
695
  severity: "error",
1147
- message: `Processing expression references missing correct response declaration ${expression.identifier}.`,
1148
- path: expression.source?.path,
1149
- source: expression.source,
696
+ message: `Duplicate modal feedback ${feedback.identifier} for outcome ${feedback.outcomeIdentifier}.`,
697
+ path: feedback.source?.path,
698
+ source: feedback.source,
1150
699
  });
1151
700
  }
1152
- }
1153
-
1154
- if (expression.type === "default") {
1155
- validateProcessingIdentifier(
1156
- expression.identifier,
1157
- "processing.variable",
1158
- expression.source,
1159
- diagnostics,
1160
- );
1161
- if (expression.identifier && !variables.has(expression.identifier)) {
701
+ seen.add(key);
702
+ if (!outcomeIdentifiers.has(feedback.outcomeIdentifier)) {
1162
703
  diagnostics.push({
1163
- code: "processing.variable.reference",
704
+ code: "feedback.outcomeIdentifier.reference",
1164
705
  severity: "error",
1165
- message: `Processing expression references missing variable ${expression.identifier}.`,
1166
- path: expression.source?.path,
1167
- source: expression.source,
706
+ message: `qti-modal-feedback ${feedback.identifier} references missing outcome declaration ${feedback.outcomeIdentifier}.`,
707
+ path: feedback.source?.path,
708
+ source: feedback.source,
1168
709
  });
1169
710
  }
1170
711
  }
1171
-
1172
- if (expression.type === "randomInteger") {
1173
- validateRandomIntegerExpression(expression, diagnostics);
1174
- }
1175
-
1176
- if (expression.type === "randomFloat") {
1177
- validateRandomFloatExpression(expression, diagnostics);
1178
- }
1179
-
1180
- if (expression.type === "baseValue") {
1181
- validateBaseValueExpression(expression, diagnostics);
1182
- }
1183
-
1184
- if (expression.type === "equalRounded") {
1185
- validateRounding(
1186
- "qti-equal-rounded",
1187
- expression.roundingMode,
1188
- expression.figures,
1189
- diagnostics,
1190
- expression.source,
1191
- );
1192
- }
1193
-
1194
- if (expression.type === "mathConstant" && !mathConstantNames.has(expression.name)) {
1195
- diagnostics.push({
1196
- code: "processing.mathConstant.name",
1197
- severity: "error",
1198
- message: `qti-math-constant has unsupported name ${expression.name}.`,
1199
- path: expression.source?.path,
1200
- source: expression.source,
1201
- });
1202
- }
1203
-
1204
- if (expression.type === "mathOperator" && !mathOperatorNames.has(expression.name)) {
1205
- diagnostics.push({
1206
- code: "processing.mathOperator.name",
1207
- severity: "error",
1208
- message: `qti-math-operator has unsupported name ${expression.name}.`,
1209
- path: expression.source?.path,
1210
- source: expression.source,
1211
- });
1212
- }
1213
-
1214
- if (expression.type === "statsOperator" && !statsOperatorNames.has(expression.name)) {
1215
- diagnostics.push({
1216
- code: "processing.statsOperator.name",
1217
- severity: "error",
1218
- message: `qti-stats-operator has unsupported name ${expression.name}.`,
1219
- path: expression.source?.path,
1220
- source: expression.source,
1221
- });
1222
- }
1223
-
1224
- if (expression.type === "repeat") {
1225
- validateRepeatExpression(expression, variables, diagnostics);
1226
- }
1227
-
1228
- if (expression.type === "inside") {
1229
- validateInsideExpression(expression, diagnostics);
1230
- }
1231
-
1232
- if (expression.type === "fieldValue") {
1233
- validateProcessingIdentifier(
1234
- expression.fieldIdentifier,
1235
- "processing.fieldValue.fieldIdentifier",
1236
- expression.source,
1237
- diagnostics,
1238
- );
1239
- }
1240
-
1241
- for (const child of expressionChildren(expression)) {
1242
- validateExpressionReferences(child, responses, variables, diagnostics);
1243
- }
1244
- }
1245
-
1246
- const mathConstantNames = new Set(["pi", "e"]);
1247
- const mathOperatorNames = new Set([
1248
- "abs",
1249
- "acos",
1250
- "acot",
1251
- "acsc",
1252
- "asec",
1253
- "asin",
1254
- "atan",
1255
- "atan2",
1256
- "ceil",
1257
- "cos",
1258
- "cosh",
1259
- "cot",
1260
- "coth",
1261
- "csc",
1262
- "csch",
1263
- "exp",
1264
- "floor",
1265
- "ln",
1266
- "log",
1267
- "sec",
1268
- "sech",
1269
- "signum",
1270
- "sin",
1271
- "sinh",
1272
- "tan",
1273
- "tanh",
1274
- "toDegrees",
1275
- "toRadians",
1276
- ]);
1277
- const statsOperatorNames = new Set(["mean", "sampleVariance", "sampleSD", "popVariance", "popSD"]);
1278
-
1279
- function validateRounding(
1280
- qtiName: string,
1281
- roundingMode: string,
1282
- figures: number,
1283
- diagnostics: QtiDiagnostic[],
1284
- source: QtiDiagnostic["source"],
1285
- ): void {
1286
- if (roundingMode !== "decimalPlaces" && roundingMode !== "significantFigures") {
1287
- diagnostics.push({
1288
- code: "processing.roundingMode",
1289
- severity: "error",
1290
- message: `${qtiName} requires rounding-mode decimalPlaces or significantFigures.`,
1291
- path: source?.path,
1292
- source,
1293
- });
1294
- }
1295
- const validFigures =
1296
- Number.isInteger(figures) && (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0);
1297
- if (!validFigures) {
1298
- diagnostics.push({
1299
- code: "processing.roundingFigures",
1300
- severity: "error",
1301
- message: `${qtiName} requires valid figures for its rounding mode.`,
1302
- path: source?.path,
1303
- source,
1304
- });
1305
- }
1306
- }
1307
-
1308
- function validateRepeatExpression(
1309
- expression: Extract<QtiProcessingExpression, { type: "repeat" }>,
1310
- variables: Set<string>,
1311
- diagnostics: QtiDiagnostic[],
1312
- ): void {
1313
- if (isInteger(expression.numberRepeats)) return;
1314
- validateProcessingIdentifier(
1315
- expression.numberRepeats,
1316
- "processing.repeat.numberRepeats",
1317
- expression.source,
1318
- diagnostics,
1319
- );
1320
- if (expression.numberRepeats && !variables.has(expression.numberRepeats)) {
1321
- diagnostics.push({
1322
- code: "processing.repeat.numberRepeats.reference",
1323
- severity: "error",
1324
- message: `qti-repeat references missing template or outcome variable ${expression.numberRepeats}.`,
1325
- path: expression.source?.path,
1326
- source: expression.source,
1327
- });
1328
- }
1329
- }
1330
-
1331
- function validateInsideExpression(
1332
- expression: Extract<QtiProcessingExpression, { type: "inside" }>,
1333
- diagnostics: QtiDiagnostic[],
1334
- ): void {
1335
- const rawShape = expression.attributes.shape;
1336
- if (rawShape === undefined) {
1337
- diagnostics.push({
1338
- code: "processing.inside.shape.required",
1339
- severity: "error",
1340
- message: "qti-inside requires shape.",
1341
- path: expression.source?.path,
1342
- source: expression.source,
1343
- });
1344
- } else if (
1345
- rawShape !== "circle" &&
1346
- rawShape !== "rect" &&
1347
- rawShape !== "poly" &&
1348
- rawShape !== "default"
1349
- ) {
1350
- diagnostics.push({
1351
- code: "processing.inside.shape",
1352
- severity: "error",
1353
- message: `qti-inside has unsupported shape ${rawShape}.`,
1354
- path: expression.source?.path,
1355
- source: expression.source,
1356
- });
1357
- }
1358
-
1359
- const expectedCoordCount =
1360
- rawShape === "circle" ? 3 : rawShape === "rect" ? 4 : rawShape === "default" ? 0 : undefined;
1361
- if (expectedCoordCount !== undefined && expression.coords.length !== expectedCoordCount) {
1362
- diagnostics.push({
1363
- code: "processing.inside.coords",
1364
- severity: "error",
1365
- message: `qti-inside shape ${rawShape} requires ${expectedCoordCount} coordinates.`,
1366
- path: expression.source?.path,
1367
- source: expression.source,
1368
- });
1369
- }
1370
- if (rawShape === "poly" && (expression.coords.length < 6 || expression.coords.length % 2 !== 0)) {
1371
- diagnostics.push({
1372
- code: "processing.inside.coords",
1373
- severity: "error",
1374
- message: "qti-inside poly requires an even number of at least 6 coordinates.",
1375
- path: expression.source?.path,
1376
- source: expression.source,
1377
- });
1378
- }
1379
- }
1380
-
1381
- function validateBaseValueExpression(
1382
- expression: Extract<QtiProcessingExpression, { type: "baseValue" }>,
1383
- diagnostics: QtiDiagnostic[],
1384
- ): void {
1385
- if (!expression.baseType) {
1386
- diagnostics.push({
1387
- code: "processing.baseValue.baseType.required",
1388
- severity: "error",
1389
- message: "qti-base-value requires base-type.",
1390
- path: expression.source?.path,
1391
- source: expression.source,
1392
- });
1393
- return;
1394
- }
1395
- if (!isBaseType(expression.baseType)) {
1396
- diagnostics.push({
1397
- code: "processing.baseValue.baseType",
1398
- severity: "error",
1399
- message: `qti-base-value has unsupported base-type ${expression.baseType}.`,
1400
- path: expression.source?.path,
1401
- source: expression.source,
1402
- });
1403
- return;
1404
- }
1405
- const value = expression.rawValue ?? "";
1406
- if (
1407
- (expression.baseType === "integer" && !isInteger(value)) ||
1408
- (expression.baseType === "float" && !isFiniteNumber(value))
1409
- ) {
1410
- diagnostics.push({
1411
- code: "processing.baseValue.numeric",
1412
- severity: "error",
1413
- message: `qti-base-value requires ${expression.baseType} content, got ${value}.`,
1414
- path: expression.source?.path,
1415
- source: expression.source,
1416
- });
1417
- }
1418
- if (expression.baseType === "boolean" && value !== "true" && value !== "false") {
1419
- diagnostics.push({
1420
- code: "processing.baseValue.boolean",
1421
- severity: "error",
1422
- message: `qti-base-value requires boolean content, got ${value}.`,
1423
- path: expression.source?.path,
1424
- source: expression.source,
1425
- });
1426
- }
1427
- }
1428
-
1429
- function validateRandomIntegerExpression(
1430
- expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
1431
- diagnostics: QtiDiagnostic[],
1432
- ): void {
1433
- validateRandomIntegerAttribute(expression, "min", diagnostics);
1434
- validateRandomIntegerAttribute(expression, "max", diagnostics);
1435
-
1436
- if (expression.attributes.step !== undefined) {
1437
- validateRandomIntegerAttribute(expression, "step", diagnostics);
1438
- if (isInteger(expression.attributes.step) && Number(expression.attributes.step) <= 0) {
1439
- diagnostics.push({
1440
- code: "processing.randomInteger.step",
1441
- severity: "error",
1442
- message: "qti-random-integer requires step to be greater than 0.",
1443
- path: expression.source?.path,
1444
- source: expression.source,
1445
- });
1446
- }
1447
- }
1448
-
1449
- const min = expression.attributes.min;
1450
- const max = expression.attributes.max;
1451
- if (
1452
- min !== undefined &&
1453
- max !== undefined &&
1454
- isInteger(min) &&
1455
- isInteger(max) &&
1456
- Number(min) > Number(max)
1457
- ) {
1458
- diagnostics.push({
1459
- code: "processing.randomInteger.bounds",
1460
- severity: "error",
1461
- message: "qti-random-integer requires min to be less than or equal to max.",
1462
- path: expression.source?.path,
1463
- source: expression.source,
1464
- });
1465
- }
1466
- }
1467
-
1468
- function validateRandomIntegerAttribute(
1469
- expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
1470
- attribute: "min" | "max" | "step",
1471
- diagnostics: QtiDiagnostic[],
1472
- ): void {
1473
- const value = expression.attributes[attribute];
1474
- if (value === undefined) {
1475
- diagnostics.push({
1476
- code: "processing.randomInteger.attribute",
1477
- severity: "error",
1478
- message: `qti-random-integer requires ${attribute}.`,
1479
- path: expression.source?.path,
1480
- source: expression.source,
1481
- });
1482
- return;
1483
- }
1484
- if (isInteger(value)) return;
1485
- diagnostics.push({
1486
- code: "processing.randomInteger.integer",
1487
- severity: "error",
1488
- message: `qti-random-integer requires integer ${attribute}, got ${value}.`,
1489
- path: expression.source?.path,
1490
- source: expression.source,
1491
- });
1492
- }
1493
-
1494
- function validateRandomFloatExpression(
1495
- expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
1496
- diagnostics: QtiDiagnostic[],
1497
- ): void {
1498
- validateRandomFloatAttribute(expression, "max", diagnostics);
1499
- if (expression.attributes.min !== undefined)
1500
- validateRandomFloatAttribute(expression, "min", diagnostics);
1501
-
1502
- const min = expression.attributes.min ?? "0";
1503
- const max = expression.attributes.max;
1504
- if (
1505
- max !== undefined &&
1506
- isFiniteNumber(min) &&
1507
- isFiniteNumber(max) &&
1508
- Number(min) > Number(max)
1509
- ) {
1510
- diagnostics.push({
1511
- code: "processing.randomFloat.bounds",
1512
- severity: "error",
1513
- message: "qti-random-float requires min to be less than or equal to max.",
1514
- path: expression.source?.path,
1515
- source: expression.source,
1516
- });
1517
- }
1518
- }
1519
-
1520
- function validateRandomFloatAttribute(
1521
- expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
1522
- attribute: "min" | "max",
1523
- diagnostics: QtiDiagnostic[],
1524
- ): void {
1525
- const value = expression.attributes[attribute];
1526
- if (value === undefined) {
1527
- diagnostics.push({
1528
- code: "processing.randomFloat.attribute",
1529
- severity: "error",
1530
- message: `qti-random-float requires ${attribute}.`,
1531
- path: expression.source?.path,
1532
- source: expression.source,
1533
- });
1534
- return;
1535
- }
1536
- if (isFiniteNumber(value)) return;
1537
- diagnostics.push({
1538
- code: "processing.randomFloat.numeric",
1539
- severity: "error",
1540
- message: `qti-random-float requires numeric ${attribute}, got ${value}.`,
1541
- path: expression.source?.path,
1542
- source: expression.source,
1543
- });
1544
- }
1545
-
1546
- function validateProcessingIdentifier(
1547
- identifier: string,
1548
- code: string,
1549
- source: QtiDiagnostic["source"],
1550
- diagnostics: QtiDiagnostic[],
1551
- ): void {
1552
- if (identifier.trim().length > 0) return;
1553
- diagnostics.push({
1554
- code,
1555
- severity: "error",
1556
- message: "Processing rule requires a non-empty identifier.",
1557
- path: source?.path,
1558
- source,
1559
- });
1560
- }
1561
-
1562
- function expressionChildren(expression: QtiProcessingExpression): QtiProcessingExpression[] {
1563
- if (expression.type === "random") {
1564
- return expression.values;
1565
- }
1566
- if (
1567
- expression.type === "multiple" ||
1568
- expression.type === "ordered" ||
1569
- expression.type === "sum" ||
1570
- expression.type === "product" ||
1571
- expression.type === "min" ||
1572
- expression.type === "max" ||
1573
- expression.type === "and" ||
1574
- expression.type === "anyN" ||
1575
- expression.type === "or" ||
1576
- expression.type === "gcd" ||
1577
- expression.type === "lcm" ||
1578
- expression.type === "mathOperator" ||
1579
- expression.type === "repeat" ||
1580
- expression.type === "customOperator"
1581
- ) {
1582
- return expression.expressions;
1583
- }
1584
- if (
1585
- expression.type === "subtract" ||
1586
- expression.type === "divide" ||
1587
- expression.type === "power" ||
1588
- expression.type === "integerDivide" ||
1589
- expression.type === "integerModulus" ||
1590
- expression.type === "match" ||
1591
- expression.type === "equal" ||
1592
- expression.type === "equalRounded" ||
1593
- expression.type === "numericCompare" ||
1594
- expression.type === "durationCompare"
1595
- ) {
1596
- return [expression.left, expression.right];
1597
- }
1598
- if (
1599
- expression.type === "not" ||
1600
- expression.type === "round" ||
1601
- expression.type === "roundTo" ||
1602
- expression.type === "truncate" ||
1603
- expression.type === "integerToFloat" ||
1604
- expression.type === "index" ||
1605
- expression.type === "containerSize" ||
1606
- expression.type === "patternMatch" ||
1607
- expression.type === "fieldValue" ||
1608
- expression.type === "inside"
1609
- ) {
1610
- return [expression.expression];
1611
- }
1612
- if (expression.type === "stringMatch" || expression.type === "substring") {
1613
- return [expression.left, expression.right];
1614
- }
1615
- if (expression.type === "member") {
1616
- return [expression.value, expression.collection];
1617
- }
1618
- if (expression.type === "delete") {
1619
- return [expression.value, expression.collection];
1620
- }
1621
- if (expression.type === "contains") {
1622
- return [expression.collection, expression.values];
1623
- }
1624
- if (expression.type === "statsOperator") {
1625
- return [expression.expression];
1626
- }
1627
- return [];
1628
- }
1629
-
1630
- function validateModalFeedback(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
1631
- const outcomeIdentifiers = new Set(
1632
- item.outcomeDeclarations.map((declaration) => declaration.identifier),
1633
- );
1634
- const seen = new Set<string>();
1635
- for (const feedback of item.modalFeedback) {
1636
- requireIdentifier("qti-modal-feedback", feedback.identifier, diagnostics, feedback.source);
1637
- const key = `${feedback.outcomeIdentifier}\n${feedback.identifier}`;
1638
- if (seen.has(key)) {
1639
- diagnostics.push({
1640
- code: "feedback.identifier.duplicate",
1641
- severity: "error",
1642
- message: `Duplicate modal feedback ${feedback.identifier} for outcome ${feedback.outcomeIdentifier}.`,
1643
- path: feedback.source?.path,
1644
- source: feedback.source,
1645
- });
1646
- }
1647
- seen.add(key);
1648
- if (!outcomeIdentifiers.has(feedback.outcomeIdentifier)) {
1649
- diagnostics.push({
1650
- code: "feedback.outcomeIdentifier.reference",
1651
- severity: "error",
1652
- message: `qti-modal-feedback ${feedback.identifier} references missing outcome declaration ${feedback.outcomeIdentifier}.`,
1653
- path: feedback.source?.path,
1654
- source: feedback.source,
1655
- });
1656
- }
1657
- }
1658
- }
1659
-
1660
- function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
1661
- const responseDeclarations = new Map(
1662
- item.responseDeclarations.map((declaration) => [declaration.identifier, declaration]),
1663
- );
1664
- const responseIdentifiers = new Set(responseDeclarations.keys());
1665
- for (const interaction of item.interactions) {
1666
- validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics);
1667
- validateInteractionResponseShape(interaction, diagnostics);
1668
- validateInteractionSharedVocabulary(interaction, diagnostics);
1669
- validateInteractionChoices(interaction, diagnostics);
1670
- validateInteractionChildren(interaction, diagnostics);
1671
- validateInteractionRequiredAttributes(interaction, diagnostics);
1672
- validatePortableCustomInteraction(interaction, item, diagnostics);
1673
- validateInteractionLimitAttributes(interaction, diagnostics);
1674
- validatePatternMaskAttribute(interaction, diagnostics);
1675
- validateGraphicHotspotObjectDimensions(interaction, diagnostics);
1676
- validateCorrectResponseReferences(
1677
- interaction,
1678
- interaction.responseIdentifier
1679
- ? responseDeclarations.get(interaction.responseIdentifier)
1680
- : undefined,
1681
- diagnostics,
1682
- );
1683
- validateMappingReferences(
1684
- interaction,
1685
- interaction.responseIdentifier
1686
- ? responseDeclarations.get(interaction.responseIdentifier)
1687
- : undefined,
1688
- diagnostics,
1689
- );
1690
- }
1691
- }
1692
-
1693
- function sharedClassNames(attributes: Record<string, string>): string[] {
1694
- return (attributes.class ?? "").split(/\s+/).filter(Boolean);
1695
- }
1696
-
1697
- function isLayoutColumnClassName(className: string): boolean {
1698
- return /^qti-layout-col-?\w+$/.test(className);
1699
- }
1700
-
1701
- function layoutColumnValue(className: string): number | undefined {
1702
- const rawValue = /^qti-layout-col-?(\d+)$/.exec(className)?.[1];
1703
- if (rawValue === undefined) return undefined;
1704
- const value = Number(rawValue);
1705
- if (value < 1 || value > 12) return undefined;
1706
- return value;
1707
- }
1708
-
1709
- function firstLayoutColumnValue(classNames: string[]): number | undefined {
1710
- for (const className of classNames) {
1711
- const value = layoutColumnValue(className);
1712
- if (value !== undefined) return value;
1713
- }
1714
- return undefined;
1715
- }
1716
-
1717
- function isLayoutOffsetClassName(className: string): boolean {
1718
- return /^qti-layout-offset-?\w+$/.test(className);
1719
- }
1720
-
1721
- function layoutOffsetValue(className: string): number | undefined {
1722
- const rawValue = /^qti-layout-offset-?(\d+)$/.exec(className)?.[1];
1723
- if (rawValue === undefined) return undefined;
1724
- const value = Number(rawValue);
1725
- if (value < 1 || value > 11) return undefined;
1726
- return value;
1727
- }
1728
-
1729
- function firstLayoutOffsetValue(classNames: string[]): number | undefined {
1730
- for (const className of classNames) {
1731
- const value = layoutOffsetValue(className);
1732
- if (value !== undefined) return value;
1733
- }
1734
- return undefined;
1735
- }
1736
-
1737
- function validateInteractionResponseReference(
1738
- interaction: QtiInteraction,
1739
- responseIdentifiers: Set<string>,
1740
- diagnostics: QtiDiagnostic[],
1741
- ): void {
1742
- if (!interaction.responseIdentifier) {
1743
- if (interaction.type !== "endAttempt") {
1744
- diagnostics.push({
1745
- code: "interaction.responseIdentifier",
1746
- severity: "error",
1747
- message: `${interaction.qtiName} is missing response-identifier.`,
1748
- path: interaction.source?.path,
1749
- source: interaction.source,
1750
- });
1751
- }
1752
- return;
1753
- }
1754
-
1755
- if (!responseIdentifiers.has(interaction.responseIdentifier)) {
1756
- diagnostics.push({
1757
- code: "interaction.responseIdentifier.reference",
1758
- severity: "error",
1759
- message: `${interaction.qtiName} references missing response declaration ${interaction.responseIdentifier}.`,
1760
- path: interaction.source?.path,
1761
- source: interaction.source,
1762
- });
1763
- }
1764
- }
1765
-
1766
- function validateInteractionResponseShape(
1767
- interaction: QtiInteraction,
1768
- diagnostics: QtiDiagnostic[],
1769
- ): void {
1770
- const expected = expectedResponseShape(interaction);
1771
- if (!expected) return;
1772
-
1773
- if (
1774
- interaction.responseCardinality &&
1775
- !expected.cardinalities.includes(interaction.responseCardinality)
1776
- ) {
1777
- diagnostics.push({
1778
- code: "interaction.cardinality",
1779
- severity: "error",
1780
- message: `${interaction.qtiName} expects ${expected.cardinalities.join(" or ")} cardinality, got ${interaction.responseCardinality}.`,
1781
- path: interaction.source?.path,
1782
- source: interaction.source,
1783
- });
1784
- }
1785
-
1786
- if (interaction.responseBaseType && !expected.baseTypes.includes(interaction.responseBaseType)) {
1787
- diagnostics.push({
1788
- code: "interaction.baseType",
1789
- severity: "error",
1790
- message: `${interaction.qtiName} expects ${expected.baseTypes.join(" or ")} base type, got ${interaction.responseBaseType}.`,
1791
- path: interaction.source?.path,
1792
- source: interaction.source,
1793
- });
1794
- }
1795
- }
1796
-
1797
- function validateInteractionChoices(
1798
- interaction: QtiInteraction,
1799
- diagnostics: QtiDiagnostic[],
1800
- ): void {
1801
- const identifiers = new Set<string>();
1802
- for (const choice of interaction.choices) {
1803
- requireIdentifier(choice.qtiName, choice.attributes.identifier, diagnostics, choice.source);
1804
- if (!choice.identifier) continue;
1805
- if (identifiers.has(choice.identifier)) {
1806
- diagnostics.push({
1807
- code: "choice.identifier.duplicate",
1808
- severity: "error",
1809
- message: `${interaction.qtiName} has duplicate choice identifier ${choice.identifier}.`,
1810
- path: choice.source?.path ?? interaction.source?.path,
1811
- source: choice.source ?? interaction.source,
1812
- });
1813
- }
1814
- identifiers.add(choice.identifier);
1815
- }
1816
-
1817
- if (
1818
- needsChoices(interaction) &&
1819
- interaction.choices.filter((choice) => choice.role !== "gap").length === 0
1820
- ) {
1821
- diagnostics.push({
1822
- code: "interaction.choices.required",
1823
- severity: "error",
1824
- message: `${interaction.qtiName} requires at least one choice.`,
1825
- path: interaction.source?.path,
1826
- source: interaction.source,
1827
- });
1828
- }
1829
-
1830
- for (const choice of interaction.choices) {
1831
- validateChoiceLimitAttributes(choice, diagnostics);
1832
- }
1833
- }
1834
-
1835
- function validateCorrectResponseReferences(
1836
- interaction: QtiInteraction,
1837
- declaration: QtiResponseDeclaration | undefined,
1838
- diagnostics: QtiDiagnostic[],
1839
- ): void {
1840
- if (!declaration || declaration.correctResponse === null) return;
1841
- if (
1842
- declaration.baseType !== "identifier" &&
1843
- declaration.baseType !== "pair" &&
1844
- declaration.baseType !== "directedPair"
1845
- ) {
1846
- return;
1847
- }
1848
-
1849
- const identifiers = new Set(
1850
- interaction.choices
1851
- .map((choice) => choice.identifier)
1852
- .filter((identifier) => identifier.length > 0),
1853
- );
1854
- if (identifiers.size === 0) return;
1855
-
1856
- for (const value of responseValues(declaration.correctResponse)) {
1857
- if (declaration.baseType === "identifier") {
1858
- if (identifiers.has(value)) continue;
1859
- invalidCorrectResponseReference(interaction, declaration, value, diagnostics);
1860
- continue;
1861
- }
1862
-
1863
- const parts = value.trim().split(/\s+/);
1864
- if (parts.length !== 2 || parts.some((part) => !identifiers.has(part))) {
1865
- invalidCorrectResponseReference(interaction, declaration, value, diagnostics);
1866
- }
1867
- }
1868
- }
1869
-
1870
- function responseValues(value: QtiResponseDeclaration["correctResponse"]): string[] {
1871
- return qtiValueToStringList(value);
1872
- }
1873
-
1874
- function invalidCorrectResponseReference(
1875
- interaction: QtiInteraction,
1876
- declaration: QtiResponseDeclaration,
1877
- value: string,
1878
- diagnostics: QtiDiagnostic[],
1879
- ): void {
1880
- diagnostics.push({
1881
- code: "response.correctResponse.reference",
1882
- severity: "error",
1883
- message: `Response declaration ${declaration.identifier} correct response ${value} does not reference choices in ${interaction.qtiName}.`,
1884
- path: declaration.source?.path,
1885
- source: declaration.source,
1886
- });
1887
- }
1888
-
1889
- function validateMappingReferences(
1890
- interaction: QtiInteraction,
1891
- declaration: QtiResponseDeclaration | undefined,
1892
- diagnostics: QtiDiagnostic[],
1893
- ): void {
1894
- if (!declaration?.mapping) return;
1895
- if (
1896
- declaration.baseType !== "identifier" &&
1897
- declaration.baseType !== "pair" &&
1898
- declaration.baseType !== "directedPair"
1899
- ) {
1900
- return;
1901
- }
1902
-
1903
- const identifiers = new Set(
1904
- interaction.choices
1905
- .map((choice) => choice.identifier)
1906
- .filter((identifier) => identifier.length > 0),
1907
- );
1908
- if (identifiers.size === 0) return;
1909
-
1910
- for (const entry of declaration.mapping.entries) {
1911
- const mapKey = entry.mapKey;
1912
- if (!mapKey) continue;
1913
- if (declaration.baseType === "identifier") {
1914
- if (identifiers.has(mapKey)) continue;
1915
- invalidMappingReference(interaction, declaration, entry, diagnostics);
1916
- continue;
1917
- }
1918
-
1919
- const parts = mapKey.trim().split(/\s+/);
1920
- if (parts.length !== 2 || parts.some((part) => !identifiers.has(part))) {
1921
- invalidMappingReference(interaction, declaration, entry, diagnostics);
1922
- }
1923
- }
1924
- }
1925
-
1926
- function invalidMappingReference(
1927
- interaction: QtiInteraction,
1928
- declaration: QtiResponseDeclaration,
1929
- entry: NonNullable<QtiResponseDeclaration["mapping"]>["entries"][number],
1930
- diagnostics: QtiDiagnostic[],
1931
- ): void {
1932
- diagnostics.push({
1933
- code: "mapping.mapKey.reference",
1934
- severity: "error",
1935
- message: `Response declaration ${declaration.identifier} map-key ${entry.mapKey ?? ""} does not reference choices in ${interaction.qtiName}.`,
1936
- path: entry.source?.path ?? declaration.source?.path,
1937
- source: entry.source ?? declaration.source,
1938
- });
1939
- }
1940
-
1941
- function validateInteractionChildren(
1942
- interaction: QtiInteraction,
1943
- diagnostics: QtiDiagnostic[],
1944
- ): void {
1945
- const allowed = allowedInteractionChildren(interaction);
1946
- if (!allowed) return;
1947
-
1948
- for (const child of interaction.childElements) {
1949
- if (allowed.has(child.qtiName)) continue;
1950
- diagnostics.push({
1951
- code: "interaction.child.unsupported",
1952
- severity: "error",
1953
- message: `${interaction.qtiName} does not allow ${child.qtiName} as a direct child.`,
1954
- path: child.source?.path ?? interaction.source?.path,
1955
- source: child.source ?? interaction.source,
1956
- });
1957
- }
1958
- }
1959
-
1960
- function validateInteractionRequiredAttributes(
1961
- interaction: QtiInteraction,
1962
- diagnostics: QtiDiagnostic[],
1963
- ): void {
1964
- if (requiresObject(interaction) && !hasRequiredObjectAsset(interaction)) {
1965
- diagnostics.push({
1966
- code: "interaction.object.required",
1967
- severity: "error",
1968
- message:
1969
- interaction.type === "drawing"
1970
- ? `${interaction.qtiName} requires an object, img, or picture canvas with a data/src attribute.`
1971
- : `${interaction.qtiName} requires an object, img, audio, or video child with a data/src attribute or media sources.`,
1972
- path: interaction.source?.path,
1973
- source: interaction.source,
1974
- });
1975
- }
1976
-
1977
- if (interaction.type === "portableCustom") {
1978
- requireInteractionAttribute(
1979
- interaction,
1980
- "custom-interaction-type-identifier",
1981
- "interaction.portableCustom.typeIdentifier",
1982
- diagnostics,
1983
- );
1984
- }
1985
-
1986
- if (interaction.type === "slider") {
1987
- const lower = interaction.attributes["lower-bound"];
1988
- const upper = interaction.attributes["upper-bound"];
1989
- requireInteractionAttribute(
1990
- interaction,
1991
- "lower-bound",
1992
- "interaction.slider.lowerBound",
1993
- diagnostics,
1994
- );
1995
- requireInteractionAttribute(
1996
- interaction,
1997
- "upper-bound",
1998
- "interaction.slider.upperBound",
1999
- diagnostics,
2000
- );
2001
- if (lower !== undefined && !isFiniteNumber(lower)) {
2002
- invalidNumber(interaction, "lower-bound", lower, diagnostics);
2003
- }
2004
- if (upper !== undefined && !isFiniteNumber(upper)) {
2005
- invalidNumber(interaction, "upper-bound", upper, diagnostics);
2006
- }
2007
- if (
2008
- lower !== undefined &&
2009
- upper !== undefined &&
2010
- isFiniteNumber(lower) &&
2011
- isFiniteNumber(upper)
2012
- ) {
2013
- if (Number(lower) >= Number(upper)) {
2014
- diagnostics.push({
2015
- code: "interaction.slider.bounds",
2016
- severity: "error",
2017
- message: `${interaction.qtiName} requires lower-bound to be less than upper-bound.`,
2018
- path: interaction.source?.path,
2019
- source: interaction.source,
2020
- });
2021
- }
2022
- }
2023
- const step = interaction.attributes.step;
2024
- if (step !== undefined && (!isFiniteNumber(step) || Number(step) <= 0)) {
2025
- invalidNumber(interaction, "step", step, diagnostics);
2026
- }
2027
- }
2028
- }
2029
-
2030
- function requiresObject(interaction: QtiInteraction): boolean {
2031
- return (
2032
- interaction.type === "graphicOrder" ||
2033
- interaction.type === "graphicAssociate" ||
2034
- interaction.type === "graphicGapMatch" ||
2035
- interaction.type === "hotspot" ||
2036
- interaction.type === "selectPoint" ||
2037
- interaction.type === "positionObject" ||
2038
- interaction.type === "media" ||
2039
- interaction.type === "drawing"
2040
- );
2041
- }
2042
-
2043
- function hasRequiredObjectAsset(interaction: QtiInteraction): boolean {
2044
- if (interaction.type === "media") {
2045
- return Boolean(
2046
- interaction.object?.data || interaction.object?.sources.some((source) => Boolean(source.src)),
2047
- );
2048
- }
2049
- if (interaction.type === "drawing") return Boolean(interaction.object?.data);
2050
- return Boolean(interaction.object?.data);
2051
- }
2052
-
2053
- function requireInteractionAttribute(
2054
- interaction: QtiInteraction,
2055
- attribute: string,
2056
- code: string,
2057
- diagnostics: QtiDiagnostic[],
2058
- ): void {
2059
- if (interaction.attributes[attribute]) return;
2060
- diagnostics.push({
2061
- code,
2062
- severity: "error",
2063
- message: `${interaction.qtiName} requires ${attribute}.`,
2064
- path: interaction.source?.path,
2065
- source: interaction.source,
2066
- });
2067
- }
2068
-
2069
- function validatePortableCustomInteraction(
2070
- interaction: QtiInteraction,
2071
- item: QtiAssessmentItem,
2072
- diagnostics: QtiDiagnostic[],
2073
- ): void {
2074
- if (interaction.type !== "portableCustom") return;
2075
- const definition = interaction.portableCustom;
2076
- if (!definition) return;
2077
-
2078
- const configuredModules = definition.interactionModules?.modules ?? [];
2079
- const hasModuleAttribute = Boolean(definition.module?.trim());
2080
- const hasConfiguredModule = configuredModules.some((module) => Boolean(module.id?.trim()));
2081
- if (!hasModuleAttribute && !hasConfiguredModule) {
2082
- diagnostics.push({
2083
- code: "interaction.portableCustom.module",
2084
- severity: "error",
2085
- message: `${interaction.qtiName} requires a module attribute or at least one qti-interaction-module id.`,
2086
- path: interaction.source?.path,
2087
- source: interaction.source,
2088
- });
2089
- }
2090
-
2091
- for (const module of configuredModules) {
2092
- if (!module.id?.trim()) {
2093
- diagnostics.push({
2094
- code: "interaction.portableCustom.moduleId",
2095
- severity: "error",
2096
- message: "qti-interaction-module requires a non-empty id.",
2097
- path: module.source?.path,
2098
- source: module.source,
2099
- });
2100
- }
2101
- warnExternalPortableCustomUrl(module.primaryPath, module.source, diagnostics);
2102
- warnExternalPortableCustomUrl(module.fallbackPath, module.source, diagnostics);
2103
- }
2104
-
2105
- warnExternalPortableCustomUrl(
2106
- definition.interactionModules?.primaryConfiguration,
2107
- definition.interactionModules?.source,
2108
- diagnostics,
2109
- );
2110
- warnExternalPortableCustomUrl(
2111
- definition.interactionModules?.secondaryConfiguration,
2112
- definition.interactionModules?.source,
2113
- diagnostics,
2114
- );
2115
-
2116
- const templateIdentifiers = new Set(
2117
- item.templateDeclarations.map((declaration) => declaration.identifier),
2118
- );
2119
- for (const variable of definition.templateVariables) {
2120
- if (!variable.identifier?.trim()) {
2121
- diagnostics.push({
2122
- code: "interaction.portableCustom.templateVariable",
2123
- severity: "error",
2124
- message: "qti-template-variable requires template-identifier or identifier.",
2125
- path: variable.source?.path,
2126
- source: variable.source,
2127
- });
2128
- continue;
2129
- }
2130
- if (!templateIdentifiers.has(variable.identifier)) {
2131
- diagnostics.push({
2132
- code: "interaction.portableCustom.templateVariable.reference",
2133
- severity: "error",
2134
- message: `qti-template-variable references missing template declaration ${variable.identifier}.`,
2135
- path: variable.source?.path,
2136
- source: variable.source,
2137
- });
2138
- }
2139
- }
2140
-
2141
- for (const variable of definition.contextVariables) {
2142
- if (variable.identifier?.trim()) continue;
2143
- diagnostics.push({
2144
- code: "interaction.portableCustom.contextVariable",
2145
- severity: "error",
2146
- message: "qti-context-variable requires identifier.",
2147
- path: variable.source?.path,
2148
- source: variable.source,
2149
- });
2150
- }
2151
-
2152
- for (const stylesheet of definition.stylesheets) {
2153
- if (stylesheet.href.trim().length > 0) continue;
2154
- diagnostics.push({
2155
- code: "stylesheet.href.required",
2156
- severity: "error",
2157
- message: "qti-stylesheet requires a non-empty href attribute.",
2158
- path: stylesheet.source?.path,
2159
- source: stylesheet.source,
2160
- });
2161
- }
2162
- }
2163
-
2164
- function warnExternalPortableCustomUrl(
2165
- url: string | undefined,
2166
- source: QtiDiagnostic["source"],
2167
- diagnostics: QtiDiagnostic[],
2168
- ): void {
2169
- if (!url || !/^https?:\/\//i.test(url)) return;
2170
- diagnostics.push({
2171
- code: "interaction.portableCustom.externalModuleUrl",
2172
- severity: "warning",
2173
- message: `Portable custom interaction module URL ${url} requires host delivery policy approval.`,
2174
- path: source?.path,
2175
- source,
2176
- });
2177
- }
2178
-
2179
- function invalidNumber(
2180
- interaction: QtiInteraction,
2181
- attribute: string,
2182
- value: string,
2183
- diagnostics: QtiDiagnostic[],
2184
- ): void {
2185
- diagnostics.push({
2186
- code: "interaction.numericAttribute",
2187
- severity: "error",
2188
- message: `${interaction.qtiName} requires numeric ${attribute}, got ${value}.`,
2189
- path: interaction.source?.path,
2190
- source: interaction.source,
2191
- });
2192
- }
2193
-
2194
- function validatePatternMaskAttribute(
2195
- interaction: QtiInteraction,
2196
- diagnostics: QtiDiagnostic[],
2197
- ): void {
2198
- if (interaction.type !== "textEntry" && interaction.type !== "extendedText") return;
2199
- const patternMask = interaction.attributes["pattern-mask"];
2200
- if (patternMask === undefined || isValidQtiPatternMask(patternMask)) return;
2201
- diagnostics.push({
2202
- code: "interaction.patternMask.invalid",
2203
- severity: "error",
2204
- message: `${interaction.qtiName} pattern-mask is not a valid regular expression.`,
2205
- path: interaction.source?.path,
2206
- source: interaction.source,
2207
- });
2208
- }
2209
-
2210
- function validateInteractionLimitAttributes(
2211
- interaction: QtiInteraction,
2212
- diagnostics: QtiDiagnostic[],
2213
- ): void {
2214
- validateNonNegativeIntegerAttribute(interaction, "max-choices", diagnostics);
2215
- validateNonNegativeIntegerAttribute(interaction, "min-choices", diagnostics);
2216
- validateNonNegativeIntegerAttribute(interaction, "max-associations", diagnostics);
2217
- validateNonNegativeIntegerAttribute(interaction, "min-associations", diagnostics);
2218
- validateNonNegativeIntegerAttribute(interaction, "expected-length", diagnostics);
2219
- validateNonNegativeIntegerAttribute(interaction, "expected-lines", diagnostics);
2220
-
2221
- validateMinMaxPair(interaction, "min-choices", "max-choices", diagnostics);
2222
- validateMinMaxPair(interaction, "min-associations", "max-associations", diagnostics);
2223
- if (interaction.type === "media") {
2224
- validateNonNegativeIntegerAttribute(interaction, "max-plays", diagnostics);
2225
- validateNonNegativeIntegerAttribute(interaction, "min-plays", diagnostics);
2226
- validateBooleanAttribute(interaction, "autostart", diagnostics);
2227
- validateBooleanAttribute(interaction, "loop", diagnostics);
2228
- validateMinMaxPair(interaction, "min-plays", "max-plays", diagnostics);
2229
- }
2230
- }
2231
-
2232
- function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
2233
- if (requiresMatchMax(choice) && !choice.attributes["match-max"]) {
2234
- diagnostics.push({
2235
- code: "choice.matchMax.required",
2236
- severity: "error",
2237
- message: `${choice.qtiName} ${choice.identifier} requires match-max.`,
2238
- path: choice.source?.path,
2239
- source: choice.source,
2240
- });
2241
- }
2242
-
2243
- validateChoiceNonNegativeIntegerAttribute(choice, "match-max", diagnostics);
2244
- validateChoiceNonNegativeIntegerAttribute(choice, "match-min", diagnostics);
2245
- validateChoiceMinMaxPair(choice, "match-min", "match-max", diagnostics);
2246
- validateGapImageAsset(choice, diagnostics);
2247
- validateHotspotGeometry(choice, diagnostics);
2248
- }
2249
-
2250
- function validateGapImageAsset(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
2251
- if (choice.qtiName !== "qti-gap-img" || choice.asset?.data) return;
2252
- diagnostics.push({
2253
- code: "choice.gapImg.media.required",
2254
- severity: "error",
2255
- message: `qti-gap-img ${choice.identifier} requires an img, object, or picture child with a usable src or data attribute.`,
2256
- path: choice.source?.path,
2257
- source: choice.source,
2258
- });
2259
- }
2260
-
2261
- function requiresMatchMax(choice: QtiChoice): boolean {
2262
- return (
2263
- choice.qtiName === "qti-simple-associable-choice" ||
2264
- choice.qtiName === "qti-associable-hotspot" ||
2265
- choice.qtiName === "qti-gap-text" ||
2266
- choice.qtiName === "qti-gap-img"
2267
- );
2268
- }
2269
-
2270
- function validateHotspotGeometry(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
2271
- if (choice.qtiName !== "qti-hotspot-choice" && choice.qtiName !== "qti-associable-hotspot") {
2272
- return;
2273
- }
2274
-
2275
- const shape = choice.attributes.shape;
2276
- const coords = choice.attributes.coords;
2277
- if (!shape) {
2278
- diagnostics.push({
2279
- code: "choice.shape.required",
2280
- severity: "error",
2281
- message: `${choice.qtiName} ${choice.identifier} requires shape.`,
2282
- path: choice.source?.path,
2283
- source: choice.source,
2284
- });
2285
- } else if (!isHotspotShape(shape)) {
2286
- diagnostics.push({
2287
- code: "choice.shape",
2288
- severity: "error",
2289
- message: `${choice.qtiName} ${choice.identifier} has unsupported shape ${shape}.`,
2290
- path: choice.source?.path,
2291
- source: choice.source,
2292
- });
2293
- }
2294
-
2295
- if (!coords) {
2296
- diagnostics.push({
2297
- code: "choice.coords.required",
2298
- severity: "error",
2299
- message: `${choice.qtiName} ${choice.identifier} requires coords.`,
2300
- path: choice.source?.path,
2301
- source: choice.source,
2302
- });
2303
- return;
2304
- }
2305
-
2306
- const values = coords.split(",").map((value) => value.trim());
2307
- if (values.length === 0 || values.some((value) => value.length === 0 || !isFiniteNumber(value))) {
2308
- diagnostics.push({
2309
- code: "choice.coords",
2310
- severity: "error",
2311
- message: `${choice.qtiName} ${choice.identifier} requires comma-separated numeric coords.`,
2312
- path: choice.source?.path,
2313
- source: choice.source,
2314
- });
2315
- return;
2316
- }
2317
-
2318
- if (shape && isHotspotShape(shape) && !hasValidShapeCoordinateCount(shape, values.map(Number))) {
2319
- diagnostics.push({
2320
- code: "choice.coords.shape",
2321
- severity: "error",
2322
- message: `${choice.qtiName} ${choice.identifier} shape ${shape} has invalid coords arity.`,
2323
- path: choice.source?.path,
2324
- source: choice.source,
2325
- });
2326
- }
2327
- }
2328
-
2329
- function validateGraphicHotspotObjectDimensions(
2330
- interaction: QtiInteraction,
2331
- diagnostics: QtiDiagnostic[],
2332
- ): void {
2333
- if (!usesGraphicHotspots(interaction)) return;
2334
- const hotspotChoices = interaction.choices.filter(isHotspotChoice);
2335
- if (hotspotChoices.length === 0) return;
2336
-
2337
- const width = positiveDimension(interaction.object?.width);
2338
- const height = positiveDimension(interaction.object?.height);
2339
- if (width === undefined || height === undefined) {
2340
- diagnostics.push({
2341
- code: "interaction.graphicObjectDimensions",
2342
- severity: "warning",
2343
- message: `${interaction.qtiName} should declare object width and height so hotspot coords map to the rendered image.`,
2344
- path: interaction.object?.source?.path ?? interaction.source?.path,
2345
- source: interaction.object?.source ?? interaction.source,
2346
- });
2347
- return;
2348
- }
2349
-
2350
- for (const choice of hotspotChoices) {
2351
- const bounds = hotspotBounds(choice);
2352
- if (!bounds) continue;
2353
- if (bounds.left >= 0 && bounds.top >= 0 && bounds.right <= width && bounds.bottom <= height) {
2354
- continue;
2355
- }
2356
- diagnostics.push({
2357
- code: "choice.coords.bounds",
2358
- severity: "warning",
2359
- message: `${choice.qtiName} ${choice.identifier} coords extend outside the ${width} by ${height} object image.`,
2360
- path: choice.source?.path,
2361
- source: choice.source,
2362
- });
2363
- }
2364
- }
2365
-
2366
- function usesGraphicHotspots(interaction: QtiInteraction): boolean {
2367
- return (
2368
- interaction.type === "graphicOrder" ||
2369
- interaction.type === "graphicAssociate" ||
2370
- interaction.type === "graphicGapMatch" ||
2371
- interaction.type === "hotspot"
2372
- );
2373
- }
2374
-
2375
- function isHotspotChoice(choice: QtiChoice): boolean {
2376
- return choice.qtiName === "qti-hotspot-choice" || choice.qtiName === "qti-associable-hotspot";
2377
- }
2378
-
2379
- function positiveDimension(value: string | undefined): number | undefined {
2380
- if (!value || value.trim().endsWith("%")) return undefined;
2381
- const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
2382
- if (!match?.[1]) return undefined;
2383
- const parsed = Number(match[1]);
2384
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
2385
- }
2386
-
2387
- function hotspotBounds(
2388
- choice: QtiChoice,
2389
- ): { left: number; top: number; right: number; bottom: number } | undefined {
2390
- const shape = choice.attributes.shape;
2391
- const coords = choice.attributes.coords;
2392
- if (!shape || !coords || !isHotspotShape(shape) || !isNumericCsv(coords)) return undefined;
2393
- const values = numericCsv(coords);
2394
- if (!hasValidShapeCoordinateCount(shape, values)) return undefined;
2395
-
2396
- if (shape === "default") return undefined;
2397
- if (shape === "circle") {
2398
- const [x, y, radius] = values as [number, number, number];
2399
- return { left: x - radius, top: y - radius, right: x + radius, bottom: y + radius };
2400
- }
2401
- if (shape === "ellipse") {
2402
- const [x, y, radiusX, radiusY] = values as [number, number, number, number];
2403
- return { left: x - radiusX, top: y - radiusY, right: x + radiusX, bottom: y + radiusY };
2404
- }
2405
- if (shape === "rect") {
2406
- const [left, top, right, bottom] = values as [number, number, number, number];
2407
- return { left, top, right, bottom };
2408
- }
2409
-
2410
- const xs = values.filter((_, index) => index % 2 === 0);
2411
- const ys = values.filter((_, index) => index % 2 === 1);
2412
- return {
2413
- left: Math.min(...xs),
2414
- top: Math.min(...ys),
2415
- right: Math.max(...xs),
2416
- bottom: Math.max(...ys),
2417
- };
2418
- }
2419
-
2420
- function isHotspotShape(value: string): boolean {
2421
- return (
2422
- value === "circle" ||
2423
- value === "default" ||
2424
- value === "ellipse" ||
2425
- value === "poly" ||
2426
- value === "rect"
2427
- );
2428
- }
2429
-
2430
- function isAreaShape(value: string): boolean {
2431
- return value === "circle" || value === "default" || value === "poly" || value === "rect";
2432
- }
2433
-
2434
- function isNumericCsv(value: string): boolean {
2435
- return value
2436
- .split(",")
2437
- .map((part) => part.trim())
2438
- .every((part) => part.length > 0 && isFiniteNumber(part));
2439
- }
2440
-
2441
- function numericCsv(value: string): number[] {
2442
- return value.split(",").map((part) => Number(part.trim()));
2443
- }
2444
-
2445
- function hasValidShapeCoordinateCount(shape: string, coords: number[]): boolean {
2446
- switch (shape) {
2447
- case "circle":
2448
- return coords.length === 3;
2449
- case "ellipse":
2450
- case "rect":
2451
- return coords.length === 4;
2452
- case "poly":
2453
- return coords.length >= 6 && coords.length % 2 === 0;
2454
- case "default":
2455
- return true;
2456
- default:
2457
- return false;
2458
- }
2459
- }
2460
-
2461
- function validateNonNegativeIntegerAttribute(
2462
- interaction: QtiInteraction,
2463
- attribute: string,
2464
- diagnostics: QtiDiagnostic[],
2465
- ): void {
2466
- const value = interaction.attributes[attribute];
2467
- if (value === undefined || isNonNegativeInteger(value)) return;
2468
- diagnostics.push({
2469
- code: "interaction.integerAttribute",
2470
- severity: "error",
2471
- message: `${interaction.qtiName} requires non-negative integer ${attribute}, got ${value}.`,
2472
- path: interaction.source?.path,
2473
- source: interaction.source,
2474
- });
2475
- }
2476
-
2477
- function validateBooleanAttribute(
2478
- interaction: QtiInteraction,
2479
- attribute: string,
2480
- diagnostics: QtiDiagnostic[],
2481
- ): void {
2482
- const value = interaction.attributes[attribute];
2483
- if (value === undefined || isBooleanAttribute(value)) return;
2484
- diagnostics.push({
2485
- code: "interaction.booleanAttribute",
2486
- severity: "error",
2487
- message: `${interaction.qtiName} requires boolean ${attribute}, got ${value}.`,
2488
- path: interaction.source?.path,
2489
- source: interaction.source,
2490
- });
2491
- }
2492
-
2493
- function validateChoiceNonNegativeIntegerAttribute(
2494
- choice: QtiChoice,
2495
- attribute: string,
2496
- diagnostics: QtiDiagnostic[],
2497
- ): void {
2498
- const value = choice.attributes[attribute];
2499
- if (value === undefined || isNonNegativeInteger(value)) return;
2500
- diagnostics.push({
2501
- code: "choice.integerAttribute",
2502
- severity: "error",
2503
- message: `${choice.qtiName} ${choice.identifier} requires non-negative integer ${attribute}, got ${value}.`,
2504
- path: choice.source?.path,
2505
- source: choice.source,
2506
- });
2507
- }
2508
-
2509
- function validateChoiceMinMaxPair(
2510
- choice: QtiChoice,
2511
- minAttribute: string,
2512
- maxAttribute: string,
2513
- diagnostics: QtiDiagnostic[],
2514
- ): void {
2515
- const min = choice.attributes[minAttribute];
2516
- const max = choice.attributes[maxAttribute];
2517
- if (
2518
- min === undefined ||
2519
- max === undefined ||
2520
- !isNonNegativeInteger(min) ||
2521
- !isNonNegativeInteger(max) ||
2522
- max === "0"
2523
- ) {
2524
- return;
2525
- }
2526
- if (Number(min) <= Number(max)) return;
2527
- diagnostics.push({
2528
- code: "choice.minMax",
2529
- severity: "error",
2530
- message: `${choice.qtiName} ${choice.identifier} requires ${minAttribute} to be less than or equal to ${maxAttribute}, unless ${maxAttribute} is 0 for unlimited.`,
2531
- path: choice.source?.path,
2532
- source: choice.source,
2533
- });
2534
- }
2535
-
2536
- function validateMinMaxPair(
2537
- interaction: QtiInteraction,
2538
- minAttribute: string,
2539
- maxAttribute: string,
2540
- diagnostics: QtiDiagnostic[],
2541
- ): void {
2542
- const min = interaction.attributes[minAttribute];
2543
- const max = interaction.attributes[maxAttribute];
2544
- if (
2545
- min === undefined ||
2546
- max === undefined ||
2547
- !isNonNegativeInteger(min) ||
2548
- !isNonNegativeInteger(max) ||
2549
- max === "0"
2550
- ) {
2551
- return;
2552
- }
2553
- if (Number(min) <= Number(max)) return;
2554
- diagnostics.push({
2555
- code: "interaction.minMax",
2556
- severity: "error",
2557
- message: `${interaction.qtiName} requires ${minAttribute} to be less than or equal to ${maxAttribute}, unless ${maxAttribute} is 0 for unlimited.`,
2558
- path: interaction.source?.path,
2559
- source: interaction.source,
2560
- });
2561
- }
2562
-
2563
- function allowedInteractionChildren(interaction: QtiInteraction): Set<string> | undefined {
2564
- const common = ["qti-prompt"];
2565
- switch (interaction.type) {
2566
- case "choice":
2567
- return setOf(common, ["qti-simple-choice"]);
2568
- case "order":
2569
- return setOf(common, ["qti-simple-choice"]);
2570
- case "associate":
2571
- return setOf(common, ["qti-simple-match-set", "qti-simple-associable-choice"]);
2572
- case "match":
2573
- return setOf(common, ["qti-simple-match-set"]);
2574
- case "gapMatch":
2575
- return setOf(common, ["qti-gap-text", "qti-gap-img", ...staticContentNames()]);
2576
- case "inlineChoice":
2577
- return setOf(common, ["qti-inline-choice"]);
2578
- case "hottext":
2579
- return setOf(common, staticContentNames());
2580
- case "graphicOrder":
2581
- return setOf(common, ["object", "qti-hotspot-choice"]);
2582
- case "graphicAssociate":
2583
- return setOf(common, ["object", "qti-associable-hotspot"]);
2584
- case "graphicGapMatch":
2585
- return setOf(common, [
2586
- "object",
2587
- "qti-gap-text",
2588
- "qti-gap-img",
2589
- "qti-associable-hotspot",
2590
- ...staticContentNames(),
2591
- ]);
2592
- case "hotspot":
2593
- return setOf(common, ["object", "qti-hotspot-choice"]);
2594
- case "positionObject":
2595
- return setOf(common, ["object", "img", "qti-position-object-stage"]);
2596
- case "selectPoint":
2597
- case "media":
2598
- return setOf(common, ["audio", "video", "object", "img"]);
2599
- case "drawing":
2600
- return setOf(common, ["object", "img", "picture"]);
2601
- case "extendedText":
2602
- return new Set(common);
2603
- case "portableCustom":
2604
- return setOf(common, [
2605
- "qti-interaction-markup",
2606
- "qti-interaction-modules",
2607
- "qti-template-variable",
2608
- "qti-context-variable",
2609
- "qti-stylesheet",
2610
- "qti-catalog-info",
2611
- ]);
2612
- case "slider":
2613
- case "textEntry":
2614
- case "upload":
2615
- case "endAttempt":
2616
- return new Set(common);
2617
- case "custom":
2618
- return undefined;
2619
- }
2620
- }
2621
-
2622
- function staticContentNames(): string[] {
2623
- return ["p", "div", "span", "ul", "ol", "li", "table", "tbody", "thead", "tr", "td", "th"];
2624
- }
2625
-
2626
- function setOf(...items: string[][]): Set<string> {
2627
- return new Set(items.flat());
2628
- }
2629
-
2630
- function isCardinality(value: string): value is QtiCardinality {
2631
- return value === "single" || value === "multiple" || value === "ordered" || value === "record";
2632
- }
2633
-
2634
- function isBaseType(value: string): value is QtiBaseType {
2635
- return (
2636
- value === "identifier" ||
2637
- value === "boolean" ||
2638
- value === "integer" ||
2639
- value === "float" ||
2640
- value === "string" ||
2641
- value === "point" ||
2642
- value === "pair" ||
2643
- value === "directedPair" ||
2644
- value === "duration" ||
2645
- value === "file" ||
2646
- value === "uri"
2647
- );
2648
- }
2649
-
2650
- function isFiniteNumber(value: string): boolean {
2651
- return Number.isFinite(Number(value));
2652
- }
2653
-
2654
- function isInteger(value: string): boolean {
2655
- return /^-?\d+$/.test(value);
2656
- }
2657
-
2658
- function isNonNegativeInteger(value: string): boolean {
2659
- return /^\d+$/.test(value);
2660
- }
2661
-
2662
- function isBooleanAttribute(value: string): boolean {
2663
- return value === "true" || value === "false" || value === "1" || value === "0";
2664
- }
2665
-
2666
- function isPoint(value: string): boolean {
2667
- const parts = value.trim().split(/\s+/);
2668
- return parts.length === 2 && parts.every(isFiniteNumber);
2669
- }
2670
-
2671
- function isPair(value: string): boolean {
2672
- const parts = value.trim().split(/\s+/);
2673
- return parts.length === 2 && parts.every((part) => part.length > 0);
2674
- }
2675
-
2676
- function expectedResponseShape(
2677
- interaction: QtiInteraction,
2678
- ): { cardinalities: QtiCardinality[]; baseTypes: QtiBaseType[] } | undefined {
2679
- if (interaction.type === "endAttempt") {
2680
- return { cardinalities: ["single"], baseTypes: ["boolean"] };
2681
- }
2682
- if (interaction.type === "media") return { cardinalities: ["single"], baseTypes: ["integer"] };
2683
- if (interaction.type === "custom") return undefined;
2684
- if (interaction.type === "order" || interaction.type === "graphicOrder") {
2685
- return { cardinalities: ["ordered"], baseTypes: ["identifier"] };
2686
- }
2687
- if (interaction.type === "associate" || interaction.type === "graphicAssociate") {
2688
- return { cardinalities: ["multiple"], baseTypes: ["pair", "directedPair"] };
2689
- }
2690
- if (
2691
- interaction.type === "match" ||
2692
- interaction.type === "gapMatch" ||
2693
- interaction.type === "graphicGapMatch"
2694
- ) {
2695
- return { cardinalities: ["multiple"], baseTypes: ["directedPair"] };
2696
- }
2697
- if (interaction.type === "selectPoint" || interaction.type === "positionObject") {
2698
- return { cardinalities: ["single", "multiple"], baseTypes: ["point"] };
2699
- }
2700
- if (interaction.type === "slider") {
2701
- return { cardinalities: ["single"], baseTypes: ["integer", "float"] };
2702
- }
2703
- if (interaction.type === "upload") {
2704
- return { cardinalities: ["single"], baseTypes: ["file"] };
2705
- }
2706
- if (interaction.type === "textEntry" || interaction.type === "extendedText") {
2707
- return { cardinalities: ["single"], baseTypes: ["string"] };
2708
- }
2709
- if (interaction.type === "drawing") return { cardinalities: ["single"], baseTypes: ["file"] };
2710
- if (interaction.type === "portableCustom") {
2711
- return {
2712
- cardinalities: ["single", "multiple", "ordered", "record"],
2713
- baseTypes: [
2714
- "identifier",
2715
- "boolean",
2716
- "integer",
2717
- "float",
2718
- "string",
2719
- "point",
2720
- "pair",
2721
- "directedPair",
2722
- "duration",
2723
- "file",
2724
- "uri",
2725
- ],
2726
- };
2727
- }
2728
- return { cardinalities: ["single", "multiple"], baseTypes: ["identifier"] };
2729
- }
2730
-
2731
- function needsChoices(interaction: QtiInteraction): boolean {
2732
- return (
2733
- interaction.type === "choice" ||
2734
- interaction.type === "order" ||
2735
- interaction.type === "associate" ||
2736
- interaction.type === "match" ||
2737
- interaction.type === "gapMatch" ||
2738
- interaction.type === "inlineChoice" ||
2739
- interaction.type === "hottext" ||
2740
- interaction.type === "graphicOrder" ||
2741
- interaction.type === "graphicAssociate" ||
2742
- interaction.type === "graphicGapMatch" ||
2743
- interaction.type === "hotspot"
2744
- );
2745
712
  }