@longsightgroup/qti3-core 0.10.6 → 0.11.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 (204) 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/diagnostics.d.ts +13 -0
  11. package/dist/diagnostics.d.ts.map +1 -0
  12. package/dist/diagnostics.js +28 -0
  13. package/dist/diagnostics.js.map +1 -0
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/item-submission-materialization.d.ts.map +1 -1
  19. package/dist/item-submission-materialization.js +2 -0
  20. package/dist/item-submission-materialization.js.map +1 -1
  21. package/dist/parser-processing.d.ts.map +1 -1
  22. package/dist/parser-processing.js +8 -18
  23. package/dist/parser-processing.js.map +1 -1
  24. package/dist/parser-values.d.ts.map +1 -1
  25. package/dist/parser-values.js +1 -3
  26. package/dist/parser-values.js.map +1 -1
  27. package/dist/parser.js +32 -0
  28. package/dist/parser.js.map +1 -1
  29. package/dist/processing-equality.d.ts +10 -0
  30. package/dist/processing-equality.d.ts.map +1 -0
  31. package/dist/processing-equality.js +78 -0
  32. package/dist/processing-equality.js.map +1 -0
  33. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  34. package/dist/processing-evaluator-comparison.js +14 -9
  35. package/dist/processing-evaluator-comparison.js.map +1 -1
  36. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  37. package/dist/processing-evaluator-numeric.js +2 -1
  38. package/dist/processing-evaluator-numeric.js.map +1 -1
  39. package/dist/processing-evaluator-string.d.ts.map +1 -1
  40. package/dist/processing-evaluator-string.js +26 -10
  41. package/dist/processing-evaluator-string.js.map +1 -1
  42. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  43. package/dist/processing-evaluator-variable.js +9 -3
  44. package/dist/processing-evaluator-variable.js.map +1 -1
  45. package/dist/processing-evaluator.d.ts +5 -1
  46. package/dist/processing-evaluator.d.ts.map +1 -1
  47. package/dist/processing-evaluator.js +13 -4
  48. package/dist/processing-evaluator.js.map +1 -1
  49. package/dist/processing-expression-children.d.ts.map +1 -1
  50. package/dist/processing-expression-children.js +2 -3
  51. package/dist/processing-expression-children.js.map +1 -1
  52. package/dist/processing-expression-tags.d.ts +2 -1
  53. package/dist/processing-expression-tags.d.ts.map +1 -1
  54. package/dist/processing-expression-tags.js +2 -1
  55. package/dist/processing-expression-tags.js.map +1 -1
  56. package/dist/processing-mapping.d.ts.map +1 -1
  57. package/dist/processing-mapping.js +26 -16
  58. package/dist/processing-mapping.js.map +1 -1
  59. package/dist/processing-state.d.ts +2 -2
  60. package/dist/processing-state.d.ts.map +1 -1
  61. package/dist/processing-state.js +5 -2
  62. package/dist/processing-state.js.map +1 -1
  63. package/dist/processing-values.d.ts +1 -2
  64. package/dist/processing-values.d.ts.map +1 -1
  65. package/dist/processing-values.js +1 -3
  66. package/dist/processing-values.js.map +1 -1
  67. package/dist/processing-variables.d.ts.map +1 -1
  68. package/dist/processing-variables.js +10 -5
  69. package/dist/processing-variables.js.map +1 -1
  70. package/dist/qti-package-assets.d.ts.map +1 -1
  71. package/dist/qti-package-assets.js +16 -10
  72. package/dist/qti-package-assets.js.map +1 -1
  73. package/dist/qti-package-manifest.d.ts +1 -0
  74. package/dist/qti-package-manifest.d.ts.map +1 -1
  75. package/dist/qti-package-manifest.js +1 -0
  76. package/dist/qti-package-manifest.js.map +1 -1
  77. package/dist/qti-package-metadata.d.ts.map +1 -1
  78. package/dist/qti-package-metadata.js +11 -0
  79. package/dist/qti-package-metadata.js.map +1 -1
  80. package/dist/qti-package-plan.d.ts +1 -1
  81. package/dist/qti-package-plan.d.ts.map +1 -1
  82. package/dist/qti-package-types.d.ts +9 -0
  83. package/dist/qti-package-types.d.ts.map +1 -1
  84. package/dist/qti-package-xml.d.ts +6 -1
  85. package/dist/qti-package-xml.d.ts.map +1 -1
  86. package/dist/qti-package-xml.js +11 -6
  87. package/dist/qti-package-xml.js.map +1 -1
  88. package/dist/qti-package-zip.d.ts +13 -1
  89. package/dist/qti-package-zip.d.ts.map +1 -1
  90. package/dist/qti-package-zip.js +185 -54
  91. package/dist/qti-package-zip.js.map +1 -1
  92. package/dist/qti-package.d.ts +12 -5
  93. package/dist/qti-package.d.ts.map +1 -1
  94. package/dist/qti-package.js +63 -8
  95. package/dist/qti-package.js.map +1 -1
  96. package/dist/response-validation-policy.d.ts.map +1 -1
  97. package/dist/response-validation-policy.js +3 -1
  98. package/dist/response-validation-policy.js.map +1 -1
  99. package/dist/response-validation.d.ts.map +1 -1
  100. package/dist/response-validation.js +2 -1
  101. package/dist/response-validation.js.map +1 -1
  102. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  103. package/dist/serializer-processing-expressions.js +2 -10
  104. package/dist/serializer-processing-expressions.js.map +1 -1
  105. package/dist/server-scoring.d.ts.map +1 -1
  106. package/dist/server-scoring.js +1 -0
  107. package/dist/server-scoring.js.map +1 -1
  108. package/dist/session-builtins.d.ts +16 -0
  109. package/dist/session-builtins.d.ts.map +1 -0
  110. package/dist/session-builtins.js +58 -0
  111. package/dist/session-builtins.js.map +1 -0
  112. package/dist/session.d.ts +5 -1
  113. package/dist/session.d.ts.map +1 -1
  114. package/dist/session.js +63 -11
  115. package/dist/session.js.map +1 -1
  116. package/dist/support-evidence.d.ts.map +1 -1
  117. package/dist/support-evidence.js +2 -0
  118. package/dist/support-evidence.js.map +1 -1
  119. package/dist/support.d.ts.map +1 -1
  120. package/dist/support.js +64 -23
  121. package/dist/support.js.map +1 -1
  122. package/dist/trusted-item-session.d.ts +4 -1
  123. package/dist/trusted-item-session.d.ts.map +1 -1
  124. package/dist/trusted-item-session.js +8 -3
  125. package/dist/trusted-item-session.js.map +1 -1
  126. package/dist/types.d.ts +21 -3
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/validation-declaration-entries.d.ts.map +1 -1
  129. package/dist/validation-declaration-entries.js +11 -1
  130. package/dist/validation-declaration-entries.js.map +1 -1
  131. package/dist/validation-item-children.d.ts +4 -0
  132. package/dist/validation-item-children.d.ts.map +1 -0
  133. package/dist/validation-item-children.js +45 -0
  134. package/dist/validation-item-children.js.map +1 -0
  135. package/dist/validation-processing.d.ts.map +1 -1
  136. package/dist/validation-processing.js +45 -12
  137. package/dist/validation-processing.js.map +1 -1
  138. package/dist/validation.d.ts.map +1 -1
  139. package/dist/validation.js +12 -0
  140. package/dist/validation.js.map +1 -1
  141. package/dist/value-guards.d.ts +4 -0
  142. package/dist/value-guards.d.ts.map +1 -0
  143. package/dist/value-guards.js +5 -0
  144. package/dist/value-guards.js.map +1 -0
  145. package/dist/xml-schema-regex-data.d.ts +48 -0
  146. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  147. package/dist/xml-schema-regex-data.js +403 -0
  148. package/dist/xml-schema-regex-data.js.map +1 -0
  149. package/dist/xml-schema-regex-parser.d.ts +31 -0
  150. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  151. package/dist/xml-schema-regex-parser.js +245 -0
  152. package/dist/xml-schema-regex-parser.js.map +1 -0
  153. package/dist/xml-schema-regex.d.ts +14 -0
  154. package/dist/xml-schema-regex.d.ts.map +1 -0
  155. package/dist/xml-schema-regex.js +114 -0
  156. package/dist/xml-schema-regex.js.map +1 -0
  157. package/package.json +1 -1
  158. package/src/adaptive-turn-materializer.ts +4 -3
  159. package/src/adaptive-turn.ts +1 -0
  160. package/src/attempt-state.ts +54 -1
  161. package/src/diagnostics.ts +29 -0
  162. package/src/index.ts +9 -0
  163. package/src/item-submission-materialization.ts +2 -0
  164. package/src/parser-processing.ts +15 -18
  165. package/src/parser-values.ts +1 -5
  166. package/src/parser.ts +34 -0
  167. package/src/processing-equality.ts +104 -0
  168. package/src/processing-evaluator-comparison.ts +19 -14
  169. package/src/processing-evaluator-numeric.ts +2 -6
  170. package/src/processing-evaluator-string.ts +25 -18
  171. package/src/processing-evaluator-variable.ts +8 -3
  172. package/src/processing-evaluator.ts +20 -7
  173. package/src/processing-expression-children.ts +2 -3
  174. package/src/processing-expression-tags.ts +2 -1
  175. package/src/processing-mapping.ts +31 -25
  176. package/src/processing-state.ts +6 -1
  177. package/src/processing-values.ts +2 -11
  178. package/src/processing-variables.ts +9 -7
  179. package/src/qti-package-assets.ts +20 -13
  180. package/src/qti-package-manifest.ts +1 -0
  181. package/src/qti-package-metadata.ts +11 -0
  182. package/src/qti-package-plan.ts +1 -1
  183. package/src/qti-package-types.ts +10 -0
  184. package/src/qti-package-xml.ts +11 -6
  185. package/src/qti-package-zip.ts +330 -97
  186. package/src/qti-package.ts +106 -7
  187. package/src/response-validation-policy.ts +5 -1
  188. package/src/response-validation.ts +2 -1
  189. package/src/serializer-processing-expressions.ts +8 -25
  190. package/src/server-scoring.ts +1 -0
  191. package/src/session-builtins.ts +75 -0
  192. package/src/session.ts +64 -8
  193. package/src/support-evidence.ts +2 -0
  194. package/src/support.ts +84 -23
  195. package/src/trusted-item-session.ts +13 -2
  196. package/src/types.ts +30 -4
  197. package/src/validation-declaration-entries.ts +11 -1
  198. package/src/validation-item-children.ts +48 -0
  199. package/src/validation-processing.ts +47 -10
  200. package/src/validation.ts +14 -0
  201. package/src/value-guards.ts +6 -0
  202. package/src/xml-schema-regex-data.ts +404 -0
  203. package/src/xml-schema-regex-parser.ts +234 -0
  204. package/src/xml-schema-regex.ts +115 -0
