@longsightgroup/qti3-core 0.10.2 → 0.10.3

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 (119) hide show
  1. package/README.md +21 -2
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +18 -0
  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 +112 -9
  8. package/dist/content-text.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  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/parser-declarations.d.ts +4 -4
  18. package/dist/parser-declarations.d.ts.map +1 -1
  19. package/dist/parser-declarations.js +87 -46
  20. package/dist/parser-declarations.js.map +1 -1
  21. package/dist/parser-item-metadata.d.ts.map +1 -1
  22. package/dist/parser-item-metadata.js +4 -3
  23. package/dist/parser-item-metadata.js.map +1 -1
  24. package/dist/parser-processing.js +10 -9
  25. package/dist/parser-processing.js.map +1 -1
  26. package/dist/parser-values.d.ts +2 -0
  27. package/dist/parser-values.d.ts.map +1 -1
  28. package/dist/parser-values.js +7 -0
  29. package/dist/parser-values.js.map +1 -1
  30. package/dist/parser.js +16 -13
  31. package/dist/parser.js.map +1 -1
  32. package/dist/processing-evaluator-collection.d.ts.map +1 -1
  33. package/dist/processing-evaluator-collection.js +8 -2
  34. package/dist/processing-evaluator-collection.js.map +1 -1
  35. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  36. package/dist/processing-evaluator-comparison.js +6 -0
  37. package/dist/processing-evaluator-comparison.js.map +1 -1
  38. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  39. package/dist/processing-evaluator-numeric.js +8 -1
  40. package/dist/processing-evaluator-numeric.js.map +1 -1
  41. package/dist/processing-expression-children.d.ts.map +1 -1
  42. package/dist/processing-expression-children.js +2 -1
  43. package/dist/processing-expression-children.js.map +1 -1
  44. package/dist/processing-limits.d.ts +3 -0
  45. package/dist/processing-limits.d.ts.map +1 -0
  46. package/dist/processing-limits.js +3 -0
  47. package/dist/processing-limits.js.map +1 -0
  48. package/dist/processing-mapping.d.ts.map +1 -1
  49. package/dist/processing-mapping.js +13 -2
  50. package/dist/processing-mapping.js.map +1 -1
  51. package/dist/processing-operators.d.ts +2 -0
  52. package/dist/processing-operators.d.ts.map +1 -1
  53. package/dist/processing-operators.js +34 -5
  54. package/dist/processing-operators.js.map +1 -1
  55. package/dist/response-validation-policy.d.ts.map +1 -1
  56. package/dist/response-validation-policy.js +5 -6
  57. package/dist/response-validation-policy.js.map +1 -1
  58. package/dist/serializer-processing-expressions.js +1 -1
  59. package/dist/serializer-processing-expressions.js.map +1 -1
  60. package/dist/slider-definition.d.ts +59 -0
  61. package/dist/slider-definition.d.ts.map +1 -0
  62. package/dist/slider-definition.js +198 -0
  63. package/dist/slider-definition.js.map +1 -0
  64. package/dist/support-evidence.d.ts +6 -2
  65. package/dist/support-evidence.d.ts.map +1 -1
  66. package/dist/support-evidence.js +15 -11
  67. package/dist/support-evidence.js.map +1 -1
  68. package/dist/support.d.ts.map +1 -1
  69. package/dist/support.js +96 -100
  70. package/dist/support.js.map +1 -1
  71. package/dist/types.d.ts +3 -0
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/validation-declaration-entries.d.ts +13 -0
  74. package/dist/validation-declaration-entries.d.ts.map +1 -0
  75. package/dist/validation-declaration-entries.js +136 -0
  76. package/dist/validation-declaration-entries.js.map +1 -0
  77. package/dist/validation-interactions.d.ts.map +1 -1
  78. package/dist/validation-interactions.js +14 -46
  79. package/dist/validation-interactions.js.map +1 -1
  80. package/dist/validation-primitives.js +2 -2
  81. package/dist/validation-primitives.js.map +1 -1
  82. package/dist/validation-processing.d.ts.map +1 -1
  83. package/dist/validation-processing.js +125 -1
  84. package/dist/validation-processing.js.map +1 -1
  85. package/dist/validation.d.ts.map +1 -1
  86. package/dist/validation.js +8 -134
  87. package/dist/validation.js.map +1 -1
  88. package/dist/xml.d.ts.map +1 -1
  89. package/dist/xml.js +3 -2
  90. package/dist/xml.js.map +1 -1
  91. package/package.json +1 -1
  92. package/src/attempt-state.ts +20 -0
  93. package/src/content-text.ts +135 -11
  94. package/src/index.ts +25 -0
  95. package/src/media-definition.ts +174 -0
  96. package/src/parser-declarations.ts +134 -40
  97. package/src/parser-item-metadata.ts +4 -3
  98. package/src/parser-processing.ts +10 -9
  99. package/src/parser-values.ts +7 -0
  100. package/src/parser.ts +16 -13
  101. package/src/processing-evaluator-collection.ts +7 -2
  102. package/src/processing-evaluator-comparison.ts +6 -0
  103. package/src/processing-evaluator-numeric.ts +5 -1
  104. package/src/processing-expression-children.ts +2 -1
  105. package/src/processing-limits.ts +2 -0
  106. package/src/processing-mapping.ts +10 -2
  107. package/src/processing-operators.ts +38 -5
  108. package/src/response-validation-policy.ts +5 -5
  109. package/src/serializer-processing-expressions.ts +1 -1
  110. package/src/slider-definition.ts +363 -0
  111. package/src/support-evidence.ts +15 -17
  112. package/src/support.ts +106 -102
  113. package/src/types.ts +8 -1
  114. package/src/validation-declaration-entries.ts +162 -0
  115. package/src/validation-interactions.ts +14 -64
  116. package/src/validation-primitives.ts +2 -2
  117. package/src/validation-processing.ts +145 -1
  118. package/src/validation.ts +11 -151
  119. package/src/xml.ts +3 -4
@@ -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(