@longsightgroup/qti3-core 0.10.6 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/README.md +126 -0
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/binary-choice.d.ts +9 -0
  11. package/dist/binary-choice.d.ts.map +1 -0
  12. package/dist/binary-choice.js +81 -0
  13. package/dist/binary-choice.js.map +1 -0
  14. package/dist/diagnostics.d.ts +13 -0
  15. package/dist/diagnostics.d.ts.map +1 -0
  16. package/dist/diagnostics.js +28 -0
  17. package/dist/diagnostics.js.map +1 -0
  18. package/dist/index.d.ts +19 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +12 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/item-submission-materialization.d.ts.map +1 -1
  23. package/dist/item-submission-materialization.js +2 -0
  24. package/dist/item-submission-materialization.js.map +1 -1
  25. package/dist/parser-processing.d.ts.map +1 -1
  26. package/dist/parser-processing.js +8 -18
  27. package/dist/parser-processing.js.map +1 -1
  28. package/dist/parser-values.d.ts.map +1 -1
  29. package/dist/parser-values.js +1 -3
  30. package/dist/parser-values.js.map +1 -1
  31. package/dist/parser.js +32 -0
  32. package/dist/parser.js.map +1 -1
  33. package/dist/processing-equality.d.ts +10 -0
  34. package/dist/processing-equality.d.ts.map +1 -0
  35. package/dist/processing-equality.js +78 -0
  36. package/dist/processing-equality.js.map +1 -0
  37. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  38. package/dist/processing-evaluator-comparison.js +14 -9
  39. package/dist/processing-evaluator-comparison.js.map +1 -1
  40. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  41. package/dist/processing-evaluator-numeric.js +2 -1
  42. package/dist/processing-evaluator-numeric.js.map +1 -1
  43. package/dist/processing-evaluator-string.d.ts.map +1 -1
  44. package/dist/processing-evaluator-string.js +26 -10
  45. package/dist/processing-evaluator-string.js.map +1 -1
  46. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  47. package/dist/processing-evaluator-variable.js +9 -3
  48. package/dist/processing-evaluator-variable.js.map +1 -1
  49. package/dist/processing-evaluator.d.ts +5 -1
  50. package/dist/processing-evaluator.d.ts.map +1 -1
  51. package/dist/processing-evaluator.js +13 -4
  52. package/dist/processing-evaluator.js.map +1 -1
  53. package/dist/processing-expression-children.d.ts.map +1 -1
  54. package/dist/processing-expression-children.js +2 -3
  55. package/dist/processing-expression-children.js.map +1 -1
  56. package/dist/processing-expression-tags.d.ts +2 -1
  57. package/dist/processing-expression-tags.d.ts.map +1 -1
  58. package/dist/processing-expression-tags.js +2 -1
  59. package/dist/processing-expression-tags.js.map +1 -1
  60. package/dist/processing-mapping.d.ts.map +1 -1
  61. package/dist/processing-mapping.js +26 -16
  62. package/dist/processing-mapping.js.map +1 -1
  63. package/dist/processing-state.d.ts +2 -2
  64. package/dist/processing-state.d.ts.map +1 -1
  65. package/dist/processing-state.js +5 -2
  66. package/dist/processing-state.js.map +1 -1
  67. package/dist/processing-values.d.ts +1 -2
  68. package/dist/processing-values.d.ts.map +1 -1
  69. package/dist/processing-values.js +1 -3
  70. package/dist/processing-values.js.map +1 -1
  71. package/dist/processing-variables.d.ts.map +1 -1
  72. package/dist/processing-variables.js +10 -5
  73. package/dist/processing-variables.js.map +1 -1
  74. package/dist/qti-identifier.d.ts +3 -0
  75. package/dist/qti-identifier.d.ts.map +1 -0
  76. package/dist/qti-identifier.js +5 -0
  77. package/dist/qti-identifier.js.map +1 -0
  78. package/dist/qti-package-assets.d.ts.map +1 -1
  79. package/dist/qti-package-assets.js +16 -10
  80. package/dist/qti-package-assets.js.map +1 -1
  81. package/dist/qti-package-manifest.d.ts +1 -0
  82. package/dist/qti-package-manifest.d.ts.map +1 -1
  83. package/dist/qti-package-manifest.js +1 -0
  84. package/dist/qti-package-manifest.js.map +1 -1
  85. package/dist/qti-package-metadata.d.ts.map +1 -1
  86. package/dist/qti-package-metadata.js +11 -0
  87. package/dist/qti-package-metadata.js.map +1 -1
  88. package/dist/qti-package-paths.d.ts +2 -0
  89. package/dist/qti-package-paths.d.ts.map +1 -1
  90. package/dist/qti-package-paths.js +6 -0
  91. package/dist/qti-package-paths.js.map +1 -1
  92. package/dist/qti-package-plan.d.ts +1 -1
  93. package/dist/qti-package-plan.d.ts.map +1 -1
  94. package/dist/qti-package-types.d.ts +9 -0
  95. package/dist/qti-package-types.d.ts.map +1 -1
  96. package/dist/qti-package-xml.d.ts +6 -1
  97. package/dist/qti-package-xml.d.ts.map +1 -1
  98. package/dist/qti-package-xml.js +11 -6
  99. package/dist/qti-package-xml.js.map +1 -1
  100. package/dist/qti-package-zip.d.ts +13 -1
  101. package/dist/qti-package-zip.d.ts.map +1 -1
  102. package/dist/qti-package-zip.js +185 -54
  103. package/dist/qti-package-zip.js.map +1 -1
  104. package/dist/qti-package.d.ts +12 -5
  105. package/dist/qti-package.d.ts.map +1 -1
  106. package/dist/qti-package.js +63 -8
  107. package/dist/qti-package.js.map +1 -1
  108. package/dist/response-validation-policy.d.ts.map +1 -1
  109. package/dist/response-validation-policy.js +3 -1
  110. package/dist/response-validation-policy.js.map +1 -1
  111. package/dist/response-validation.d.ts.map +1 -1
  112. package/dist/response-validation.js +2 -1
  113. package/dist/response-validation.js.map +1 -1
  114. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  115. package/dist/serializer-processing-expressions.js +2 -10
  116. package/dist/serializer-processing-expressions.js.map +1 -1
  117. package/dist/server-scoring.d.ts.map +1 -1
  118. package/dist/server-scoring.js +1 -0
  119. package/dist/server-scoring.js.map +1 -1
  120. package/dist/session-builtins.d.ts +16 -0
  121. package/dist/session-builtins.d.ts.map +1 -0
  122. package/dist/session-builtins.js +58 -0
  123. package/dist/session-builtins.js.map +1 -0
  124. package/dist/session.d.ts +5 -1
  125. package/dist/session.d.ts.map +1 -1
  126. package/dist/session.js +63 -11
  127. package/dist/session.js.map +1 -1
  128. package/dist/support-evidence.d.ts.map +1 -1
  129. package/dist/support-evidence.js +2 -0
  130. package/dist/support-evidence.js.map +1 -1
  131. package/dist/support.d.ts.map +1 -1
  132. package/dist/support.js +66 -23
  133. package/dist/support.js.map +1 -1
  134. package/dist/test-expression-parser.d.ts +6 -0
  135. package/dist/test-expression-parser.d.ts.map +1 -0
  136. package/dist/test-expression-parser.js +76 -0
  137. package/dist/test-expression-parser.js.map +1 -0
  138. package/dist/test-expression-serializer.d.ts +5 -0
  139. package/dist/test-expression-serializer.d.ts.map +1 -0
  140. package/dist/test-expression-serializer.js +54 -0
  141. package/dist/test-expression-serializer.js.map +1 -0
  142. package/dist/test-expression-validation.d.ts +14 -0
  143. package/dist/test-expression-validation.d.ts.map +1 -0
  144. package/dist/test-expression-validation.js +101 -0
  145. package/dist/test-expression-validation.js.map +1 -0
  146. package/dist/test-expression.d.ts +99 -0
  147. package/dist/test-expression.d.ts.map +1 -0
  148. package/dist/test-expression.js +28 -0
  149. package/dist/test-expression.js.map +1 -0
  150. package/dist/test-model.d.ts +62 -0
  151. package/dist/test-model.d.ts.map +1 -0
  152. package/dist/test-model.js +5 -0
  153. package/dist/test-model.js.map +1 -0
  154. package/dist/test-parser.d.ts +13 -0
  155. package/dist/test-parser.d.ts.map +1 -0
  156. package/dist/test-parser.js +144 -0
  157. package/dist/test-parser.js.map +1 -0
  158. package/dist/test-processing.d.ts +22 -0
  159. package/dist/test-processing.d.ts.map +1 -0
  160. package/dist/test-processing.js +84 -0
  161. package/dist/test-processing.js.map +1 -0
  162. package/dist/test-session.d.ts +31 -0
  163. package/dist/test-session.d.ts.map +1 -0
  164. package/dist/test-session.js +102 -0
  165. package/dist/test-session.js.map +1 -0
  166. package/dist/test-support.d.ts +4 -0
  167. package/dist/test-support.d.ts.map +1 -0
  168. package/dist/test-support.js +30 -0
  169. package/dist/test-support.js.map +1 -0
  170. package/dist/test-validation.d.ts +4 -0
  171. package/dist/test-validation.d.ts.map +1 -0
  172. package/dist/test-validation.js +133 -0
  173. package/dist/test-validation.js.map +1 -0
  174. package/dist/test-xml.d.ts +7 -0
  175. package/dist/test-xml.d.ts.map +1 -0
  176. package/dist/test-xml.js +23 -0
  177. package/dist/test-xml.js.map +1 -0
  178. package/dist/trusted-item-session.d.ts +4 -1
  179. package/dist/trusted-item-session.d.ts.map +1 -1
  180. package/dist/trusted-item-session.js +8 -3
  181. package/dist/trusted-item-session.js.map +1 -1
  182. package/dist/types.d.ts +26 -4
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/validation-declaration-entries.d.ts.map +1 -1
  185. package/dist/validation-declaration-entries.js +11 -1
  186. package/dist/validation-declaration-entries.js.map +1 -1
  187. package/dist/validation-item-children.d.ts +4 -0
  188. package/dist/validation-item-children.d.ts.map +1 -0
  189. package/dist/validation-item-children.js +45 -0
  190. package/dist/validation-item-children.js.map +1 -0
  191. package/dist/validation-processing.d.ts.map +1 -1
  192. package/dist/validation-processing.js +45 -12
  193. package/dist/validation-processing.js.map +1 -1
  194. package/dist/validation.d.ts.map +1 -1
  195. package/dist/validation.js +12 -0
  196. package/dist/validation.js.map +1 -1
  197. package/dist/value-guards.d.ts +4 -0
  198. package/dist/value-guards.d.ts.map +1 -0
  199. package/dist/value-guards.js +5 -0
  200. package/dist/value-guards.js.map +1 -0
  201. package/dist/xml-schema-regex-data.d.ts +48 -0
  202. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  203. package/dist/xml-schema-regex-data.js +403 -0
  204. package/dist/xml-schema-regex-data.js.map +1 -0
  205. package/dist/xml-schema-regex-parser.d.ts +31 -0
  206. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  207. package/dist/xml-schema-regex-parser.js +245 -0
  208. package/dist/xml-schema-regex-parser.js.map +1 -0
  209. package/dist/xml-schema-regex.d.ts +14 -0
  210. package/dist/xml-schema-regex.d.ts.map +1 -0
  211. package/dist/xml-schema-regex.js +114 -0
  212. package/dist/xml-schema-regex.js.map +1 -0
  213. package/package.json +1 -1
  214. package/src/adaptive-turn-materializer.ts +4 -3
  215. package/src/adaptive-turn.ts +1 -0
  216. package/src/attempt-state.ts +54 -1
  217. package/src/binary-choice.ts +96 -0
  218. package/src/diagnostics.ts +29 -0
  219. package/src/index.ts +38 -0
  220. package/src/item-submission-materialization.ts +2 -0
  221. package/src/parser-processing.ts +15 -18
  222. package/src/parser-values.ts +1 -5
  223. package/src/parser.ts +34 -0
  224. package/src/processing-equality.ts +104 -0
  225. package/src/processing-evaluator-comparison.ts +19 -14
  226. package/src/processing-evaluator-numeric.ts +2 -6
  227. package/src/processing-evaluator-string.ts +25 -18
  228. package/src/processing-evaluator-variable.ts +8 -3
  229. package/src/processing-evaluator.ts +20 -7
  230. package/src/processing-expression-children.ts +2 -3
  231. package/src/processing-expression-tags.ts +2 -1
  232. package/src/processing-mapping.ts +31 -25
  233. package/src/processing-state.ts +6 -1
  234. package/src/processing-values.ts +2 -11
  235. package/src/processing-variables.ts +9 -7
  236. package/src/qti-identifier.ts +4 -0
  237. package/src/qti-package-assets.ts +20 -13
  238. package/src/qti-package-manifest.ts +1 -0
  239. package/src/qti-package-metadata.ts +11 -0
  240. package/src/qti-package-paths.ts +9 -0
  241. package/src/qti-package-plan.ts +1 -1
  242. package/src/qti-package-types.ts +10 -0
  243. package/src/qti-package-xml.ts +11 -6
  244. package/src/qti-package-zip.ts +330 -97
  245. package/src/qti-package.ts +106 -7
  246. package/src/response-validation-policy.ts +5 -1
  247. package/src/response-validation.ts +2 -1
  248. package/src/serializer-processing-expressions.ts +8 -25
  249. package/src/server-scoring.ts +1 -0
  250. package/src/session-builtins.ts +75 -0
  251. package/src/session.ts +64 -8
  252. package/src/support-evidence.ts +2 -0
  253. package/src/support.ts +86 -23
  254. package/src/test-expression-parser.ts +89 -0
  255. package/src/test-expression-serializer.ts +71 -0
  256. package/src/test-expression-validation.ts +139 -0
  257. package/src/test-expression.ts +66 -0
  258. package/src/test-model.ts +68 -0
  259. package/src/test-parser.ts +201 -0
  260. package/src/test-processing.ts +107 -0
  261. package/src/test-session.ts +160 -0
  262. package/src/test-support.ts +31 -0
  263. package/src/test-validation.ts +190 -0
  264. package/src/test-xml.ts +37 -0
  265. package/src/trusted-item-session.ts +13 -2
  266. package/src/types.ts +36 -4
  267. package/src/validation-declaration-entries.ts +11 -1
  268. package/src/validation-item-children.ts +48 -0
  269. package/src/validation-processing.ts +47 -10
  270. package/src/validation.ts +14 -0
  271. package/src/value-guards.ts +6 -0
  272. package/src/xml-schema-regex-data.ts +404 -0
  273. package/src/xml-schema-regex-parser.ts +234 -0
  274. package/src/xml-schema-regex.ts +115 -0