package/src/support.ts CHANGED
@@ -92,7 +92,11 @@ export const processingSupport: QtiProcessingElementSupport[] = [
92
92
  "packages/fixtures/xml/random-integer-template-reference.xml",
93
93
  "packages/fixtures/xml/template-content-reference.xml",
94
94
  ]),
95
- templateProcessingEntry("qti-set-default-value"),
95
+ {
96
+ ...templateProcessingEntry("qti-set-default-value"),
97
+ notes:
98
+ "Updates effective response/outcome defaults for the clone, including explicit NULL overrides; rejected template passes discard their overrides.",
99
+ },
96
100
  templateProcessingEntry("qti-set-correct-response", [
97
101
  "packages/fixtures/xml/template-processing-reference.xml",
98
102
  "packages/fixtures/xml/random-integer-template-reference.xml",
@@ -133,32 +137,65 @@ export const processingSupport: QtiProcessingElementSupport[] = [
133
137
  operatorProcessingEntry("qti-null"),
134
138
  mappingProcessingEntry("qti-match-table"),
135
139
  mappingProcessingEntry("qti-match-table-entry"),
136
- mappingProcessingEntry("qti-interpolation-table"),
140
+ {
141
+ ...mappingProcessingEntry("qti-interpolation-table"),
142
+ notes:
143
+ "Selects the first authored entry whose source-value is below the input, including equality only when include-boundary permits it.",
144
+ },
137
145
  mappingProcessingEntry("qti-interpolation-table-entry"),
138
- operatorProcessingEntry("qti-is-null"),
146
+ {
147
+ ...operatorProcessingEntry("qti-is-null"),
148
+ notes:
149
+ "Accepts one arbitrary expression, evaluates it in the full variable context, and tests the result for NULL.",
150
+ },
139
151
  mappingProcessingEntry("qti-match", [
140
152
  "packages/fixtures/xml/generic-match-processing-reference.xml",
141
153
  "packages/fixtures/xml/advanced-processing-reference.xml",
142
154
  ]),
143
155
  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
- ]),
156
+ {
157
+ ...mappingProcessingEntry("qti-default", [], [processingTemplateTest]),
158
+ notes:
159
+ "Reads the effective authored or template-overridden default, independently of the variable's current value.",
160
+ },
161
+ {
162
+ ...mappingProcessingEntry("qti-map-response"),
163
+ notes:
164
+ "String map entries honor case-sensitive (default false); mapping counts each distinct response value once using QTI base-type equality, then applies bounds.",
165
+ },
166
+ {
167
+ ...mappingProcessingEntry("qti-map-response-point"),
168
+ notes:
169
+ "Each point selects the first matching authored area; each selected area contributes at most once.",
170
+ },
171
+ {
172
+ ...responseProcessingEntry("qti-variable", [
173
+ "packages/fixtures/xml/generic-match-processing-reference.xml",
174
+ "packages/fixtures/xml/template-processing-reference.xml",
175
+ "packages/fixtures/xml/random-integer-template-reference.xml",
176
+ "packages/fixtures/xml/template-content-reference.xml",
177
+ "packages/fixtures/xml/advanced-processing-reference.xml",
178
+ "packages/fixtures/xml/adaptive-feedback-reference.xml",
179
+ ]),
180
+ notes:
181
+ "Resolves declared variables and implicit completionStatus, numAttempts, timed-item duration (float seconds), and QTI_CONTEXT; session environment is resolved before template processing.",
182
+ tests: [
183
+ processingResponseTest,
184
+ "packages/core/src/session-builtins.test.ts",
185
+ "tests/browser/player-lifecycle.spec.ts",
186
+ ],
187
+ },
155
188
  templateProcessingEntry(
156
189
  "qti-random-integer",
157
190
  ["packages/fixtures/xml/random-integer-template-reference.xml"],
158
191
  [...processingBrowserEvidence["qti-random-integer"]],
159
192
  ),
160
193
  operatorProcessingEntry("qti-random-float"),
161
- operatorProcessingEntry("qti-random"),
194
+ {
195
+ ...operatorProcessingEntry("qti-random"),
196
+ notes:
197
+ "Evaluates one multiple/ordered container expression, including variables, then selects a scalar member; NULL/empty containers return NULL and serialization retains the operand.",
198
+ },
162
199
  operatorProcessingEntry("qti-multiple", [
163
200
  "packages/fixtures/xml/advanced-processing-reference.xml",
164
201
  ]),
@@ -166,7 +203,15 @@ export const processingSupport: QtiProcessingElementSupport[] = [
166
203
  "packages/fixtures/xml/advanced-processing-reference.xml",
167
204
  ]),
