@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
@@ -0,0 +1,37 @@
1
+ import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
2
+ import type { QtiDiagnostic } from "./types.js";
3
+ import type { XmlNode } from "./xml.js";
4
+
5
+ /** Report an unsupported construct at its XML source location. */
6
+ export function rejectTestXml(node: XmlNode, diagnostics: QtiDiagnostic[]): void {
7
+ diagnostics.push({
8
+ code: "test.xml.unsupported",
9
+ severity: "error",
10
+ message: `Unsupported test element or attributes: ${node.localName}.`,
11
+ source: node.source,
12
+ });
13
+ }
14
+
15
+ /** Check the closed test profile's namespace, attributes, children and element-only content. */
16
+ export function checkTestXml(
17
+ node: XmlNode,
18
+ names: readonly string[],
19
+ attrs: readonly string[],
20
+ diagnostics: QtiDiagnostic[],
21
+ allowText = false,
22
+ ): void {
23
+ if (
24
+ node.uri !== QTI_ASI_NAMESPACE ||
25
+ Object.keys(node.attributes).some(
26
+ (name) =>
27
+ name !== "xmlns" &&
28
+ !name.startsWith("xmlns:") &&
29
+ !["xsi:schemaLocation", "xml:lang", ...attrs].includes(name),
30
+ ) ||
31
+ (!allowText && node.text.trim().length > 0)
32
+ )
33
+ rejectTestXml(node, diagnostics);
34
+ for (const child of node.children)
35
+ if (child.uri !== QTI_ASI_NAMESPACE || !names.includes(child.localName))
36
+ rejectTestXml(child, diagnostics);
37
+ }
@@ -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>;
@@ -744,6 +770,7 @@ export interface QtiScoreResult {
744
770
  export type QtiElementSupport =
745
771
  | QtiInteractionElementSupport
746
772
  | QtiProcessingElementSupport
773
+ | QtiTestElementSupport
747
774
  | QtiItemMetadataElementSupport;
748
775
 
749
776
  interface QtiElementSupportBase {
@@ -768,6 +795,11 @@ export interface QtiProcessingElementSupport extends QtiElementSupportBase {
768
795
  category: "processing";
769
796
  }
770
797
 
798
+ /** Executable assessment-test support, separate from item processing/rendering. */
799
+ export interface QtiTestElementSupport extends QtiElementSupportBase {
800
+ category: "test";
801
+ }
802
+
771
803
  export interface QtiItemMetadataElementSupport extends QtiElementSupportBase {
772
804
  category: "itemMetadata";
773
805
  }
@@ -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
+ }