@longsightgroup/qti3-core 0.10.2 → 0.10.4

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 (188) hide show
  1. package/README.md +21 -2
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +20 -27
  4. package/dist/attempt-state.js.map +1 -1
  5. package/dist/content-text.d.ts +4 -0
  6. package/dist/content-text.d.ts.map +1 -1
  7. package/dist/content-text.js +137 -12
  8. package/dist/content-text.js.map +1 -1
  9. package/dist/index.d.ts +6 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/media-definition.d.ts +55 -0
  14. package/dist/media-definition.d.ts.map +1 -0
  15. package/dist/media-definition.js +97 -0
  16. package/dist/media-definition.js.map +1 -0
  17. package/dist/package-xml.d.ts +1 -0
  18. package/dist/package-xml.d.ts.map +1 -1
  19. package/dist/package-xml.js +1 -0
  20. package/dist/package-xml.js.map +1 -1
  21. package/dist/parser-custom-interactions.js +1 -0
  22. package/dist/parser-custom-interactions.js.map +1 -1
  23. package/dist/parser-declarations.d.ts +4 -4
  24. package/dist/parser-declarations.d.ts.map +1 -1
  25. package/dist/parser-declarations.js +87 -46
  26. package/dist/parser-declarations.js.map +1 -1
  27. package/dist/parser-item-metadata.d.ts.map +1 -1
  28. package/dist/parser-item-metadata.js +5 -3
  29. package/dist/parser-item-metadata.js.map +1 -1
  30. package/dist/parser-processing.js +10 -9
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts +6 -0
  33. package/dist/parser-values.d.ts.map +1 -1
  34. package/dist/parser-values.js +35 -2
  35. package/dist/parser-values.js.map +1 -1
  36. package/dist/parser.d.ts.map +1 -1
  37. package/dist/parser.js +81 -47
  38. package/dist/parser.js.map +1 -1
  39. package/dist/processing-evaluator-collection.d.ts.map +1 -1
  40. package/dist/processing-evaluator-collection.js +8 -2
  41. package/dist/processing-evaluator-collection.js.map +1 -1
  42. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  43. package/dist/processing-evaluator-comparison.js +22 -6
  44. package/dist/processing-evaluator-comparison.js.map +1 -1
  45. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  46. package/dist/processing-evaluator-numeric.js +8 -1
  47. package/dist/processing-evaluator-numeric.js.map +1 -1
  48. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  49. package/dist/processing-evaluator-variable.js +1 -1
  50. package/dist/processing-evaluator-variable.js.map +1 -1
  51. package/dist/processing-expression-children.d.ts.map +1 -1
  52. package/dist/processing-expression-children.js +2 -1
  53. package/dist/processing-expression-children.js.map +1 -1
  54. package/dist/processing-limits.d.ts +3 -0
  55. package/dist/processing-limits.d.ts.map +1 -0
  56. package/dist/processing-limits.js +3 -0
  57. package/dist/processing-limits.js.map +1 -0
  58. package/dist/processing-mapping.d.ts.map +1 -1
  59. package/dist/processing-mapping.js +28 -9
  60. package/dist/processing-mapping.js.map +1 -1
  61. package/dist/processing-operators.d.ts +2 -0
  62. package/dist/processing-operators.d.ts.map +1 -1
  63. package/dist/processing-operators.js +34 -5
  64. package/dist/processing-operators.js.map +1 -1
  65. package/dist/processing-values.d.ts +4 -4
  66. package/dist/processing-values.d.ts.map +1 -1
  67. package/dist/processing-values.js +28 -14
  68. package/dist/processing-values.js.map +1 -1
  69. package/dist/processing-variables.d.ts +3 -1
  70. package/dist/processing-variables.d.ts.map +1 -1
  71. package/dist/processing-variables.js +38 -2
  72. package/dist/processing-variables.js.map +1 -1
  73. package/dist/qti-namespaces.d.ts +7 -0
  74. package/dist/qti-namespaces.d.ts.map +1 -0
  75. package/dist/qti-namespaces.js +7 -0
  76. package/dist/qti-namespaces.js.map +1 -0
  77. package/dist/qti-package-items.d.ts.map +1 -1
  78. package/dist/qti-package-items.js +23 -13
  79. package/dist/qti-package-items.js.map +1 -1
  80. package/dist/qti-package-manifest.d.ts.map +1 -1
  81. package/dist/qti-package-manifest.js +4 -3
  82. package/dist/qti-package-manifest.js.map +1 -1
  83. package/dist/qti-package-metadata.d.ts.map +1 -1
  84. package/dist/qti-package-metadata.js +3 -2
  85. package/dist/qti-package-metadata.js.map +1 -1
  86. package/dist/qti-package-xml.d.ts +4 -2
  87. package/dist/qti-package-xml.d.ts.map +1 -1
  88. package/dist/qti-package-xml.js +10 -5
  89. package/dist/qti-package-xml.js.map +1 -1
  90. package/dist/qti-package-zip.d.ts +17 -1
  91. package/dist/qti-package-zip.d.ts.map +1 -1
  92. package/dist/qti-package-zip.js +60 -5
  93. package/dist/qti-package-zip.js.map +1 -1
  94. package/dist/qti-package.d.ts +3 -3
  95. package/dist/qti-package.d.ts.map +1 -1
  96. package/dist/qti-package.js +10 -4
  97. package/dist/qti-package.js.map +1 -1
  98. package/dist/response-validation-policy.d.ts.map +1 -1
  99. package/dist/response-validation-policy.js +5 -6
  100. package/dist/response-validation-policy.js.map +1 -1
  101. package/dist/response-validation.d.ts +12 -2
  102. package/dist/response-validation.d.ts.map +1 -1
  103. package/dist/response-validation.js +122 -19
  104. package/dist/response-validation.js.map +1 -1
  105. package/dist/serializer-processing-expressions.js +1 -1
  106. package/dist/serializer-processing-expressions.js.map +1 -1
  107. package/dist/session.js +1 -1
  108. package/dist/session.js.map +1 -1
  109. package/dist/slider-definition.d.ts +59 -0
  110. package/dist/slider-definition.d.ts.map +1 -0
  111. package/dist/slider-definition.js +198 -0
  112. package/dist/slider-definition.js.map +1 -0
  113. package/dist/support-evidence.d.ts +6 -2
  114. package/dist/support-evidence.d.ts.map +1 -1
  115. package/dist/support-evidence.js +15 -11
  116. package/dist/support-evidence.js.map +1 -1
  117. package/dist/support.d.ts.map +1 -1
  118. package/dist/support.js +96 -100
  119. package/dist/support.js.map +1 -1
  120. package/dist/trusted-item-session.d.ts.map +1 -1
  121. package/dist/trusted-item-session.js +5 -3
  122. package/dist/trusted-item-session.js.map +1 -1
  123. package/dist/types.d.ts +5 -0
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/validation-declaration-entries.d.ts +13 -0
  126. package/dist/validation-declaration-entries.d.ts.map +1 -0
  127. package/dist/validation-declaration-entries.js +136 -0
  128. package/dist/validation-declaration-entries.js.map +1 -0
  129. package/dist/validation-interactions.d.ts.map +1 -1
  130. package/dist/validation-interactions.js +14 -46
  131. package/dist/validation-interactions.js.map +1 -1
  132. package/dist/validation-primitives.d.ts +5 -1
  133. package/dist/validation-primitives.d.ts.map +1 -1
  134. package/dist/validation-primitives.js +41 -3
  135. package/dist/validation-primitives.js.map +1 -1
  136. package/dist/validation-processing.d.ts.map +1 -1
  137. package/dist/validation-processing.js +125 -1
  138. package/dist/validation-processing.js.map +1 -1
  139. package/dist/validation.d.ts.map +1 -1
  140. package/dist/validation.js +8 -134
  141. package/dist/validation.js.map +1 -1
  142. package/dist/xml.d.ts.map +1 -1
  143. package/dist/xml.js +31 -2
  144. package/dist/xml.js.map +1 -1
  145. package/package.json +1 -1
  146. package/src/attempt-state.ts +22 -26
  147. package/src/content-text.ts +174 -14
  148. package/src/index.ts +28 -0
  149. package/src/media-definition.ts +174 -0
  150. package/src/package-xml.ts +2 -0
  151. package/src/parser-custom-interactions.ts +1 -0
  152. package/src/parser-declarations.ts +134 -40
  153. package/src/parser-item-metadata.ts +5 -3
  154. package/src/parser-processing.ts +10 -9
  155. package/src/parser-values.ts +35 -2
  156. package/src/parser.ts +137 -103
  157. package/src/processing-evaluator-collection.ts +7 -2
  158. package/src/processing-evaluator-comparison.ts +30 -5
  159. package/src/processing-evaluator-numeric.ts +5 -1
  160. package/src/processing-evaluator-variable.ts +1 -0
  161. package/src/processing-expression-children.ts +2 -1
  162. package/src/processing-limits.ts +2 -0
  163. package/src/processing-mapping.ts +32 -8
  164. package/src/processing-operators.ts +38 -5
  165. package/src/processing-values.ts +59 -14
  166. package/src/processing-variables.ts +47 -2
  167. package/src/qti-namespaces.ts +8 -0
  168. package/src/qti-package-items.ts +56 -49
  169. package/src/qti-package-manifest.ts +13 -4
  170. package/src/qti-package-metadata.ts +3 -2
  171. package/src/qti-package-xml.ts +16 -3
  172. package/src/qti-package-zip.ts +128 -3
  173. package/src/qti-package.ts +12 -3
  174. package/src/response-validation-policy.ts +5 -5
  175. package/src/response-validation.ts +185 -20
  176. package/src/serializer-processing-expressions.ts +1 -1
  177. package/src/session.ts +1 -0
  178. package/src/slider-definition.ts +363 -0
  179. package/src/support-evidence.ts +15 -17
  180. package/src/support.ts +106 -102
  181. package/src/trusted-item-session.ts +5 -3
  182. package/src/types.ts +10 -1
  183. package/src/validation-declaration-entries.ts +162 -0
  184. package/src/validation-interactions.ts +14 -64
  185. package/src/validation-primitives.ts +44 -4
  186. package/src/validation-processing.ts +145 -1
  187. package/src/validation.ts +11 -151
  188. package/src/xml.ts +40 -4