package/src/support.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  QtiProcessingElementSupport,
9
9
  QtiSourceLocation,
10
10
  } from "./types.js";
11
+ import { testExecutionSupport } from "./test-support.js";
11
12
  import {
12
13
  browserFeedbackTests,
13
14
  browserHarnessTests,
@@ -92,7 +93,11 @@ export const processingSupport: QtiProcessingElementSupport[] = [
92
93
  "packages/fixtures/xml/random-integer-template-reference.xml",
93
94
  "packages/fixtures/xml/template-content-reference.xml",
94
95
  ]),
95
- templateProcessingEntry("qti-set-default-value"),
96
+ {
97
+ ...templateProcessingEntry("qti-set-default-value"),
98
+ notes:
99
+ "Updates effective response/outcome defaults for the clone, including explicit NULL overrides; rejected template passes discard their overrides.",
100
+ },
96
101
  templateProcessingEntry("qti-set-correct-response", [
97
102
  "packages/fixtures/xml/template-processing-reference.xml",
98
103
  "packages/fixtures/xml/random-integer-template-reference.xml",
@@ -133,32 +138,65 @@ export const processingSupport: QtiProcessingElementSupport[] = [
133
138
  operatorProcessingEntry("qti-null"),
134
139
  mappingProcessingEntry("qti-match-table"),
135
140
  mappingProcessingEntry("qti-match-table-entry"),
136
- mappingProcessingEntry("qti-interpolation-table"),
141
+ {
142
+ ...mappingProcessingEntry("qti-interpolation-table"),
143
+ notes:
144
+ "Selects the first authored entry whose source-value is below the input, including equality only when include-boundary permits it.",
145
+ },
137
146
  mappingProcessingEntry("qti-interpolation-table-entry"),
138
- operatorProcessingEntry("qti-is-null"),
147
+ {
148
+ ...operatorProcessingEntry("qti-is-null"),
149
+ notes:
150
+ "Accepts one arbitrary expression, evaluates it in the full variable context, and tests the result for NULL.",
151
+ },
139
152
  mappingProcessingEntry("qti-match", [
140
153
  "packages/fixtures/xml/generic-match-processing-reference.xml",
141
154
  "packages/fixtures/xml/advanced-processing-reference.xml",
142
155
  ]),
143
156
  mappingProcessingEntry("qti-correct"),
144
- mappingProcessingEntry("qti-default"),
145
- mappingProcessingEntry("qti-map-response"),
146
- mappingProcessingEntry("qti-map-response-point"),
147
- responseProcessingEntry("qti-variable", [
148
- "packages/fixtures/xml/generic-match-processing-reference.xml",
149
- "packages/fixtures/xml/template-processing-reference.xml",
150
- "packages/fixtures/xml/random-integer-template-reference.xml",
151
- "packages/fixtures/xml/template-content-reference.xml",
152
- "packages/fixtures/xml/advanced-processing-reference.xml",
153
- "packages/fixtures/xml/adaptive-feedback-reference.xml",
154
- ]),
157
+ {
158
+ ...mappingProcessingEntry("qti-default", [], [processingTemplateTest]),
159
+ notes:
160
+ "Reads the effective authored or template-overridden default, independently of the variable's current value.",
161
+ },
162
+ {
163
+ ...mappingProcessingEntry("qti-map-response"),
164
+ notes:
165
+ "String map entries honor case-sensitive (default false); mapping counts each distinct response value once using QTI base-type equality, then applies bounds.",
166
+ },
167
+ {
168
+ ...mappingProcessingEntry("qti-map-response-point"),
169
+ notes:
170
+ "Each point selects the first matching authored area; each selected area contributes at most once.",
171
+ },
172
+ {
173
+ ...responseProcessingEntry("qti-variable", [
174
+ "packages/fixtures/xml/generic-match-processing-reference.xml",
175
+ "packages/fixtures/xml/template-processing-reference.xml",
176
+ "packages/fixtures/xml/random-integer-template-reference.xml",
177
+ "packages/fixtures/xml/template-content-reference.xml",
178
+ "packages/fixtures/xml/advanced-processing-reference.xml",
179
+ "packages/fixtures/xml/adaptive-feedback-reference.xml",
180
+ ]),
181
+ notes:
182
+ "Resolves declared variables and implicit completionStatus, numAttempts, timed-item duration (float seconds), and QTI_CONTEXT; session environment is resolved before template processing.",
183
+ tests: [
184
+ processingResponseTest,
185
+ "packages/core/src/session-builtins.test.ts",
186
+ "tests/browser/player-lifecycle.spec.ts",
187
+ ],
188
+ },
155
189
  templateProcessingEntry(
156
190
  "qti-random-integer",
157
191
  ["packages/fixtures/xml/random-integer-template-reference.xml"],
158
192
  [...processingBrowserEvidence["qti-random-integer"]],
159
193
  ),
160
194
  operatorProcessingEntry("qti-random-float"),
161
- operatorProcessingEntry("qti-random"),
195
+ {
196
+ ...operatorProcessingEntry("qti-random"),
197
+ notes:
198
+ "Evaluates one multiple/ordered container expression, including variables, then selects a scalar member; NULL/empty containers return NULL and serialization retains the operand.",
199
+ },
162
200
  operatorProcessingEntry("qti-multiple", [
163
201
  "packages/fixtures/xml/advanced-processing-reference.xml",
164
202
  ]),
@@ -166,7 +204,15 @@ export const processingSupport: QtiProcessingElementSupport[] = [
166
204
  "packages/fixtures/xml/advanced-processing-reference.xml",
167
205
  ]),
168
206
  operatorProcessingEntry("qti-index"),
169
- operatorProcessingEntry("qti-contains"),
207
+ {
208
+ ...operatorProcessingEntry(
209
+ "qti-contains",
210
+ [],
211
+ ["packages/core/src/processing-containment.test.ts"],
212
+ ),
213
+ notes:
214
+ "Multiple containers use multiset containment; ordered containers require a contiguous sequence and preserve duplicate counts, including variable/delete/repeat operands.",
215
+ },
170
216
  operatorProcessingEntry("qti-container-size", [
171
217
  "packages/fixtures/xml/advanced-processing-reference.xml",
172
218
  ]),
@@ -192,10 +238,20 @@ export const processingSupport: QtiProcessingElementSupport[] = [
192
238
  operatorProcessingEntry("qti-any-n"),
193
239
  operatorProcessingEntry("qti-or"),
194
240
  operatorProcessingEntry("qti-not"),
195
- operatorProcessingEntry("qti-equal"),
196
- operatorProcessingEntry("qti-equal-rounded", [
197
- "packages/fixtures/xml/advanced-processing-reference.xml",
198
- ]),
241
+ {
242
+ ...operatorProcessingEntry("qti-equal", [], ["packages/core/src/processing-equality.test.ts"]),
243
+ notes:
244
+ "Preserves and evaluates exact, absolute, and relative tolerance with asymmetric bounds, variable tolerances, and inclusive/exclusive boundaries.",
245
+ },
246
+ {
247
+ ...operatorProcessingEntry(
248
+ "qti-equal-rounded",
249
+ ["packages/fixtures/xml/advanced-processing-reference.xml"],
250
+ ["packages/core/src/processing-equality.test.ts"],
251
+ ),
252
+ notes:
253
+ "Defaults rounding-mode to significantFigures and resolves integer-or-variable-reference figures at evaluation time, preserving both through serialization.",
254
+ },
199
255
  mappingProcessingEntry("qti-field-value"),
200
256
  operatorProcessingEntry("qti-lt"),
201
257
  operatorProcessingEntry("qti-lte"),
@@ -203,7 +259,12 @@ export const processingSupport: QtiProcessingElementSupport[] = [
203
259
  operatorProcessingEntry("qti-gte"),
204
260
  operatorProcessingEntry("qti-string-match"),
205
261
  operatorProcessingEntry("qti-substring"),
206
- operatorProcessingEntry("qti-pattern-match"),
262
+ {
263
+ ...operatorProcessingEntry("qti-pattern-match"),
264
+ tests: [processingOperatorsTest, "packages/core/src/processing-pattern.test.ts"],
265
+ notes:
266
+ "XML Schema Appendix F whole-string matching, XML name escapes, Unicode categories/blocks and class subtraction; brace-enclosed pattern variables, NULL propagation and explicit syntax/resource diagnostics. No JavaScript-pattern fallback.",
267
+ },
207
268
  operatorProcessingEntry("qti-member"),
208
269
  operatorProcessingEntry("qti-delete"),
209
270
  operatorProcessingEntry("qti-duration-gte"),
@@ -272,10 +333,11 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
272
333
  tests: [
273
334
  processingResponseTest,
274
335
  "packages/core/src/parser-item-metadata.test.ts",
336
+ "packages/core/src/adaptive-turn-materializer.test.ts",
275
337
  ...browserFeedbackTests,
276
338
  ],
277
339
  notes:
278
- "Parsed by parser-item-metadata.ts and validated by validateModalFeedback. Response processing supplies the outcome value used for visibility, and the player renders the matching feedback in player-feedback.spec.ts.",
340
+ "Parsed by parser-item-metadata.ts and validated by validateModalFeedback. Response processing supplies the scalar/container outcome used for show/hide visibility, including inverse hide conditions, in both player rendering and adaptive candidate XML materialization.",
279
341
  },
280
342
  {
281
343
  qtiName: "qti-companion-materials-info",
@@ -336,6 +398,7 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
336
398
  ];
337
399
 
338
400
  export const elementSupport: QtiElementSupport[] = [
401
+ ...testExecutionSupport,
339
402
  ...interactionSupport,
340
403
  ...deprecatedInteractionSupport,
341
404
  ...processingSupport,
@@ -406,7 +469,7 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
406
469
  ? "Supports single and multiple unordered pair responses."
407
470
  : "Supports single and multiple directedPair responses."
408
471
  : interactionType === "graphicGapMatch"
409
- ? "Requires multiple directedPair responses."
472
+ ? "Requires multiple directedPair responses; multiple and repeated placements in one hotspot preserve each association through rendering, removal, scoring, and restoration, subject to source/target match-max and max-associations."
410
473
  : undefined,
411
474
  };
412
475
  }
@@ -0,0 +1,89 @@
1
+ import { assertNever } from "./assert-never.js";
2
+ import { coerceValue } from "./parser-values.js";
3
+ import { isTestBaseType, testExpressionSyntax, type QtiTestExpression } from "./test-expression.js";
4
+ import { checkTestXml, rejectTestXml } from "./test-xml.js";
5
+ import type { QtiDiagnostic } from "./types.js";
6
+ import { textContent, type XmlNode } from "./xml.js";
7
+
8
+ /** Read the single expression owned by a branch or outcome assignment. */
9
+ export function parseTestExpressionChild(
10
+ node: XmlNode,
11
+ diagnostics: QtiDiagnostic[],
12
+ ): QtiTestExpression | undefined {
13
+ const first = node.children[0];
14
+ if (node.children.length !== 1 || !first) {
15
+ rejectTestXml(node, diagnostics);
16
+ return undefined;
17
+ }
18
+ return parseTestExpression(first, diagnostics);
19
+ }
20
+
21
+ function parseTestExpression(
22
+ node: XmlNode,
23
+ diagnostics: QtiDiagnostic[],
24
+ ): QtiTestExpression | undefined {
25
+ const syntax = testExpressionSyntax.find((entry) => entry.name === node.localName);
26
+ if (!syntax) {
27
+ rejectTestXml(node, diagnostics);
28
+ return undefined;
29
+ }
30
+ checkTestXml(
31
+ node,
32
+ testExpressionSyntax.map((entry) => entry.name),
33
+ syntax.attributes,
34
+ diagnostics,
35
+ syntax.type === "baseValue",
36
+ );
37
+ const arityValid =
38
+ syntax.arity === "oneOrMore" ? node.children.length > 0 : node.children.length === syntax.arity;
39
+ if (!arityValid) {
40
+ rejectTestXml(node, diagnostics);
41
+ return undefined;
42
+ }
43
+ const expressions: QtiTestExpression[] = [];
44
+ for (const child of node.children) {
45
+ const parsed = parseTestExpression(child, diagnostics);
46
+ if (!parsed) return undefined;
47
+ expressions.push(parsed);
48
+ }
49
+ const source = node.source;
50
+ switch (syntax.type) {
51
+ case "baseValue": {
52
+ const baseType = node.attributes["base-type"];
53
+ if (!isTestBaseType(baseType)) {
54
+ rejectTestXml(node, diagnostics);
55
+ return undefined;
56
+ }
57
+ return {
58
+ type: "baseValue",
59
+ baseType,
60
+ value: coerceValue(textContent(node), baseType),
61
+ source,
62
+ };
63
+ }
64
+ case "variable":
65
+ return { type: "variable", identifier: node.attributes.identifier ?? "", source };
66
+ case "testVariables":
67
+ return {
68
+ type: "testVariables",
69
+ variableIdentifier: node.attributes["variable-identifier"] ?? "",
70
+ includeCategory: node.attributes["include-category"],
71
+ source,
72
+ };
73
+ case "sum":
74
+ case "and":
75
+ case "or":
76
+ return { type: syntax.type, expressions, source };
77
+ case "numericCompare": {
78
+ const [left, right] = expressions;
79
+ if (!left || !right) return undefined;
80
+ return { type: "numericCompare", operator: syntax.operator, left, right, source };
81
+ }
82
+ case "not": {
83
+ const expression = expressions[0];
84
+ return expression ? { type: "not", expression, source } : undefined;
85
+ }
86
+ default:
87
+ return assertNever(syntax);
88
+ }
89
+ }
@@ -0,0 +1,71 @@
1
+ import { assertNever } from "./assert-never.js";
2
+ import { renderElement, type XmlAttribute } from "./serializer-processing-xml.js";
3
+ import { testExpressionSyntax, type QtiTestExpression } from "./test-expression.js";
4
+ import { testFailure, type QtiTestResult } from "./test-model.js";
5
+ import { escapeXmlText } from "./xml.js";
6
+
7
+ /** Serialize the closed test expression language without involving item processing. */
8
+ export function serializeTestExpression(expression: QtiTestExpression): QtiTestResult<string> {
9
+ const result = serializeExpression(expression, 0);
10
+ return result.ok ? { ok: true, value: result.value.join("\n") } : result;
11
+ }
12
+
13
+ function serializeExpression(
14
+ expression: QtiTestExpression,
15
+ indent: number,
16
+ ): QtiTestResult<readonly string[]> {
17
+ const syntax = testExpressionSyntax.find(
18
+ (entry) =>
19
+ entry.type === expression.type &&
20
+ (expression.type !== "numericCompare" ||
21
+ (entry.type === "numericCompare" && entry.operator === expression.operator)),
22
+ );
23
+ if (!syntax)
24
+ return testFailure(
25
+ "expression.unsupported",
26
+ "Cannot serialize an unsupported test expression.",
27
+ );
28
+ const attributes: XmlAttribute[] = [];
29
+ let children: readonly QtiTestExpression[] = [];
30
+ switch (expression.type) {
31
+ case "baseValue":
32
+ return {
33
+ ok: true,
34
+ value: renderElement(
35
+ syntax.name,
36
+ [["base-type", expression.baseType]],
37
+ escapeXmlText(String(expression.value)),
38
+ indent,
39
+ ),
40
+ };
41
+ case "variable":
42
+ attributes.push(["identifier", expression.identifier]);
43
+ break;
44
+ case "testVariables":
45
+ attributes.push(
46
+ ["variable-identifier", expression.variableIdentifier],
47
+ ["include-category", expression.includeCategory],
48
+ );
49
+ break;
50
+ case "sum":
51
+ case "and":
52
+ case "or":
53
+ children = expression.expressions;
54
+ break;
55
+ case "numericCompare":
56
+ children = [expression.left, expression.right];
57
+ break;
58
+ case "not":
59
+ children = [expression.expression];
60
+ break;
61
+ default:
62
+ return assertNever(expression);
63
+ }
64
+ const content: string[] = [];
65
+ for (const child of children) {
66
+ const result = serializeExpression(child, indent + 1);
67
+ if (!result.ok) return result;
68
+ content.push(...result.value);
69
+ }
70
+ return { ok: true, value: renderElement(syntax.name, attributes, content, indent) };
71
+ }
@@ -0,0 +1,139 @@
1
+ import { assertNever } from "./assert-never.js";
2
+ import {
3
+ isTestBaseType,
4
+ testExpressionSyntax,
5
+ type QtiTestBaseType,
6
+ type QtiTestExpression,
7
+ } from "./test-expression.js";
8
+ import type { QtiDiagnostic, QtiOutcomeDeclaration, QtiValue } from "./types.js";
9
+
10
+ /** The declared QTI type of an expression, including its cardinality. */
11
+ export interface TestExpressionType {
12
+ readonly baseType: QtiTestBaseType;
13
+ readonly cardinality: "single" | "multiple";
14
+ }
15
+
16
+ /** Check scalar literals and defaults against the supported base types. */
17
+ export function isTestScalarValue(baseType: string | undefined, value: QtiValue): boolean {
18
+ if (baseType === undefined) return false;
19
+ switch (baseType) {
20
+ case "integer":
21
+ return typeof value === "number" && Number.isSafeInteger(value);
22
+ case "float":
23
+ return typeof value === "number" && Number.isFinite(value);
24
+ case "boolean":
25
+ return typeof value === "boolean";
26
+ case "string":
27
+ case "identifier":
28
+ return typeof value === "string";
29
+ default:
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** Infer a test expression's QTI type and diagnose invalid operands and scope. */
35
+ export function checkTestExpression(
36
+ expression: QtiTestExpression,
37
+ declarations: ReadonlyMap<string, QtiOutcomeDeclaration>,
38
+ categories: ReadonlySet<string>,
39
+ scope: "outcome" | "branch",
40
+ diagnostics: QtiDiagnostic[],
41
+ ): TestExpressionType | undefined {
42
+ const reject = (code: string, message: string) => {
43
+ diagnostics.push({
44
+ code: `test.${code}`,
45
+ severity: "error",
46
+ message,
47
+ source: expression.source,
48
+ });
49
+ };
50
+ const check = (child: QtiTestExpression) =>
51
+ checkTestExpression(child, declarations, categories, scope, diagnostics);
52
+ if (!testExpressionSyntax.some((syntax) => syntax.type === expression.type)) {
53
+ reject("expression.unsupported", "Expression is outside the supported test execution profile.");
54
+ return undefined;
55
+ }
56
+ switch (expression.type) {
57
+ case "baseValue":
58
+ if (!isTestScalarValue(expression.baseType, expression.value)) {
59
+ reject("expression.value", "Expression values must match a supported scalar base type.");
60
+ return undefined;
61
+ }
62
+ return { baseType: expression.baseType, cardinality: "single" };
63
+ case "variable": {
64
+ const declaration = declarations.get(expression.identifier);
65
+ if (!declaration || !isTestBaseType(declaration.baseType)) {
66
+ reject(
67
+ "expression.variable",
68
+ "Test expression references an undeclared or unsupported outcome.",
69
+ );
70
+ return undefined;
71
+ }
72
+ return { baseType: declaration.baseType, cardinality: "single" };
73
+ }
74
+ case "testVariables":
75
+ if (scope === "branch")
76
+ reject(
77
+ "branch.scope",
78
+ "Test-variable aggregation belongs in outcome processing, not a branch rule.",
79
+ );
80
+ if (
81
+ expression.variableIdentifier !== "SCORE" ||
82
+ (expression.includeCategory !== undefined && !categories.has(expression.includeCategory))
83
+ )
84
+ reject(
85
+ "expression.testVariables",
86
+ "This test profile aggregates item SCORE values from declared categories.",
87
+ );
88
+ return { baseType: "float", cardinality: "multiple" };
89
+ case "sum": {
90
+ const operands = expression.expressions.map(check);
91
+ if (!operands.length || operands.some((type) => !isNumeric(type)))
92
+ reject("expression.sum", "Test sums require numeric operands.");
93
+ return {
94
+ baseType: operands.every((type) => type?.baseType === "integer") ? "integer" : "float",
95
+ cardinality: "single",
96
+ };
97
+ }
98
+ case "numericCompare": {
99
+ const left = check(expression.left);
100
+ const right = check(expression.right);
101
+ if (
102
+ !testExpressionSyntax.some(
103
+ (syntax) => syntax.type === "numericCompare" && syntax.operator === expression.operator,
104
+ )
105
+ )
106
+ reject("expression.compare", "Unsupported test comparison operator.");
107
+ if (
108
+ !isNumeric(left) ||
109
+ left?.cardinality !== "single" ||
110
+ !isNumeric(right) ||
111
+ right?.cardinality !== "single"
112
+ )
113
+ reject("expression.compare", "Test comparisons require two scalar numbers.");
114
+ return { baseType: "boolean", cardinality: "single" };
115
+ }
116
+ case "and":
117
+ case "or": {
118
+ const operands = expression.expressions.map(check);
119
+ if (!operands.length || operands.some((type) => !isBoolean(type)))
120
+ reject("expression.boolean", "Test logic requires boolean operands.");
121
+ return { baseType: "boolean", cardinality: "single" };
122
+ }
123
+ case "not":
124
+ if (!isBoolean(check(expression.expression)))
125
+ reject("expression.boolean", "Test logic requires a boolean operand.");
126
+ return { baseType: "boolean", cardinality: "single" };
127
+ default:
128
+ return assertNever(expression);
129
+ }
130
+ }
131
+
132
+ function isNumeric(type: TestExpressionType | undefined): boolean {
133
+ return type?.baseType === "integer" || type?.baseType === "float";
134
+ }
135
+
136
+ /** Whether a branch expression has the required single boolean type. */
137
+ export function isBoolean(type: TestExpressionType | undefined): boolean {
138
+ return type?.baseType === "boolean" && type.cardinality === "single";
139
+ }
@@ -0,0 +1,66 @@
1
+ import type { QtiSourceLocation } from "./types.js";
2
+
3
+ /** Scalar types supported by deterministic test outcome processing. */
4
+ export type QtiTestBaseType = "integer" | "float" | "boolean" | "string" | "identifier";
5
+
6
+ /** Test expressions form a closed language, independent of item processing. */
7
+ export type QtiTestExpression = (
8
+ | {
9
+ readonly type: "baseValue";
10
+ readonly baseType: QtiTestBaseType;
11
+ readonly value: number | boolean | string;
12
+ }
13
+ | { readonly type: "variable"; readonly identifier: string }
14
+ | {
15
+ readonly type: "testVariables";
16
+ readonly variableIdentifier: string;
17
+ readonly includeCategory?: string | undefined;
18
+ }
19
+ | { readonly type: "sum"; readonly expressions: readonly QtiTestExpression[] }
20
+ | { readonly type: "and"; readonly expressions: readonly QtiTestExpression[] }
21
+ | { readonly type: "or"; readonly expressions: readonly QtiTestExpression[] }
22
+ | {
23
+ readonly type: "numericCompare";
24
+ readonly operator: "gt" | "gte" | "lt" | "lte";
25
+ readonly left: QtiTestExpression;
26
+ readonly right: QtiTestExpression;
27
+ }
28
+ | { readonly type: "not"; readonly expression: QtiTestExpression }
29
+ ) & { readonly source?: QtiSourceLocation | undefined };
30
+
31
+ /** XML names, AST variants, arities and attributes owned by the test language. */
32
+ export const testExpressionSyntax = [
33
+ { name: "qti-base-value", type: "baseValue", arity: 0, attributes: ["base-type"] },
34
+ { name: "qti-variable", type: "variable", arity: 0, attributes: ["identifier"] },
35
+ {
36
+ name: "qti-test-variables",
37
+ type: "testVariables",
38
+ arity: 0,
39
+ attributes: ["variable-identifier", "include-category"],
40
+ },
41
+ { name: "qti-sum", type: "sum", arity: "oneOrMore", attributes: [] },
42
+ { name: "qti-gt", type: "numericCompare", operator: "gt", arity: 2, attributes: [] },
43
+ { name: "qti-gte", type: "numericCompare", operator: "gte", arity: 2, attributes: [] },
44
+ { name: "qti-lt", type: "numericCompare", operator: "lt", arity: 2, attributes: [] },
45
+ { name: "qti-lte", type: "numericCompare", operator: "lte", arity: 2, attributes: [] },
46
+ { name: "qti-and", type: "and", arity: "oneOrMore", attributes: [] },
47
+ { name: "qti-or", type: "or", arity: "oneOrMore", attributes: [] },
48
+ { name: "qti-not", type: "not", arity: 1, attributes: [] },
49
+ ] as const satisfies readonly {
50
+ name: string;
51
+ type: QtiTestExpression["type"];
52
+ operator?: "gt" | "gte" | "lt" | "lte";
53
+ arity: number | "oneOrMore";
54
+ attributes: readonly string[];
55
+ }[];
56
+
57
+ /** Refine a supported test scalar type without accepting item-only base types. */
58
+ export function isTestBaseType(value: string | undefined): value is QtiTestBaseType {
59
+ return (
60
+ value === "integer" ||
61
+ value === "float" ||
62
+ value === "boolean" ||
63
+ value === "string" ||
64
+ value === "identifier"
65
+ );
66
+ }
@@ -0,0 +1,68 @@
1
+ import type { QtiDiagnostic, QtiOutcomeDeclaration } from "./types.js";
2
+ import type { QtiTestBaseType, QtiTestExpression } from "./test-expression.js";
3
+
4
+ /** An ordered branch evaluated after the section's final item submission. */
5
+ export interface QtiTestBranch {
6
+ readonly target: string;
7
+ readonly expression: QtiTestExpression;
8
+ }
9
+
10
+ /** A fixed item reference; categories participate in test-variable aggregation. */
11
+ export interface QtiTestItemRef {
12
+ readonly identifier: string;
13
+ readonly href: string;
14
+ readonly categories: readonly string[];
15
+ }
16
+
17
+ /** A flat section in the supported linear, individually submitted test profile. */
18
+ export interface QtiTestSection {
19
+ readonly identifier: string;
20
+ readonly title: string;
21
+ readonly items: readonly QtiTestItemRef[];
22
+ readonly branches: readonly QtiTestBranch[];
23
+ }
24
+
25
+ /** Framework-neutral QTI test definition. Validation establishes executable support. */
26
+ export interface QtiTestDefinition {
27
+ readonly identifier: string;
28
+ readonly title: string;
29
+ readonly partIdentifier: string;
30
+ readonly sections: readonly QtiTestSection[];
31
+ readonly outcomeDeclarations: readonly QtiOutcomeDeclaration[];
32
+ readonly outcomeProcessing: readonly QtiTestOutcomeRule[];
33
+ }
34
+
35
+ /** Sequential assignment of a test expression to a declared test outcome. */
36
+ export interface QtiTestOutcomeRule {
37
+ readonly type: "setOutcomeValue";
38
+ readonly identifier: string;
39
+ readonly expression: QtiTestExpression;
40
+ }
41
+
42
+ type ExecutableSection = QtiTestSection & {
43
+ readonly items: readonly [QtiTestItemRef, ...QtiTestItemRef[]];
44
+ };
45
+ type ExecutableOutcome = Readonly<QtiOutcomeDeclaration> & {
46
+ readonly cardinality: "single";
47
+ readonly baseType: QtiTestBaseType;
48
+ readonly defaultValue: number | boolean | string | null;
49
+ };
50
+
51
+ declare const executableTest: unique symbol;
52
+
53
+ /** A test checked for supported expressions, unique identifiers and forward routing. */
54
+ export type QtiExecutableTest = Omit<QtiTestDefinition, "sections" | "outcomeDeclarations"> & {
55
+ readonly [executableTest]: true;
56
+ readonly sections: readonly [ExecutableSection, ...ExecutableSection[]];
57
+ readonly outcomeDeclarations: readonly ExecutableOutcome[];
58
+ };
59
+
60
+ /** Typed failure channel shared by test construction, parsing and transitions. */
61
+ export type QtiTestResult<T> =
62
+ | { readonly ok: true; readonly value: T }
63
+ | { readonly ok: false; readonly diagnostics: readonly QtiDiagnostic[] };
64
+
65
+ /** Construct a safe test diagnostic without including submitted content. */
66
+ export function testFailure(code: string, message: string): QtiTestResult<never> {
67
+ return { ok: false, diagnostics: [{ code: `test.${code}`, severity: "error", message }] };
68
+ }