@longsightgroup/qti3-core 0.10.5 → 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 (229) hide show
  1. package/README.md +153 -4
  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/catalog.d.ts +8 -0
  11. package/dist/catalog.d.ts.map +1 -1
  12. package/dist/catalog.js +10 -0
  13. package/dist/catalog.js.map +1 -1
  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 +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/item-scoring-expectation.d.ts +2 -0
  23. package/dist/item-scoring-expectation.d.ts.map +1 -1
  24. package/dist/item-scoring-expectation.js +8 -0
  25. package/dist/item-scoring-expectation.js.map +1 -1
  26. package/dist/item-submission-materialization.d.ts.map +1 -1
  27. package/dist/item-submission-materialization.js +2 -0
  28. package/dist/item-submission-materialization.js.map +1 -1
  29. package/dist/parser-processing.d.ts.map +1 -1
  30. package/dist/parser-processing.js +8 -18
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts.map +1 -1
  33. package/dist/parser-values.js +1 -3
  34. package/dist/parser-values.js.map +1 -1
  35. package/dist/parser.js +36 -6
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-equality.d.ts +10 -0
  38. package/dist/processing-equality.d.ts.map +1 -0
  39. package/dist/processing-equality.js +78 -0
  40. package/dist/processing-equality.js.map +1 -0
  41. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  42. package/dist/processing-evaluator-comparison.js +14 -9
  43. package/dist/processing-evaluator-comparison.js.map +1 -1
  44. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  45. package/dist/processing-evaluator-numeric.js +2 -1
  46. package/dist/processing-evaluator-numeric.js.map +1 -1
  47. package/dist/processing-evaluator-string.d.ts.map +1 -1
  48. package/dist/processing-evaluator-string.js +26 -10
  49. package/dist/processing-evaluator-string.js.map +1 -1
  50. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  51. package/dist/processing-evaluator-variable.js +9 -3
  52. package/dist/processing-evaluator-variable.js.map +1 -1
  53. package/dist/processing-evaluator.d.ts +5 -1
  54. package/dist/processing-evaluator.d.ts.map +1 -1
  55. package/dist/processing-evaluator.js +13 -4
  56. package/dist/processing-evaluator.js.map +1 -1
  57. package/dist/processing-expression-children.d.ts.map +1 -1
  58. package/dist/processing-expression-children.js +2 -3
  59. package/dist/processing-expression-children.js.map +1 -1
  60. package/dist/processing-expression-tags.d.ts +2 -1
  61. package/dist/processing-expression-tags.d.ts.map +1 -1
  62. package/dist/processing-expression-tags.js +2 -1
  63. package/dist/processing-expression-tags.js.map +1 -1
  64. package/dist/processing-mapping.d.ts.map +1 -1
  65. package/dist/processing-mapping.js +26 -16
  66. package/dist/processing-mapping.js.map +1 -1
  67. package/dist/processing-state.d.ts +2 -2
  68. package/dist/processing-state.d.ts.map +1 -1
  69. package/dist/processing-state.js +5 -2
  70. package/dist/processing-state.js.map +1 -1
  71. package/dist/processing-values.d.ts +1 -2
  72. package/dist/processing-values.d.ts.map +1 -1
  73. package/dist/processing-values.js +1 -3
  74. package/dist/processing-values.js.map +1 -1
  75. package/dist/processing-variables.d.ts.map +1 -1
  76. package/dist/processing-variables.js +10 -5
  77. package/dist/processing-variables.js.map +1 -1
  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-plan.d.ts +1 -1
  89. package/dist/qti-package-plan.d.ts.map +1 -1
  90. package/dist/qti-package-types.d.ts +9 -0
  91. package/dist/qti-package-types.d.ts.map +1 -1
  92. package/dist/qti-package-xml.d.ts +6 -1
  93. package/dist/qti-package-xml.d.ts.map +1 -1
  94. package/dist/qti-package-xml.js +11 -6
  95. package/dist/qti-package-xml.js.map +1 -1
  96. package/dist/qti-package-zip.d.ts +13 -1
  97. package/dist/qti-package-zip.d.ts.map +1 -1
  98. package/dist/qti-package-zip.js +185 -54
  99. package/dist/qti-package-zip.js.map +1 -1
  100. package/dist/qti-package.d.ts +12 -5
  101. package/dist/qti-package.d.ts.map +1 -1
  102. package/dist/qti-package.js +63 -8
  103. package/dist/qti-package.js.map +1 -1
  104. package/dist/response-validation-policy.d.ts +3 -1
  105. package/dist/response-validation-policy.d.ts.map +1 -1
  106. package/dist/response-validation-policy.js +66 -6
  107. package/dist/response-validation-policy.js.map +1 -1
  108. package/dist/response-validation.d.ts +3 -1
  109. package/dist/response-validation.d.ts.map +1 -1
  110. package/dist/response-validation.js +32 -10
  111. package/dist/response-validation.js.map +1 -1
  112. package/dist/scoring-disposition-policy.d.ts.map +1 -1
  113. package/dist/scoring-disposition-policy.js +6 -2
  114. package/dist/scoring-disposition-policy.js.map +1 -1
  115. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  116. package/dist/serializer-processing-expressions.js +2 -10
  117. package/dist/serializer-processing-expressions.js.map +1 -1
  118. package/dist/server-scoring.d.ts.map +1 -1
  119. package/dist/server-scoring.js +1 -0
  120. package/dist/server-scoring.js.map +1 -1
  121. package/dist/session-builtins.d.ts +16 -0
  122. package/dist/session-builtins.d.ts.map +1 -0
  123. package/dist/session-builtins.js +58 -0
  124. package/dist/session-builtins.js.map +1 -0
  125. package/dist/session.d.ts +5 -1
  126. package/dist/session.d.ts.map +1 -1
  127. package/dist/session.js +63 -11
  128. package/dist/session.js.map +1 -1
  129. package/dist/support-evidence.d.ts.map +1 -1
  130. package/dist/support-evidence.js +16 -3
  131. package/dist/support-evidence.js.map +1 -1
  132. package/dist/support.d.ts.map +1 -1
  133. package/dist/support.js +72 -24
  134. package/dist/support.js.map +1 -1
  135. package/dist/text-response.d.ts +9 -0
  136. package/dist/text-response.d.ts.map +1 -0
  137. package/dist/text-response.js +102 -0
  138. package/dist/text-response.js.map +1 -0
  139. package/dist/trusted-item-session.d.ts +4 -1
  140. package/dist/trusted-item-session.d.ts.map +1 -1
  141. package/dist/trusted-item-session.js +12 -6
  142. package/dist/trusted-item-session.js.map +1 -1
  143. package/dist/types.d.ts +23 -3
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/validation-declaration-entries.d.ts.map +1 -1
  146. package/dist/validation-declaration-entries.js +11 -1
  147. package/dist/validation-declaration-entries.js.map +1 -1
  148. package/dist/validation-interactions.d.ts.map +1 -1
  149. package/dist/validation-interactions.js +102 -12
  150. package/dist/validation-interactions.js.map +1 -1
  151. package/dist/validation-item-children.d.ts +4 -0
  152. package/dist/validation-item-children.d.ts.map +1 -0
  153. package/dist/validation-item-children.js +45 -0
  154. package/dist/validation-item-children.js.map +1 -0
  155. package/dist/validation-processing.d.ts.map +1 -1
  156. package/dist/validation-processing.js +45 -12
  157. package/dist/validation-processing.js.map +1 -1
  158. package/dist/validation.d.ts.map +1 -1
  159. package/dist/validation.js +12 -0
  160. package/dist/validation.js.map +1 -1
  161. package/dist/value-guards.d.ts +4 -0
  162. package/dist/value-guards.d.ts.map +1 -0
  163. package/dist/value-guards.js +5 -0
  164. package/dist/value-guards.js.map +1 -0
  165. package/dist/xml-schema-regex-data.d.ts +48 -0
  166. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  167. package/dist/xml-schema-regex-data.js +403 -0
  168. package/dist/xml-schema-regex-data.js.map +1 -0
  169. package/dist/xml-schema-regex-parser.d.ts +31 -0
  170. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  171. package/dist/xml-schema-regex-parser.js +245 -0
  172. package/dist/xml-schema-regex-parser.js.map +1 -0
  173. package/dist/xml-schema-regex.d.ts +14 -0
  174. package/dist/xml-schema-regex.d.ts.map +1 -0
  175. package/dist/xml-schema-regex.js +114 -0
  176. package/dist/xml-schema-regex.js.map +1 -0
  177. package/package.json +1 -1
  178. package/src/adaptive-turn-materializer.ts +4 -3
  179. package/src/adaptive-turn.ts +1 -0
  180. package/src/attempt-state.ts +54 -1
  181. package/src/catalog.ts +29 -0
  182. package/src/diagnostics.ts +29 -0
  183. package/src/index.ts +16 -0
  184. package/src/item-scoring-expectation.ts +8 -0
  185. package/src/item-submission-materialization.ts +2 -0
  186. package/src/parser-processing.ts +15 -18
  187. package/src/parser-values.ts +1 -5
  188. package/src/parser.ts +38 -6
  189. package/src/processing-equality.ts +104 -0
  190. package/src/processing-evaluator-comparison.ts +19 -14
  191. package/src/processing-evaluator-numeric.ts +2 -6
  192. package/src/processing-evaluator-string.ts +25 -18
  193. package/src/processing-evaluator-variable.ts +8 -3
  194. package/src/processing-evaluator.ts +20 -7
  195. package/src/processing-expression-children.ts +2 -3
  196. package/src/processing-expression-tags.ts +2 -1
  197. package/src/processing-mapping.ts +31 -25
  198. package/src/processing-state.ts +6 -1
  199. package/src/processing-values.ts +2 -11
  200. package/src/processing-variables.ts +9 -7
  201. package/src/qti-package-assets.ts +20 -13
  202. package/src/qti-package-manifest.ts +1 -0
  203. package/src/qti-package-metadata.ts +11 -0
  204. package/src/qti-package-plan.ts +1 -1
  205. package/src/qti-package-types.ts +10 -0
  206. package/src/qti-package-xml.ts +11 -6
  207. package/src/qti-package-zip.ts +330 -97
  208. package/src/qti-package.ts +106 -7
  209. package/src/response-validation-policy.ts +76 -4
  210. package/src/response-validation.ts +51 -5
  211. package/src/scoring-disposition-policy.ts +7 -2
  212. package/src/serializer-processing-expressions.ts +8 -25
  213. package/src/server-scoring.ts +1 -0
  214. package/src/session-builtins.ts +75 -0
  215. package/src/session.ts +64 -8
  216. package/src/support-evidence.ts +16 -3
  217. package/src/support.ts +94 -24
  218. package/src/text-response.ts +106 -0
  219. package/src/trusted-item-session.ts +17 -5
  220. package/src/types.ts +32 -4
  221. package/src/validation-declaration-entries.ts +11 -1
  222. package/src/validation-interactions.ts +139 -14
  223. package/src/validation-item-children.ts +48 -0
  224. package/src/validation-processing.ts +47 -10
  225. package/src/validation.ts +14 -0
  226. package/src/value-guards.ts +6 -0
  227. package/src/xml-schema-regex-data.ts +404 -0
  228. package/src/xml-schema-regex-parser.ts +234 -0
  229. package/src/xml-schema-regex.ts +115 -0