168
205
  operatorProcessingEntry("qti-index"),
169
- operatorProcessingEntry("qti-contains"),
206
+ {
207
+ ...operatorProcessingEntry(
208
+ "qti-contains",
209
+ [],
210
+ ["packages/core/src/processing-containment.test.ts"],
211
+ ),
212
+ notes:
213
+ "Multiple containers use multiset containment; ordered containers require a contiguous sequence and preserve duplicate counts, including variable/delete/repeat operands.",
214
+ },
170
215
  operatorProcessingEntry("qti-container-size", [
171
216
  "packages/fixtures/xml/advanced-processing-reference.xml",
172
217
  ]),
@@ -192,10 +237,20 @@ export const processingSupport: QtiProcessingElementSupport[] = [
192
237
  operatorProcessingEntry("qti-any-n"),
193
238
  operatorProcessingEntry("qti-or"),
194
239
  operatorProcessingEntry("qti-not"),
195
- operatorProcessingEntry("qti-equal"),
196
- operatorProcessingEntry("qti-equal-rounded", [
197
- "packages/fixtures/xml/advanced-processing-reference.xml",
198
- ]),
240
+ {
241
+ ...operatorProcessingEntry("qti-equal", [], ["packages/core/src/processing-equality.test.ts"]),
242
+ notes:
243
+ "Preserves and evaluates exact, absolute, and relative tolerance with asymmetric bounds, variable tolerances, and inclusive/exclusive boundaries.",
244
+ },
245
+ {
246
+ ...operatorProcessingEntry(
247
+ "qti-equal-rounded",
248
+ ["packages/fixtures/xml/advanced-processing-reference.xml"],
249
+ ["packages/core/src/processing-equality.test.ts"],
250
+ ),
251
+ notes:
252
+ "Defaults rounding-mode to significantFigures and resolves integer-or-variable-reference figures at evaluation time, preserving both through serialization.",
253
+ },
199
254
  mappingProcessingEntry("qti-field-value"),
200
255
  operatorProcessingEntry("qti-lt"),
201
256
  operatorProcessingEntry("qti-lte"),
@@ -203,7 +258,12 @@ export const processingSupport: QtiProcessingElementSupport[] = [
203
258
  operatorProcessingEntry("qti-gte"),
204
259
  operatorProcessingEntry("qti-string-match"),
205
260
  operatorProcessingEntry("qti-substring"),
206
- operatorProcessingEntry("qti-pattern-match"),
261
+ {
262
+ ...operatorProcessingEntry("qti-pattern-match"),
263
+ tests: [processingOperatorsTest, "packages/core/src/processing-pattern.test.ts"],
264
+ notes:
265
+ "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.",
266
+ },
207
267
  operatorProcessingEntry("qti-member"),
208
268
  operatorProcessingEntry("qti-delete"),
209
269
  operatorProcessingEntry("qti-duration-gte"),
@@ -272,10 +332,11 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
272
332
  tests: [
273
333
  processingResponseTest,
274
334
  "packages/core/src/parser-item-metadata.test.ts",
335
+ "packages/core/src/adaptive-turn-materializer.test.ts",
275
336
  ...browserFeedbackTests,
276
337
  ],
277
338
  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.",
339
+ "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
340
  },
280
341
  {
281
342
  qtiName: "qti-companion-materials-info",
@@ -406,7 +467,7 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
406
467
  ? "Supports single and multiple unordered pair responses."
407
468
  : "Supports single and multiple directedPair responses."
408
469
  : interactionType === "graphicGapMatch"
409
- ? "Requires multiple directedPair responses."
470
+ ? "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
471
  : undefined,
411
472
  };
412
473
  }