@@ -9,6 +9,8 @@ import type {
9
9
  } from "./types.js";
10
10
  import { assertNever } from "./assert-never.js";
11
11
  import { isValidQtiPatternMask } from "./pattern-mask.js";
12
+ import { parseQtiMediaDefinition } from "./media-definition.js";
13
+ import { parseQtiSliderDefinition } from "./slider-definition.js";
12
14
  import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interaction-validation.js";
13
15
  import { qtiValueToStringList } from "./value-format.js";
14
16
  import {
@@ -17,7 +19,6 @@ import {
17
19
  } from "./validation-geometry.js";
18
20
  import {
19
21
  isBooleanAttribute,
20
- isFiniteNumber,
21
22
  isNonNegativeInteger,
22
23
  requireIdentifier,
23
24
  } from "./validation-primitives.js";
@@ -104,7 +105,11 @@ function validateInteractionResponseShape(
104
105
  });
105
106
  }
106
107
 
107
- if (interaction.responseBaseType && !expected.baseTypes.includes(interaction.responseBaseType)) {
108
+ if (
109
+ interaction.type !== "slider" &&
110
+ interaction.responseBaseType &&
111
+ !expected.baseTypes.includes(interaction.responseBaseType)
112
+ ) {
108
113
  diagnostics.push({
109
114
  code: "interaction.baseType",
110
115
  severity: "error",
@@ -305,46 +310,13 @@ function validateInteractionRequiredAttributes(
305
310
  }
306
311
 
307
312
  if (interaction.type === "slider") {
308
- const lower = interaction.attributes["lower-bound"];
309
- const upper = interaction.attributes["upper-bound"];
310
- requireInteractionAttribute(
311
- interaction,
312
- "lower-bound",
313
- "interaction.slider.lowerBound",
314
- diagnostics,
315
- );
316
- requireInteractionAttribute(
317
- interaction,
318
- "upper-bound",
319
- "interaction.slider.upperBound",
320
- diagnostics,
321
- );
322
- if (lower !== undefined && !isFiniteNumber(lower)) {
323
- invalidNumber(interaction, "lower-bound", lower, diagnostics);
324
- }
325
- if (upper !== undefined && !isFiniteNumber(upper)) {
326
- invalidNumber(interaction, "upper-bound", upper, diagnostics);
327
- }
328
- if (
329
- lower !== undefined &&
330
- upper !== undefined &&
331
- isFiniteNumber(lower) &&
332
- isFiniteNumber(upper)
333
- ) {
334
- if (Number(lower) >= Number(upper)) {
335
- diagnostics.push({
336
- code: "interaction.slider.bounds",
337
- severity: "error",
338
- message: `${interaction.qtiName} requires lower-bound to be less than upper-bound.`,
339
- path: interaction.source?.path,
340
- source: interaction.source,
341
- });
342
- }
343
- }
344
- const step = interaction.attributes.step;
345
- if (step !== undefined && (!isFiniteNumber(step) || Number(step) <= 0)) {
346
- invalidNumber(interaction, "step", step, diagnostics);
347
- }
313
+ const definition = parseQtiSliderDefinition(interaction);
314
+ if (!definition.ok) diagnostics.push(...definition.diagnostics);
315
+ }
316
+
317
+ if (interaction.type === "media") {
318
+ const definition = parseQtiMediaDefinition(interaction);
319
+ if (!definition.ok) diagnostics.push(...definition.diagnostics);
348
320
  }
349
321
  }
350
322
 
@@ -497,21 +469,6 @@ function warnExternalPortableCustomUrl(
497
469
  });
498
470
  }
499
471
 
500
- function invalidNumber(
501
- interaction: QtiInteraction,
502
- attribute: string,
503
- value: string,
504
- diagnostics: QtiDiagnostic[],
505
- ): void {
506
- diagnostics.push({
507
- code: "interaction.numericAttribute",
508
- severity: "error",
509
- message: `${interaction.qtiName} requires numeric ${attribute}, got ${value}.`,
510
- path: interaction.source?.path,
511
- source: interaction.source,
512
- });
513
- }
514
-
515
472
  function validatePatternMaskAttribute(
516
473
  interaction: QtiInteraction,
517
474
  diagnostics: QtiDiagnostic[],
@@ -542,13 +499,6 @@ function validateInteractionLimitAttributes(
542
499
  validateMinMaxPair(interaction, "min-choices", "max-choices", diagnostics);
543
500
  validateMinMaxPair(interaction, "min-associations", "max-associations", diagnostics);
544
501
  validateBooleanAttribute(interaction, "required", diagnostics);
545
- if (interaction.type === "media") {
546
- validateNonNegativeIntegerAttribute(interaction, "max-plays", diagnostics);
547
- validateNonNegativeIntegerAttribute(interaction, "min-plays", diagnostics);
548
- validateBooleanAttribute(interaction, "autostart", diagnostics);
549
- validateBooleanAttribute(interaction, "loop", diagnostics);
550
- validateMinMaxPair(interaction, "min-plays", "max-plays", diagnostics);
551
- }
552
502
  }
553
503
 
554
504
  function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
@@ -1,5 +1,6 @@
1
- import type { QtiBaseType, QtiCardinality, QtiDiagnostic } from "./types.js";
2
- import { parseXmlBoolean } from "./parser-values.js";
1
+ import type { QtiBaseType, QtiCardinality, QtiDiagnostic, QtiScalarValue } from "./types.js";
2
+ import { assertNever } from "./assert-never.js";
3
+ import { parseFiniteNumber, parseInteger, parseQtiPair, parseXmlBoolean } from "./parser-values.js";
3
4
 
4
5
  export function requireIdentifier(
5
6
  qtiName: string,
@@ -38,11 +39,11 @@ export function isBaseType(value: string): value is QtiBaseType {
38
39
  }
39
40
 
40
41
  export function isFiniteNumber(value: string): boolean {
41
- return Number.isFinite(Number(value));
42
+ return parseFiniteNumber(value) !== undefined;
42
43
  }
43
44
 
44
45
  export function isInteger(value: string): boolean {
45
- return /^-?\d+$/.test(value);
46
+ return parseInteger(value) !== undefined;
46
47
  }
47
48
 
48
49
  export function isNonNegativeInteger(value: string): boolean {
@@ -67,3 +68,42 @@ export function isPair(value: string): boolean {
67
68
  const parts = value.trim().split(/\s+/);
68
69
  return parts.length === 2 && parts.every((part) => part.length > 0);
69
70
  }
71
+
72
+ /** Parse and normalize a runtime scalar for a QTI base type. */
73
+ export function parseQtiScalarForBaseType(
74
+ value: QtiScalarValue,
75
+ baseType: QtiBaseType,
76
+ ): QtiScalarValue | undefined {
77
+ switch (baseType) {
78
+ case "integer":
79
+ if (typeof value === "number") return Number.isInteger(value) ? value : undefined;
80
+ return typeof value === "string" ? parseInteger(value) : undefined;
81
+ case "float":
82
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
83
+ return typeof value === "string" ? parseFiniteNumber(value) : undefined;
84
+ case "boolean":
85
+ if (typeof value === "boolean") return value;
86
+ return typeof value === "string" ? parseXmlBoolean(value) : undefined;
87
+ case "point":
88
+ return typeof value === "string" && isPoint(value) ? value : undefined;
89
+ case "pair":
90
+ case "directedPair":
91
+ return typeof value === "string" ? parseQtiPair(value, baseType) : undefined;
92
+ case "identifier":
93
+ return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value)
94
+ ? value
95
+ : undefined;
96
+ case "string":
97
+ case "duration":
98
+ case "file":
99
+ case "uri":
100
+ return typeof value === "string" ? value : undefined;
101
+ default:
102
+ return assertNever(baseType);
103
+ }
104
+ }
105
+
106
+ /** Check a runtime scalar against the value representation accepted for a QTI base type. */
107
+ export function qtiScalarMatchesBaseType(value: QtiScalarValue, baseType: QtiBaseType): boolean {
108
+ return parseQtiScalarForBaseType(value, baseType) !== undefined;
109
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  QtiTemplateRule,
9
9
  } from "./types.js";
10
10
  import { expressionChildren } from "./processing-expression-children.js";
11
+ import { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
11
12
  import { MATH_OPERATOR_NAMES, STATS_OPERATOR_NAMES } from "./processing-operators.js";
12
13
  import {
13
14
  responseProcessingTemplateKind,
@@ -450,6 +451,11 @@ function validateExpressionReferences(
450
451
  );
451
452
  }
452
453
 
454
+ if (expression.type === "anyN") {
455
+ validateAnyNBound("min", expression.min, responses, variables, diagnostics, expression.source);
456
+ validateAnyNBound("max", expression.max, responses, variables, diagnostics, expression.source);
457
+ }
458
+
453
459
  if (expression.type === "mathConstant" && !mathConstantNames.has(expression.name)) {
454
460
  diagnostics.push({
455
461
  code: "processing.mathConstant.name",
@@ -480,6 +486,35 @@ function validateExpressionReferences(
480
486
  });
481
487
  }
482
488
 
489
+ if (
490
+ (expression.type === "sum" ||
491
+ expression.type === "product" ||
492
+ expression.type === "min" ||
493
+ expression.type === "max") &&
494
+ expression.expressions.length === 0
495
+ ) {
496
+ diagnostics.push({
497
+ code: "processing.numeric.arity",
498
+ severity: "error",
499
+ message: `qti-${expression.type} requires at least one child expression.`,
500
+ path: expression.source?.path,
501
+ source: expression.source,
502
+ });
503
+ }
504
+
505
+ if (
506
+ expression.type === "statsOperator" &&
507
+ (expression.expressions ?? [expression.expression]).length !== 1
508
+ ) {
509
+ diagnostics.push({
510
+ code: "processing.statsOperator.arity",
511
+ severity: "error",
512
+ message: "qti-stats-operator requires exactly one child expression.",
513
+ path: expression.source?.path,
514
+ source: expression.source,
515
+ });
516
+ }
517
+
483
518
  if (expression.type === "repeat") {
484
519
  validateRepeatExpression(expression, variables, diagnostics);
485
520
  }
@@ -540,7 +575,31 @@ function validateRepeatExpression(
540
575
  variables: Set<string>,
541
576
  diagnostics: QtiDiagnostic[],
542
577
  ): void {
543
- if (isInteger(expression.numberRepeats)) return;
578
+ if (isInteger(expression.numberRepeats)) {
579
+ const repeats = BigInt(expression.numberRepeats);
580
+ if (repeats < 0n) {
581
+ diagnostics.push(repeatCountDiagnostic(expression));
582
+ return;
583
+ }
584
+ const minimumPerRepeat = minimumProducedElements(expression.expressions);
585
+ if (
586
+ repeats > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS) ||
587
+ repeats * BigInt(minimumPerRepeat) > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS)
588
+ ) {
589
+ diagnostics.push({
590
+ code: "processing.repeat.limit",
591
+ severity: "error",
592
+ message: `qti-repeat cannot exceed ${MAX_QTI_REPEAT_RESULT_ELEMENTS} iterations or produced elements.`,
593
+ path: expression.source?.path,
594
+ source: expression.source,
595
+ });
596
+ }
597
+ return;
598
+ }
599
+ if (Number.isFinite(Number(expression.numberRepeats))) {
600
+ diagnostics.push(repeatCountDiagnostic(expression));
601
+ return;
602
+ }
544
603
  validateProcessingIdentifier(
545
604
  expression.numberRepeats,
546
605
  "processing.repeat.numberRepeats",
@@ -558,6 +617,91 @@ function validateRepeatExpression(
558
617
  }
559
618
  }
560
619
 
620
+ function validateAnyNBound(
621
+ attribute: "min" | "max",
622
+ value: string,
623
+ responses: Set<string>,
624
+ variables: Set<string>,
625
+ diagnostics: QtiDiagnostic[],
626
+ source: QtiDiagnostic["source"],
627
+ ): void {
628
+ if (value.length === 0) {
629
+ diagnostics.push({
630
+ code: `processing.anyN.${attribute}.required`,
631
+ severity: "error",
632
+ message: `qti-any-n requires ${attribute}.`,
633
+ path: source?.path,
634
+ source,
635
+ });
636
+ return;
637
+ }
638
+ if (isInteger(value)) return;
639
+ if (Number.isFinite(Number(value))) {
640
+ diagnostics.push({
641
+ code: `processing.anyN.${attribute}.integer`,
642
+ severity: "error",
643
+ message: `qti-any-n ${attribute} must be an integer or a declared variable.`,
644
+ path: source?.path,
645
+ source,
646
+ });
647
+ return;
648
+ }
649
+ if (!variables.has(value) || responses.has(value)) {
650
+ diagnostics.push({
651
+ code: `processing.anyN.${attribute}.reference`,
652
+ severity: "error",
653
+ message: `qti-any-n ${attribute} references missing template or outcome variable ${value}.`,
654
+ path: source?.path,
655
+ source,
656
+ });
657
+ }
658
+ }
659
+
660
+ function repeatCountDiagnostic(
661
+ expression: Extract<QtiProcessingExpression, { type: "repeat" }>,
662
+ ): QtiDiagnostic {
663
+ return {
664
+ code: "processing.repeat.numberRepeats",
665
+ severity: "error",
666
+ message: "qti-repeat requires a non-negative integer or a declared variable.",
667
+ path: expression.source?.path,
668
+ source: expression.source,
669
+ };
670
+ }
671
+
672
+ function minimumProducedElements(expressions: QtiProcessingExpression[]): number {
673
+ let total = 0;
674
+ for (const expression of expressions) {
675
+ total = cappedElementCount(total + minimumExpressionElements(expression));
676
+ }
677
+ return total;
678
+ }
679
+
680
+ function minimumExpressionElements(expression: QtiProcessingExpression): number {
681
+ if (expression.type === "baseValue") return 1;
682
+ if (expression.type === "multiple" || expression.type === "ordered") {
683
+ return minimumProducedElements(expression.expressions);
684
+ }
685
+ 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;
692
+ }
693
+ if (expression.type !== "repeat" || !/^\d+$/.test(expression.numberRepeats)) return 0;
694
+ const perRepeat = minimumProducedElements(expression.expressions);
695
+ const projected = BigInt(expression.numberRepeats) * BigInt(perRepeat);
696
+ return projected > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS)
697
+ ? MAX_QTI_REPEAT_RESULT_ELEMENTS + 1
698
+ : Number(projected);
699
+ }
700
+
701
+ function cappedElementCount(value: number): number {
702
+ return Math.min(value, MAX_QTI_REPEAT_RESULT_ELEMENTS + 1);
703
+ }
704
+
561
705
  function validateInsideExpression(
562
706
  expression: Extract<QtiProcessingExpression, { type: "inside" }>,
563
707
  diagnostics: QtiDiagnostic[],
package/src/validation.ts CHANGED
@@ -16,11 +16,10 @@ import { assertNever } from "./assert-never.js";
16
16
  import { validateQtiDataSsmlMetadata } from "./tts.js";
17
17
  import { validateCompanionMaterials } from "./validation-companion-materials.js";
18
18
  import {
19
- hasValidShapeCoordinateCount,
20
- isAreaShape,
21
- isNumericCsv,
22
- numericCsv,
23
- } from "./validation-geometry.js";
19
+ validateAreaMapEntryAttributes,
20
+ validateLookupTableEntryAttributes,
21
+ validateMapEntryAttributes,
22
+ } from "./validation-declaration-entries.js";
24
23
  import {
25
24
  isBaseType,
26
25
  isBooleanAttribute,
@@ -237,7 +236,8 @@ function isValidDeclarationBaseValue(value: string, baseType: QtiBaseType): bool
237
236
  function validateMapping(declaration: QtiResponseDeclaration, diagnostics: QtiDiagnostic[]): void {
238
237
  const mapping = declaration.mapping;
239
238
  if (!mapping) return;
240
- if (!Number.isFinite(mapping.defaultValue)) {
239
+ const defaultValue = mapping.attributes["default-value"];
240
+ if (defaultValue !== undefined && !isFiniteNumber(defaultValue)) {
241
241
  diagnostics.push({
242
242
  code: "mapping.defaultValue",
243
243
  severity: "error",
@@ -249,42 +249,7 @@ function validateMapping(declaration: QtiResponseDeclaration, diagnostics: QtiDi
249
249
  validateMappingBounds(declaration, mapping.attributes, mapping.source, "mapping", diagnostics);
250
250
 
251
251
  for (const entry of mapping.entries) {
252
- validateMapEntry(declaration, entry, diagnostics);
253
- }
254
- }
255
-
256
- function validateMapEntry(
257
- declaration: QtiResponseDeclaration,
258
- entry: NonNullable<QtiResponseDeclaration["mapping"]>["entries"][number],
259
- diagnostics: QtiDiagnostic[],
260
- ): void {
261
- if (!entry.attributes["map-key"]) {
262
- diagnostics.push({
263
- code: "mapEntry.mapKey.required",
264
- severity: "error",
265
- message: `Response declaration ${declaration.identifier} map entry requires map-key.`,
266
- path: entry.source?.path,
267
- source: entry.source,
268
- });
269
- }
270
-
271
- const mappedValue = entry.attributes["mapped-value"];
272
- if (mappedValue === undefined) {
273
- diagnostics.push({
274
- code: "mapEntry.mappedValue.required",
275
- severity: "error",
276
- message: `Response declaration ${declaration.identifier} map entry requires mapped-value.`,
277
- path: entry.source?.path,
278
- source: entry.source,
279
- });
280
- } else if (!isFiniteNumber(mappedValue)) {
281
- diagnostics.push({
282
- code: "mapEntry.mappedValue",
283
- severity: "error",
284
- message: `Response declaration ${declaration.identifier} map entry requires numeric mapped-value.`,
285
- path: entry.source?.path,
286
- source: entry.source,
287
- });
252
+ validateMapEntryAttributes(declaration.identifier, entry, diagnostics);
288
253
  }
289
254
  }
290
255
 
@@ -294,7 +259,8 @@ function validateAreaMapping(
294
259
  ): void {
295
260
  const areaMapping = declaration.areaMapping;
296
261
  if (!areaMapping) return;
297
- if (!Number.isFinite(areaMapping.defaultValue)) {
262
+ const defaultValue = areaMapping.attributes["default-value"];
263
+ if (defaultValue !== undefined && !isFiniteNumber(defaultValue)) {
298
264
  diagnostics.push({
299
265
  code: "areaMapping.defaultValue",
300
266
  severity: "error",
@@ -312,7 +278,7 @@ function validateAreaMapping(
312
278
  );
313
279
 
314
280
  for (const entry of areaMapping.entries) {
315
- validateAreaMapEntry(declaration, entry, diagnostics);
281
+ validateAreaMapEntryAttributes(declaration.identifier, entry, diagnostics);
316
282
  }
317
283
  }
318
284
 
@@ -360,82 +326,6 @@ function validateMappingBounds(
360
326
  }
361
327
  }
362
328
 
363
- function validateAreaMapEntry(
364
- declaration: QtiResponseDeclaration,
365
- entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
366
- diagnostics: QtiDiagnostic[],
367
- ): void {
368
- const shape = entry.attributes.shape;
369
- const coords = entry.attributes.coords;
370
- const mappedValue = entry.attributes["mapped-value"];
371
-
372
- if (!shape) {
373
- diagnostics.push({
374
- code: "areaMapEntry.shape.required",
375
- severity: "error",
376
- message: `Response declaration ${declaration.identifier} area map entry requires shape.`,
377
- path: entry.source?.path,
378
- source: entry.source,
379
- });
380
- } else if (!isAreaShape(shape)) {
381
- diagnostics.push({
382
- code: "areaMapEntry.shape",
383
- severity: "error",
384
- message: `Response declaration ${declaration.identifier} area map entry has unsupported shape ${shape}.`,
385
- path: entry.source?.path,
386
- source: entry.source,
387
- });
388
- }
389
-
390
- if (!coords) {
391
- diagnostics.push({
392
- code: "areaMapEntry.coords.required",
393
- severity: "error",
394
- message: `Response declaration ${declaration.identifier} area map entry requires coords.`,
395
- path: entry.source?.path,
396
- source: entry.source,
397
- });
398
- } else if (!isNumericCsv(coords)) {
399
- diagnostics.push({
400
- code: "areaMapEntry.coords",
401
- severity: "error",
402
- message: `Response declaration ${declaration.identifier} area map entry requires comma-separated numeric coords.`,
403
- path: entry.source?.path,
404
- source: entry.source,
405
- });
406
- } else if (
407
- shape &&
408
- isAreaShape(shape) &&
409
- !hasValidShapeCoordinateCount(shape, numericCsv(coords))
410
- ) {
411
- diagnostics.push({
412
- code: "areaMapEntry.coords.shape",
413
- severity: "error",
414
- message: `Response declaration ${declaration.identifier} area map entry shape ${shape} has invalid coords arity.`,
415
- path: entry.source?.path,
416
- source: entry.source,
417
- });
418
- }
419
-
420
- if (mappedValue === undefined) {
421
- diagnostics.push({
422
- code: "areaMapEntry.mappedValue.required",
423
- severity: "error",
424
- message: `Response declaration ${declaration.identifier} area map entry requires mapped-value.`,
425
- path: entry.source?.path,
426
- source: entry.source,
427
- });
428
- } else if (!isFiniteNumber(mappedValue)) {
429
- diagnostics.push({
430
- code: "areaMapEntry.mappedValue",
431
- severity: "error",
432
- message: `Response declaration ${declaration.identifier} area map entry requires numeric mapped-value.`,
433
- path: entry.source?.path,
434
- source: entry.source,
435
- });
436
- }
437
- }
438
-
439
329
  function validateDeclarationRequiredAttributes(
440
330
  declaration: QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration,
441
331
  diagnostics: QtiDiagnostic[],
@@ -502,37 +392,7 @@ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDi
502
392
  });
503
393
  }
504
394
  for (const entry of lookupTable.entries) {
505
- if (!isFiniteNumber(entry.attributes["source-value"] ?? "")) {
506
- diagnostics.push({
507
- code: "lookupTable.entry.sourceValue",
508
- severity: "error",
509
- message: "Lookup table entry requires numeric source-value.",
510
- path: entry.source?.path,
511
- source: entry.source,
512
- });
513
- }
514
- if (entry.attributes["target-value"] === undefined) {
515
- diagnostics.push({
516
- code: "lookupTable.entry.targetValue",
517
- severity: "error",
518
- message: "Lookup table entry requires target-value.",
519
- path: entry.source?.path,
520
- source: entry.source,
521
- });
522
- }
523
- if (
524
- lookupTable.type === "match" &&
525
- entry.attributes["source-value"] !== undefined &&
526
- !isInteger(entry.attributes["source-value"])
527
- ) {
528
- diagnostics.push({
529
- code: "lookupTable.match.sourceValue",
530
- severity: "error",
531
- message: "qti-match-table-entry source-value must be an integer.",
532
- path: entry.source?.path,
533
- source: entry.source,
534
- });
535
- }
395
+ validateLookupTableEntryAttributes(lookupTable.type, entry, diagnostics);
536
396
  }
537
397
  }
538
398
  }
package/src/xml.ts CHANGED
@@ -190,10 +190,9 @@ export function descendants(node: XmlNode, predicate: (node: XmlNode) => boolean
190
190
  }
191
191
 
192
192
  export function textContent(node: XmlNode): string {
193
- const parts = node.content.map((entry) =>
194
- typeof entry === "string" ? entry : textContent(entry),
195
- );
196
- return parts.join(" ").replace(/\s+/g, " ").trim();
193
+ return node.content
194
+ .map((entry) => (typeof entry === "string" ? entry : textContent(entry)))
195
+ .join("");
197
196
  }
198
197
 
199
198
  function openElement(
@@ -212,12 +211,21 @@ function openElement(
212
211
  const attributes: Record<string, string> = {};
213
212
  const inheritedNamespaces = state.namespaceStack.at(-1)?.namespaces ?? {};
214
213
  const namespaces: Record<string, string> = { ...inheritedNamespaces };
214
+ const uniqueAttributes: ParsedAttribute[] = [];
215
+ const qualifiedAttributeNames = new Set<string>();
215
216
  for (const attribute of parsedAttributes) {
217
+ if (qualifiedAttributeNames.has(attribute.name)) {
218
+ state.errors.push(new Error(`Duplicate XML attribute ${attribute.name}.`));
219
+ continue;
220
+ }
221
+ qualifiedAttributeNames.add(attribute.name);
222
+ uniqueAttributes.push(attribute);
216
223
  attributes[attribute.name] = attribute.value;
217
224
  if (attribute.name === "xmlns") namespaces[""] = attribute.value;
218
225
  else if (attribute.name.startsWith("xmlns:"))
219
226
  namespaces[attribute.name.slice(6)] = attribute.value;
220
227
  }
228
+ validateUniqueExpandedAttributeNames(uniqueAttributes, namespaces, state);
221
229
 
222
230
  const { prefix, localName } = splitQualifiedName(name);
223
231
  const path = nodePath(parent, localName);
@@ -498,6 +506,34 @@ function splitQualifiedName(name: string): {
498
506
  };
499
507
  }
500
508
 
509
+ function validateUniqueExpandedAttributeNames(
510
+ attributes: readonly ParsedAttribute[],
511
+ namespaces: Readonly<Record<string, string>>,
512
+ state: ParserState,
513
+ ): void {
514
+ const expandedNames = new Map<string, string>();
515
+ for (const attribute of attributes) {
516
+ if (attribute.name === "xmlns" || attribute.name.startsWith("xmlns:")) continue;
517
+
518
+ const { prefix, localName } = splitQualifiedName(attribute.name);
519
+ const namespaceUri = prefix ? namespaces[prefix] : "";
520
+ if (namespaceUri === undefined) continue;
521
+
522
+ const expandedName = `${namespaceUri}\u0000${localName}`;
523
+ const previousName = expandedNames.get(expandedName);
524
+ if (previousName) {
525
+ const displayName = namespaceUri ? `{${namespaceUri}}${localName}` : localName;
526
+ state.errors.push(
527
+ new Error(
528
+ `XML attributes ${previousName} and ${attribute.name} have duplicate expanded name ${displayName}.`,
529
+ ),
530
+ );
531
+ continue;
532
+ }
533
+ expandedNames.set(expandedName, attribute.name);
534
+ }
535
+ }
536
+
501
537
  function buildLineStarts(xml: string): number[] {
502
538
  const lineStarts = [0];
503
539
  for (let index = 0; index < xml.length; index += 1) {