package/src/types.ts CHANGED
@@ -250,6 +250,8 @@ export interface QtiGapMatchGapSegment {
250
250
  }
251
251
 
252
252
  export interface QtiObjectAsset {
253
+ /** Attributes of an authored image or picture fallback, when present. */
254
+ imageAttributes?: Record<string, string> | undefined;
253
255
  data?: string | undefined;
254
256
  type?: string | undefined;
255
257
  width?: string | undefined;
@@ -457,6 +459,13 @@ export interface QtiAssessmentItem {
457
459
  stylesheets: QtiStylesheet[];
458
460
  body: QtiContentNode[];
459
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;
460
469
  source?: QtiSourceLocation | undefined;
461
470
  }
462
471
 
@@ -591,7 +600,7 @@ export type QtiProcessingExpression = (
591
600
  baseType?: string | undefined;
592
601
  }
593
602
  | { type: "null" }
594
- | { type: "isNull"; identifier: string }
603
+ | { type: "isNull"; expression: QtiProcessingExpression }
595
604
  | { type: "matchCorrect"; identifier: string; correctIdentifier: string }
596
605
  | { type: "match"; left: QtiProcessingExpression; right: QtiProcessingExpression }
597
606
  | { type: "correct"; identifier: string }
@@ -612,7 +621,7 @@ export type QtiProcessingExpression = (
612
621
  max: number;
613
622
  attributes: Record<string, string>;
614
623
  }
615
- | { type: "random"; values: QtiProcessingExpression[] }
624
+ | { type: "random"; expression: QtiProcessingExpression }
616
625
  | { type: "multiple"; expressions: QtiProcessingExpression[] }
617
626
  | { type: "ordered"; expressions: QtiProcessingExpression[] }
618
627
  | { type: "index"; expression: QtiProcessingExpression; n: string }
@@ -639,13 +648,18 @@ export type QtiProcessingExpression = (
639
648
  | { type: "anyN"; expressions: QtiProcessingExpression[]; min: string; max: string }
640
649
  | { type: "or"; expressions: QtiProcessingExpression[] }
641
650
  | { type: "not"; expression: QtiProcessingExpression }
642
- | { type: "equal"; left: QtiProcessingExpression; right: QtiProcessingExpression }
651
+ | {
652
+ type: "equal";
653
+ left: QtiProcessingExpression;
654
+ right: QtiProcessingExpression;
655
+ attributes?: Record<string, string> | undefined;
656
+ }
643
657
  | {
644
658
  type: "equalRounded";
645
659
  left: QtiProcessingExpression;
646
660
  right: QtiProcessingExpression;
647
661
  roundingMode: string;
648
- figures: number;
662
+ figures: number | string;
649
663
  }
650
664
  | {
651
665
  type: "numericCompare";
@@ -722,8 +736,22 @@ export interface QtiValidationResult {
722
736
  diagnostics: QtiDiagnostic[];
723
737
  }
724
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
+
725
752
  export interface QtiAttemptStateV1 {
726
753
  schema: "qti3.attempt-state.v1";
754
+ builtInVariables?: QtiBuiltInVariables | undefined;
727
755
  itemIdentifier: string;
728
756
  status: QtiAttemptStatus;
729
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",
@@ -34,10 +34,13 @@ export function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDi
34
34
  validateInteractionSharedVocabulary(interaction, diagnostics);
35
35
  validateInteractionChoices(interaction, diagnostics);
36
36
  validateInteractionChildren(interaction, diagnostics);
37
+ validateGraphicGapMatchChildren(interaction, diagnostics);
38
+ validatePositionObjectChildren(interaction, diagnostics);
37
39
  validateInteractionRequiredAttributes(interaction, diagnostics);
38
40
  validatePortableCustomInteraction(interaction, item, diagnostics);
39
41
  validateInteractionLimitAttributes(interaction, diagnostics);
40
42
  validatePatternMaskAttribute(interaction, diagnostics);
43
+ validateTextInteractionContract(interaction, responseDeclarations, diagnostics);
41
44
  validateGraphicHotspotObjectDimensions(interaction, diagnostics);
42
45
  validateCorrectResponseReferences(
43
46
  interaction,
@@ -649,7 +652,7 @@ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> |
649
652
  case "order":
650
653
  return setOf(common, ["qti-simple-choice"]);
651
654
  case "associate":
652
- return setOf(common, ["qti-simple-match-set", "qti-simple-associable-choice"]);
655
+ return setOf(common, ["qti-simple-associable-choice"]);
653
656
  case "match":
654
657
  return setOf(common, ["qti-simple-match-set"]);
655
658
  case "gapMatch":
@@ -659,22 +662,24 @@ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> |
659
662
  case "hottext":
660
663
  return setOf(common, staticContentNames());
661
664
  case "graphicOrder":
662
- return setOf(common, ["object", "qti-hotspot-choice"]);
665
+ return setOf(common, ["object", "img", "picture", "qti-hotspot-choice"]);
663
666
  case "graphicAssociate":
664
- return setOf(common, ["object", "qti-associable-hotspot"]);
667
+ return setOf(common, ["object", "img", "picture", "qti-associable-hotspot"]);
665
668
  case "graphicGapMatch":
666
669
  return setOf(common, [
667
670
  "object",
671
+ "img",
672
+ "picture",
668
673
  "qti-gap-text",
669
674
  "qti-gap-img",
670
675
  "qti-associable-hotspot",
671
- ...staticContentNames(),
672
676
  ]);
673
677
  case "hotspot":
674
- return setOf(common, ["object", "qti-hotspot-choice"]);
678
+ return setOf(common, ["object", "img", "picture", "qti-hotspot-choice"]);
675
679
  case "positionObject":
676
- return setOf(common, ["object", "img", "qti-position-object-stage"]);
680
+ return new Set(["object", "img", "picture"]);
677
681
  case "selectPoint":
682
+ return setOf(common, ["object", "img", "picture"]);
678
683
  case "media":
679
684
  return setOf(common, ["audio", "video", "object", "img"]);
680
685
  case "drawing":
@@ -722,13 +727,12 @@ function expectedResponseShape(
722
727
  return { cardinalities: ["ordered"], baseTypes: ["identifier"] };
723
728
  }
724
729
  if (interaction.type === "associate" || interaction.type === "graphicAssociate") {
725
- return { cardinalities: ["multiple"], baseTypes: ["pair", "directedPair"] };
730
+ return { cardinalities: ["single", "multiple"], baseTypes: ["pair"] };
726
731
  }
727
- if (
728
- interaction.type === "match" ||
729
- interaction.type === "gapMatch" ||
730
- interaction.type === "graphicGapMatch"
731
- ) {
732
+ if (interaction.type === "match" || interaction.type === "gapMatch") {
733
+ return { cardinalities: ["single", "multiple"], baseTypes: ["directedPair"] };
734
+ }
735
+ if (interaction.type === "graphicGapMatch") {
732
736
  return { cardinalities: ["multiple"], baseTypes: ["directedPair"] };
733
737
  }
734
738
  if (interaction.type === "selectPoint" || interaction.type === "positionObject") {
@@ -740,8 +744,14 @@ function expectedResponseShape(
740
744
  if (interaction.type === "upload") {
741
745
  return { cardinalities: ["single"], baseTypes: ["file"] };
742
746
  }
743
- if (interaction.type === "textEntry" || interaction.type === "extendedText") {
744
- return { cardinalities: ["single"], baseTypes: ["string"] };
747
+ if (interaction.type === "textEntry") {
748
+ return { cardinalities: ["single", "record"], baseTypes: ["string", "integer", "float"] };
749
+ }
750
+ if (interaction.type === "extendedText") {
751
+ return {
752
+ cardinalities: ["single", "multiple", "ordered", "record"],
753
+ baseTypes: ["string", "integer", "float"],
754
+ };
745
755
  }
746
756
  if (interaction.type === "drawing") return { cardinalities: ["single"], baseTypes: ["file"] };
747
757
  if (interaction.type === "portableCustom") {
@@ -780,3 +790,118 @@ function needsChoices(interaction: QtiInteraction): boolean {
780
790
  interaction.type === "hotspot"
781
791
  );
782
792
  }
793
+
794
+ function validateTextInteractionContract(
795
+ interaction: QtiInteraction,
796
+ declarations: ReadonlyMap<string, QtiResponseDeclaration>,
797
+ diagnostics: QtiDiagnostic[],
798
+ ): void {
799
+ if (interaction.type !== "textEntry" && interaction.type !== "extendedText") return;
800
+ const report = (code: string, message: string) =>
801
+ diagnostics.push({
802
+ code,
803
+ severity: "error",
804
+ message,
805
+ path: interaction.source?.path,
806
+ source: interaction.source,
807
+ });
808
+ const base = Number(interaction.attributes.base ?? 10);
809
+ if (!Number.isInteger(base) || base < 2 || base > 36) {
810
+ report("interaction.text.base", "Text response base must be an integer from 2 through 36.");
811
+ }
812
+ const format = interaction.attributes.format;
813
+ if (
814
+ format !== undefined &&
815
+ format !== "plain" &&
816
+ !(interaction.type === "extendedText" && format === "preformatted") &&
817
+ !(
818
+ interaction.type === "extendedText" &&
819
+ format === "xhtml" &&
820
+ interaction.responseBaseType === "string"
821
+ )
822
+ ) {
823
+ report(
824
+ "interaction.text.format.unsupported",
825
+ `Text format ${format} is not supported; use plain text.`,
826
+ );
827
+ }
828
+ if (interaction.type === "extendedText") {
829
+ const container =
830
+ interaction.responseCardinality === "multiple" ||
831
+ interaction.responseCardinality === "ordered";
832
+ const max = interaction.attributes["max-strings"];
833
+ const min = interaction.attributes["min-strings"] ?? "0";
834
+ if (
835
+ (container && max === undefined) ||
836
+ (max !== undefined && !isNonNegativeInteger(max)) ||
837
+ !isNonNegativeInteger(min) ||
838
+ Number(min) > (container ? Number(max) : 1)
839
+ ) {
840
+ report(
841
+ "interaction.text.strings",
842
+ "Extended text requires valid min-strings/max-strings limits; containers require max-strings and the minimum must not exceed the maximum.",
843
+ );
844
+ }
845
+ }
846
+ const companion = interaction.attributes["string-identifier"];
847
+ if (companion !== undefined) {
848
+ const declaration = declarations.get(companion);
849
+ if (
850
+ !declaration ||
851
+ declaration.baseType !== "string" ||
852
+ declaration.cardinality !== interaction.responseCardinality ||
853
+ companion === interaction.responseIdentifier ||
854
+ (interaction.responseBaseType !== "integer" && interaction.responseBaseType !== "float")
855
+ ) {
856
+ report(
857
+ "interaction.text.stringIdentifier",
858
+ "string-identifier must reference a separate string response with matching cardinality for a numeric text interaction.",
859
+ );
860
+ }
861
+ }
862
+ }
863
+
864
+ function validateGraphicGapMatchChildren(
865
+ interaction: QtiInteraction,
866
+ diagnostics: QtiDiagnostic[],
867
+ ): void {
868
+ if (interaction.type !== "graphicGapMatch") return;
869
+ const names = interaction.childElements.map((child) => child.qtiName);
870
+ const sequence = names.join(" ");
871
+ if (
872
+ /^(?:qti-prompt )?(?:object|img|picture)(?: qti-gap-(?:text|img))+(?: qti-associable-hotspot)+$/.test(
873
+ sequence,
874
+ )
875
+ )
876
+ return;
877
+ diagnostics.push({
878
+ code: "interaction.graphicGapMatch.children",
879
+ severity: "error",
880
+ message:
881
+ "Graphic Gap Match requires an optional prompt, one image, gap choices, then one or more associable hotspots, in that order.",
882
+ path: interaction.source?.path,
883
+ source: interaction.source,
884
+ });
885
+ }
886
+
887
+ function validatePositionObjectChildren(
888
+ interaction: QtiInteraction,
889
+ diagnostics: QtiDiagnostic[],
890
+ ): void {
891
+ if (interaction.type !== "positionObject") return;
892
+ const [child] = interaction.childElements;
893
+ if (
894
+ interaction.childElements.length === 1 &&
895
+ child &&
896
+ ["object", "img", "picture"].includes(child.qtiName)
897
+ )
898
+ return;
899
+ diagnostics.push({
900
+ code: "interaction.positionObject.children",
901
+ severity: "error",
902
+ message:
903
+ "Position Object requires exactly one marker image (object, img, or picture); instructions belong before its stage.",
904
+ path: interaction.source?.path,
905
+ source: interaction.source,
906
+ });
907
+ }
@@ -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
+ }