@@ -1,3 +1,4 @@
1
+ import type { QtiSessionEnvironment } from "./session-builtins.js";
1
2
  import { parseQtiXml } from "./parser.js";
2
3
  import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
3
4
  import { parseQtiResponseVariables } from "./response-validation.js";
@@ -31,6 +32,8 @@ export interface QtiTrustedResponseApplication {
31
32
  }
32
33
 
33
34
  export interface QtiTrustedItemParseOptions {
35
+ /** Trusted host environment; never take duration/context from candidate response fields. */
36
+ sessionEnvironment?: QtiSessionEnvironment | undefined;
34
37
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
35
38
  }
36
39
 
@@ -48,7 +51,7 @@ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
48
51
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
49
52
  submission: QtiTrustedResponseApplication;
50
53
  priorState?: QtiAttemptStateV1 | null | undefined;
51
- /** Applied before submission. Intended for server-side scoring, not adaptive turns. */
54
+ /** Applied before submission, except completed is applied after capture. For server scoring, not adaptive turns. */
52
55
  attemptStatus?: QtiAttemptStatus | undefined;
53
56
  scoring: QtiTrustedItemScoringPolicy;
54
57
  requireNumericScore: boolean;
@@ -156,12 +159,15 @@ export function runTrustedItemSession(
156
159
  input.diagnosticPrefix,
157
160
  input.allowedUndeclaredResponseIdentifiers,
158
161
  parsedResult.parsed.diagnostics,
162
+ input.sessionEnvironment,
159
163
  );
160
164
  if (!sessionResult.ok) {
161
165
  return emptyTrustedItemSessionFailure(sessionResult.diagnostics);
162
166
  }
163
167
 
164
- if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
168
+ if (input.attemptStatus && input.attemptStatus !== "completed") {
169
+ sessionResult.session.setStatus(input.attemptStatus);
170
+ }
165
171
 
166
172
  let submission = input.submission;
167
173
  if (input.submissionValidation === "strict") {
@@ -189,6 +195,9 @@ export function runTrustedItemSession(
189
195
  return emptyTrustedItemSessionFailure(diagnostics);
190
196
  }
191
197
 
198
+ // Capture the submitted attempt before closing it, so built-in counters reflect participation.
199
+ if (input.attemptStatus === "completed") sessionResult.session.setStatus("completed");
200
+
192
201
  const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
193
202
  const externalScore = itemHasExternalScore(parsedResult.parsed.document.item);
194
203
 
@@ -277,11 +286,13 @@ function createTrustedItemSession(
277
286
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
278
287
  allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
279
288
  existingDiagnostics: QtiDiagnostic[] = [],
289
+ sessionEnvironment: QtiSessionEnvironment = {},
280
290
  ): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
281
291
  try {
282
292
  return {
283
293
  ok: true,
284
294
  session: createItemSession(parsed.document, priorState ?? undefined, {
295
+ ...sessionEnvironment,
285
296
  allowedUndeclaredResponseIdentifiers,
286
297
  }),
287
298
  };
package/src/types.ts CHANGED
@@ -459,6 +459,13 @@ export interface QtiAssessmentItem {
459
459
  stylesheets: QtiStylesheet[];
460
460
  body: QtiContentNode[];
461
461
  bodyText: string;
462
+ /** Authored direct children in XML order; omitted for items created without source XML. */
463
+ sourceChildren?:
464
+ | readonly {
465
+ readonly qtiName: string;
466
+ readonly source?: QtiSourceLocation | undefined;
467
+ }[]
468
+ | undefined;
462
469
  source?: QtiSourceLocation | undefined;
463
470
  }
464
471
 
@@ -593,7 +600,7 @@ export type QtiProcessingExpression = (
593
600
  baseType?: string | undefined;
594
601
  }
595
602
  | { type: "null" }
596
- | { type: "isNull"; identifier: string }
603
+ | { type: "isNull"; expression: QtiProcessingExpression }
597
604
  | { type: "matchCorrect"; identifier: string; correctIdentifier: string }
598
605
  | { type: "match"; left: QtiProcessingExpression; right: QtiProcessingExpression }
599
606
  | { type: "correct"; identifier: string }
@@ -614,7 +621,7 @@ export type QtiProcessingExpression = (
614
621
  max: number;
615
622
  attributes: Record<string, string>;
616
623
  }
617
- | { type: "random"; values: QtiProcessingExpression[] }
624
+ | { type: "random"; expression: QtiProcessingExpression }
618
625
  | { type: "multiple"; expressions: QtiProcessingExpression[] }
619
626
  | { type: "ordered"; expressions: QtiProcessingExpression[] }
620
627
  | { type: "index"; expression: QtiProcessingExpression; n: string }
@@ -641,13 +648,18 @@ export type QtiProcessingExpression = (
641
648
  | { type: "anyN"; expressions: QtiProcessingExpression[]; min: string; max: string }
642
649
  | { type: "or"; expressions: QtiProcessingExpression[] }
643
650
  | { type: "not"; expression: QtiProcessingExpression }
644
- | { type: "equal"; left: QtiProcessingExpression; right: QtiProcessingExpression }
651
+ | {
652
+ type: "equal";
653
+ left: QtiProcessingExpression;
654
+ right: QtiProcessingExpression;
655
+ attributes?: Record<string, string> | undefined;
656
+ }
645
657
  | {
646
658
  type: "equalRounded";
647
659
  left: QtiProcessingExpression;
648
660
  right: QtiProcessingExpression;
649
661
  roundingMode: string;
650
- figures: number;
662
+ figures: number | string;
651
663
  }
652
664
  | {
653
665
  type: "numericCompare";
@@ -724,8 +736,22 @@ export interface QtiValidationResult {
724
736
  diagnostics: QtiDiagnostic[];
725
737
  }
726
738
 
739
+ export interface QtiSessionContext {
740
+ candidateIdentifier: string;
741
+ testIdentifier: string;
742
+ environmentIdentifier: string;
743
+ }
744
+
745
+ export interface QtiBuiltInVariables {
746
+ numAttempts: number;
747
+ duration: number | null;
748
+ attemptInProgress: boolean;
749
+ context: QtiSessionContext;
750
+ }
751
+
727
752
  export interface QtiAttemptStateV1 {
728
753
  schema: "qti3.attempt-state.v1";
754
+ builtInVariables?: QtiBuiltInVariables | undefined;
729
755
  itemIdentifier: string;
730
756
  status: QtiAttemptStatus;
731
757
  responses: Record<string, QtiValue>;
@@ -5,7 +5,7 @@ import {
5
5
  isNumericCsv,
6
6
  numericCsv,
7
7
  } from "./validation-geometry.js";
8
- import { isFiniteNumber, isInteger } from "./validation-primitives.js";
8
+ import { isBooleanAttribute, isFiniteNumber, isInteger } from "./validation-primitives.js";
9
9
 
10
10
  interface DeclarationEntryAttributes {
11
11
  attributes: Record<string, string>;
@@ -18,6 +18,16 @@ export function validateMapEntryAttributes(
18
18
  entry: DeclarationEntryAttributes,
19
19
  diagnostics: QtiDiagnostic[],
20
20
  ): void {
21
+ const caseSensitive = entry.attributes["case-sensitive"];
22
+ if (caseSensitive !== undefined && !isBooleanAttribute(caseSensitive)) {
23
+ diagnostics.push({
24
+ code: "mapEntry.caseSensitive",
25
+ severity: "error",
26
+ message: `Response declaration ${declarationIdentifier} map entry requires a boolean case-sensitive value.`,
27
+ path: entry.source?.path,
28
+ source: entry.source,
29
+ });
30
+ }
21
31
  if (!entry.attributes["map-key"]) {
22
32
  diagnostics.push({
23
33
  code: "mapEntry.mapKey.required",
@@ -0,0 +1,48 @@
1
+ import type { QtiAssessmentItem, QtiDiagnostic } from "./types.js";
2
+
3
+ // QTI 3.0.1 AssessmentItemDType sequence:
4
+ // https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_itemv3p0p1_v1p0.xsd
5
+ const itemChildOrder: readonly string[] = [
6
+ "qti-context-declaration",
7
+ "qti-response-declaration",
8
+ "qti-outcome-declaration",
9
+ "qti-template-declaration",
10
+ "qti-template-processing",
11
+ "qti-assessment-stimulus-ref",
12
+ "qti-companion-materials-info",
13
+ "qti-stylesheet",
14
+ "qti-item-body",
15
+ "qti-catalog-info",
16
+ "qti-response-processing",
17
+ "qti-modal-feedback",
18
+ ];
19
+
20
+ /** Validate the authored direct-child sequence retained by the XML parser. */
21
+ export function validateAssessmentItemChildren(
22
+ item: QtiAssessmentItem,
23
+ diagnostics: QtiDiagnostic[],
24
+ ): void {
25
+ let lastOrder = -1;
26
+ for (const child of item.sourceChildren ?? []) {
27
+ const order = itemChildOrder.indexOf(child.qtiName);
28
+ if (order < 0) {
29
+ diagnostics.push({
30
+ code: "assessmentItem.child.unsupported",
31
+ severity: "error",
32
+ message: `qti-assessment-item does not allow ${child.qtiName} as a direct child.`,
33
+ path: child.source?.path,
34
+ source: child.source,
35
+ });
36
+ } else if (order < lastOrder) {
37
+ diagnostics.push({
38
+ code: "assessmentItem.child.order",
39
+ severity: "error",
40
+ message: `${child.qtiName} appears out of QTI 3 qti-assessment-item child order.`,
41
+ path: child.source?.path,
42
+ source: child.source,
43
+ });
44
+ } else {
45
+ lastOrder = order;
46
+ }
47
+ }
48
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  QtiSetOutcomeValue,
8
8
  QtiTemplateRule,
9
9
  } from "./types.js";
10
+ import { parseXmlSchemaRegex, patternVariableIdentifier } from "./xml-schema-regex.js";
10
11
  import { expressionChildren } from "./processing-expression-children.js";
11
12
  import { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
12
13
  import { MATH_OPERATOR_NAMES, STATS_OPERATOR_NAMES } from "./processing-operators.js";
@@ -21,6 +22,7 @@ import {
21
22
  isInteger,
22
23
  } from "./validation-primitives.js";
23
24
  import { COMPLETION_STATUS } from "./attempt-state-constants.js";
25
+ import { validateEqualTolerance } from "./processing-equality.js";
24
26
 
25
27
  export function validateResponseProcessingTemplate(
26
28
  item: QtiAssessmentItem,
@@ -120,7 +122,14 @@ export function validateProcessingReferences(
120
122
  const outcomes = new Set(item.outcomeDeclarations.map((declaration) => declaration.identifier));
121
123
  outcomes.add(COMPLETION_STATUS);
122
124
  const templates = new Set(item.templateDeclarations.map((declaration) => declaration.identifier));
123
- const variables = new Set([...responses, ...outcomes, ...templates]);
125
+ const variables = new Set([
126
+ ...responses,
127
+ ...outcomes,
128
+ ...templates,
129
+ "numAttempts",
130
+ "QTI_CONTEXT",
131
+ ...(item.timeDependent ? ["duration"] : []),
132
+ ]);
124
133
 
125
134
  for (const rule of item.templateProcessing?.rules ?? []) {
126
135
  validateTemplateRule(rule, responses, outcomes, templates, variables, diagnostics);
@@ -324,7 +333,7 @@ function validateExpressionReferences(
324
333
  ): void {
325
334
  if (!expression) return;
326
335
 
327
- if (expression.type === "variable" || expression.type === "isNull") {
336
+ if (expression.type === "variable") {
328
337
  validateProcessingIdentifier(
329
338
  expression.identifier,
330
339
  "processing.variable",
@@ -429,6 +438,30 @@ function validateExpressionReferences(
429
438
  }
430
439
  }
431
440
 
441
+ if (expression.type === "patternMatch") {
442
+ const reference = patternVariableIdentifier(expression.pattern);
443
+ if (reference !== undefined) {
444
+ if (!variables.has(reference))
445
+ diagnostics.push({
446
+ code: "processing.variable.reference",
447
+ severity: "error",
448
+ message: `Pattern references missing variable ${reference}.`,
449
+ source: expression.source,
450
+ path: expression.source?.path,
451
+ });
452
+ } else {
453
+ const parsed = parseXmlSchemaRegex(expression.pattern);
454
+ if (!parsed.ok)
455
+ diagnostics.push({
456
+ code: `processing.pattern.${parsed.problem.code}`,
457
+ severity: "error",
458
+ message: parsed.problem.message,
459
+ source: expression.source,
460
+ path: expression.source?.path,
461
+ });
462
+ }
463
+ }
464
+
432
465
  if (expression.type === "randomInteger") {
433
466
  validateRandomIntegerExpression(expression, diagnostics);
434
467
  }
@@ -446,11 +479,16 @@ function validateExpressionReferences(
446
479
  "qti-equal-rounded",
447
480
  expression.roundingMode,
448
481
  expression.figures,
482
+ variables,
449
483
  diagnostics,
450
484
  expression.source,
451
485
  );
452
486
  }
453
487
 
488
+ if (expression.type === "equal") {
489
+ validateEqualTolerance(expression, variables, diagnostics);
490
+ }
491
+
454
492
  if (expression.type === "anyN") {
455
493
  validateAnyNBound("min", expression.min, responses, variables, diagnostics, expression.source);
456
494
  validateAnyNBound("max", expression.max, responses, variables, diagnostics, expression.source);
@@ -544,7 +582,8 @@ const statsOperatorNames = new Set<string>(STATS_OPERATOR_NAMES);
544
582
  function validateRounding(
545
583
  qtiName: string,
546
584
  roundingMode: string,
547
- figures: number,
585
+ figures: number | string,
586
+ variables: ReadonlySet<string>,
548
587
  diagnostics: QtiDiagnostic[],
549
588
  source: QtiDiagnostic["source"],
550
589
  ): void {
@@ -558,7 +597,10 @@ function validateRounding(
558
597
  });
559
598
  }
560
599
  const validFigures =
561
- Number.isInteger(figures) && (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0);
600
+ typeof figures === "string"
601
+ ? variables.has(figures)
602
+ : Number.isInteger(figures) &&
603
+ (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0);
562
604
  if (!validFigures) {
563
605
  diagnostics.push({
564
606
  code: "processing.roundingFigures",
@@ -683,12 +725,7 @@ function minimumExpressionElements(expression: QtiProcessingExpression): number
683
725
  return minimumProducedElements(expression.expressions);
684
726
  }
685
727
  if (expression.type === "random") {
686
- if (expression.values.length === 0) return 0;
687
- let minimum = MAX_QTI_REPEAT_RESULT_ELEMENTS + 1;
688
- for (const value of expression.values) {
689
- minimum = Math.min(minimum, minimumExpressionElements(value));
690
- }
691
- return minimum;
728
+ return minimumExpressionElements(expression.expression) > 0 ? 1 : 0;
692
729
  }
693
730
  if (expression.type !== "repeat" || !/^\d+$/.test(expression.numberRepeats)) return 0;
694
731
  const perRepeat = minimumProducedElements(expression.expressions);
package/src/validation.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateAssessmentItemChildren } from "./validation-item-children.js";
1
2
  import type {
2
3
  QtiAssessmentItem,
3
4
  QtiBaseType,
@@ -44,6 +45,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
44
45
 
45
46
  requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
46
47
  validateAssessmentItemRoot(item, diagnostics);
48
+ validateAssessmentItemChildren(item, diagnostics);
47
49
  validateItemBody(item, diagnostics);
48
50
  validateItemBodySharedVocabulary(item, diagnostics);
49
51
  validateDeclarationIdentifiers(item, diagnostics);
@@ -132,6 +134,18 @@ function validateDeclarationIdentifiers(
132
134
  source: declaration.source,
133
135
  });
134
136
  }
137
+ if (
138
+ ["numAttempts", "duration", "QTI_CONTEXT"].includes(declaration.identifier) ||
139
+ (declaration.identifier === COMPLETION_STATUS && declaration.kind !== "outcome")
140
+ ) {
141
+ diagnostics.push({
142
+ code: "declaration.builtIn",
143
+ severity: "error",
144
+ message: `${declaration.identifier} is a reserved built-in variable and must not be declared explicitly.`,
145
+ path: declaration.source?.path,
146
+ source: declaration.source,
147
+ });
148
+ }
135
149
  if (seen.has(declaration.identifier)) {
136
150
  diagnostics.push({
137
151
  code: "identifier.duplicate",
@@ -0,0 +1,6 @@
1
+ import type { QtiRecordValue, QtiValue } from "./types.js";
2
+
3
+ /** Narrow an already-typed QTI value to its record variant. */
4
+ export function isRecordValue(value: QtiValue): value is QtiRecordValue {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }