@longsightgroup/qti3-core 0.10.4 → 0.10.6

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 (89) hide show
  1. package/README.md +62 -4
  2. package/dist/catalog.d.ts +8 -0
  3. package/dist/catalog.d.ts.map +1 -1
  4. package/dist/catalog.js +10 -0
  5. package/dist/catalog.js.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/item-scoring-expectation.d.ts +2 -0
  11. package/dist/item-scoring-expectation.d.ts.map +1 -1
  12. package/dist/item-scoring-expectation.js +8 -0
  13. package/dist/item-scoring-expectation.js.map +1 -1
  14. package/dist/parser.js +4 -6
  15. package/dist/parser.js.map +1 -1
  16. package/dist/qti-package-assets.d.ts +3 -2
  17. package/dist/qti-package-assets.d.ts.map +1 -1
  18. package/dist/qti-package-assets.js.map +1 -1
  19. package/dist/qti-package-items.d.ts +1 -0
  20. package/dist/qti-package-items.d.ts.map +1 -1
  21. package/dist/qti-package-items.js +36 -32
  22. package/dist/qti-package-items.js.map +1 -1
  23. package/dist/qti-package-manifest.d.ts +3 -2
  24. package/dist/qti-package-manifest.d.ts.map +1 -1
  25. package/dist/qti-package-manifest.js.map +1 -1
  26. package/dist/qti-package-metadata.d.ts +3 -2
  27. package/dist/qti-package-metadata.d.ts.map +1 -1
  28. package/dist/qti-package-metadata.js +5 -2
  29. package/dist/qti-package-metadata.js.map +1 -1
  30. package/dist/qti-package-plan.d.ts +41 -0
  31. package/dist/qti-package-plan.d.ts.map +1 -0
  32. package/dist/qti-package-plan.js +85 -0
  33. package/dist/qti-package-plan.js.map +1 -0
  34. package/dist/qti-package-stream.d.ts +38 -0
  35. package/dist/qti-package-stream.d.ts.map +1 -0
  36. package/dist/qti-package-stream.js +149 -0
  37. package/dist/qti-package-stream.js.map +1 -0
  38. package/dist/qti-package.d.ts.map +1 -1
  39. package/dist/qti-package.js +21 -51
  40. package/dist/qti-package.js.map +1 -1
  41. package/dist/response-validation-policy.d.ts +3 -1
  42. package/dist/response-validation-policy.d.ts.map +1 -1
  43. package/dist/response-validation-policy.js +63 -5
  44. package/dist/response-validation-policy.js.map +1 -1
  45. package/dist/response-validation.d.ts +3 -1
  46. package/dist/response-validation.d.ts.map +1 -1
  47. package/dist/response-validation.js +30 -9
  48. package/dist/response-validation.js.map +1 -1
  49. package/dist/scoring-disposition-policy.d.ts.map +1 -1
  50. package/dist/scoring-disposition-policy.js +6 -2
  51. package/dist/scoring-disposition-policy.js.map +1 -1
  52. package/dist/support-evidence.d.ts.map +1 -1
  53. package/dist/support-evidence.js +14 -3
  54. package/dist/support-evidence.js.map +1 -1
  55. package/dist/support.js +9 -2
  56. package/dist/support.js.map +1 -1
  57. package/dist/text-response.d.ts +9 -0
  58. package/dist/text-response.d.ts.map +1 -0
  59. package/dist/text-response.js +102 -0
  60. package/dist/text-response.js.map +1 -0
  61. package/dist/trusted-item-session.d.ts.map +1 -1
  62. package/dist/trusted-item-session.js +4 -3
  63. package/dist/trusted-item-session.js.map +1 -1
  64. package/dist/types.d.ts +2 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/validation-interactions.d.ts.map +1 -1
  67. package/dist/validation-interactions.js +102 -12
  68. package/dist/validation-interactions.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/catalog.ts +29 -0
  71. package/src/index.ts +15 -0
  72. package/src/item-scoring-expectation.ts +8 -0
  73. package/src/parser.ts +4 -6
  74. package/src/qti-package-assets.ts +3 -4
  75. package/src/qti-package-items.ts +50 -40
  76. package/src/qti-package-manifest.ts +2 -3
  77. package/src/qti-package-metadata.ts +16 -4
  78. package/src/qti-package-plan.ts +168 -0
  79. package/src/qti-package-stream.ts +254 -0
  80. package/src/qti-package.ts +27 -87
  81. package/src/response-validation-policy.ts +71 -3
  82. package/src/response-validation.ts +49 -4
  83. package/src/scoring-disposition-policy.ts +7 -2
  84. package/src/support-evidence.ts +14 -3
  85. package/src/support.ts +11 -2
  86. package/src/text-response.ts +106 -0
  87. package/src/trusted-item-session.ts +4 -3
  88. package/src/types.ts +2 -0
  89. package/src/validation-interactions.ts +139 -14
@@ -51,11 +51,16 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
51
51
  "tests/browser/player-interaction-sweep.spec.ts",
52
52
  ];
53
53
  const extras: Partial<Record<QtiInteractionType, string[]>> = {
54
- associate: browserKeyboardA11yTests,
54
+ associate: [
55
+ ...browserKeyboardA11yTests,
56
+ "packages/core/src/association-contracts.test.ts",
57
+ "tests/browser/player-dom-behavior.spec.ts",
58
+ ],
55
59
  choice: ["tests/browser/player-choice.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
56
60
  drawing: ["tests/browser/player-graphic.spec.ts"],
57
61
  endAttempt: ["tests/browser/player-dom-behavior.spec.ts", ...browserKeyboardA11yTests],
58
62
  extendedText: [
63
+ "packages/core/src/text-response.test.ts",
59
64
  "tests/browser/player-dom-behavior.spec.ts",
60
65
  "tests/browser/player-extended-text-xhtml.spec.ts",
61
66
  ],
@@ -65,7 +70,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
65
70
  "tests/browser/player-graphic-gap-match.spec.ts",
66
71
  "tests/browser/player-graphic.spec.ts",
67
72
  ],
68
- graphicOrder: ["tests/browser/player-graphic.spec.ts"],
73
+ graphicOrder: [
74
+ "packages/core/src/graphic-image-contracts.test.ts",
75
+ "tests/browser/player-graphic.spec.ts",
76
+ ],
69
77
  hotspot: ["tests/browser/player-graphic.spec.ts"],
70
78
  hottext: ["tests/browser/player-hottext.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
71
79
  inlineChoice: ["tests/browser/player-inline-choice.spec.ts"],
@@ -81,7 +89,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
81
89
  "tests/browser/player-slider-cross-browser.spec.ts",
82
90
  ...browserKeyboardA11yTests,
83
91
  ],
84
- textEntry: ["tests/browser/player-dom-behavior.spec.ts"],
92
+ textEntry: [
93
+ "packages/core/src/text-response.test.ts",
94
+ "tests/browser/player-dom-behavior.spec.ts",
95
+ ],
85
96
  upload: ["tests/browser/player-dom-behavior.spec.ts"],
86
97
  };
87
98
  return [...base, ...(extras[interactionType] ?? [])];
package/src/support.ts CHANGED
@@ -401,20 +401,29 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
401
401
  process: true,
402
402
  fixtures: interactionSupportFixtures(interactionType),
403
403
  tests: interactionSupportTests(interactionType),
404
+ notes: ["associate", "graphicAssociate", "match", "gapMatch"].includes(interactionType)
405
+ ? interactionType === "associate" || interactionType === "graphicAssociate"
406
+ ? "Supports single and multiple unordered pair responses."
407
+ : "Supports single and multiple directedPair responses."
408
+ : interactionType === "graphicGapMatch"
409
+ ? "Requires multiple directedPair responses."
410
+ : undefined,
404
411
  };
405
412
  }
406
413
 
407
414
  function extendedTextInteractionEntry(): QtiInteractionElementSupport {
408
415
  return {
409
416
  ...entry("qti-extended-text-interaction", "extendedText"),
410
- notes: "Supports plain and format=xhtml extended text.",
417
+ notes:
418
+ "Supports single, multiple, ordered and numeric record responses; string/integer/float values; min-strings/max-strings; plain, preformatted and string XHTML capture.",
411
419
  };
412
420
  }
413
421
 
414
422
  function textEntryInteractionEntry(): QtiInteractionElementSupport {
415
423
  return {
416
424
  ...entry("qti-text-entry-interaction", "textEntry"),
417
- notes: "Supports placeholder-text and pattern-mask on text-entry controls.",
425
+ notes:
426
+ "Supports single string/integer/float and numeric record responses, base 2–36, string-identifier, placeholder-text and pattern-mask; text-entry format is plain only.",
418
427
  };
419
428
  }
420
429
 
@@ -0,0 +1,106 @@
1
+ import type { QtiInteraction, QtiRecordValue, QtiValue } from "./types.js";
2
+
3
+ /** Capture text using the interaction's declared response type and authored radix. */
4
+ export function captureQtiTextResponse(interaction: QtiInteraction, text: string): QtiValue {
5
+ if (interaction.responseCardinality === "record") return numericRecord(text, radix(interaction));
6
+ if (interaction.responseBaseType === "string") return text;
7
+ if (text.trim() === "") return null;
8
+ const record = numericRecord(text, radix(interaction));
9
+ return interaction.responseBaseType === "integer"
10
+ ? (record.integerValue ?? null)
11
+ : (record.floatValue ?? null);
12
+ }
13
+
14
+ /** Recover editable text, including the original lexical form in a numeric record. */
15
+ export function qtiTextResponseString(value: QtiValue): string {
16
+ if (value !== null && typeof value === "object") {
17
+ return !Array.isArray(value) && typeof value.stringValue === "string" ? value.stringValue : "";
18
+ }
19
+ return value === null ? "" : String(value);
20
+ }
21
+
22
+ /** Format a captured scalar in its authored radix, preserving companion and record text. */
23
+ export function formatQtiTextResponse(interaction: QtiInteraction, value: QtiValue): string {
24
+ const base = radix(interaction);
25
+ if (typeof value === "number" && Number.isInteger(base) && base >= 2 && base <= 36) {
26
+ return value.toString(base);
27
+ }
28
+ return qtiTextResponseString(value);
29
+ }
30
+
31
+ function radix(interaction: QtiInteraction): number {
32
+ return Number(interaction.attributes.base ?? 10);
33
+ }
34
+
35
+ function numericRecord(text: string, base: number): QtiRecordValue {
36
+ const empty: QtiRecordValue = {
37
+ stringValue: text,
38
+ floatValue: null,
39
+ integerValue: null,
40
+ leftDigits: null,
41
+ rightDigits: null,
42
+ ndp: null,
43
+ nsf: null,
44
+ exponent: null,
45
+ };
46
+ if (!Number.isInteger(base) || base < 2 || base > 36) return empty;
47
+ const normalized = text.trim();
48
+ const match = (
49
+ base === 10
50
+ ? /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/
51
+ : /^([+-]?)([0-9a-z]*)(?:\.([0-9a-z]*))?$/i
52
+ ).exec(normalized);
53
+ if (!match) return empty;
54
+ const left = match[2] ?? "";
55
+ const right = match[3] ?? "";
56
+ const digits = left + right;
57
+ if (!digits || digits.split("").some((digit) => Number.parseInt(digit, 36) >= base)) return empty;
58
+ const exponent = match[4] === undefined ? null : Number(match[4]);
59
+ if (exponent !== null && !Number.isSafeInteger(exponent)) return empty;
60
+ const ndp = Math.max(0, right.length - (exponent ?? 0));
61
+ if (!Number.isSafeInteger(ndp)) return empty;
62
+ const magnitude =
63
+ (Number.parseInt(left || "0", base) +
64
+ right
65
+ .split("")
66
+ .reduce(
67
+ (sum, digit, index) => sum + Number.parseInt(digit, base) / base ** (index + 1),
68
+ 0,
69
+ )) *
70
+ base ** (exponent ?? 0);
71
+ const value = base === 10 ? Number(normalized) : match[1] === "-" ? -magnitude : magnitude;
72
+ if (!Number.isFinite(value)) return empty;
73
+ const integer =
74
+ match[3] === undefined && exponent === null && Number.isSafeInteger(value) ? value : null;
75
+ return {
76
+ stringValue: text,
77
+ floatValue: value,
78
+ integerValue: integer,
79
+ leftDigits: left.length,
80
+ rightDigits: right.length,
81
+ ndp,
82
+ nsf: digits.replace(/^0+/, "").length || 1,
83
+ exponent,
84
+ };
85
+ }
86
+
87
+ export function isQtiTextResponseRecord(value: QtiValue): boolean {
88
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
89
+ const integerFields = new Set([
90
+ "integerValue",
91
+ "leftDigits",
92
+ "rightDigits",
93
+ "ndp",
94
+ "nsf",
95
+ "exponent",
96
+ ]);
97
+ return Object.entries(value).every(([field, entry]) => {
98
+ if (field === "stringValue") return typeof entry === "string" || entry === null;
99
+ if (field === "floatValue")
100
+ return entry === null || (typeof entry === "number" && Number.isFinite(entry));
101
+ return (
102
+ integerFields.has(field) &&
103
+ (entry === null || (typeof entry === "number" && Number.isSafeInteger(entry)))
104
+ );
105
+ });
106
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseQtiXml } from "./parser.js";
2
- import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
2
+ import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
3
3
  import { parseQtiResponseVariables } from "./response-validation.js";
4
4
  import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
5
5
  import type {
@@ -190,6 +190,7 @@ export function runTrustedItemSession(
190
190
  }
191
191
 
192
192
  const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
193
+ const externalScore = itemHasExternalScore(parsedResult.parsed.document.item);
193
194
 
194
195
  let outcomes = sessionResult.session.serialize().outcomes;
195
196
  let state = sessionResult.session.serialize();
@@ -212,7 +213,7 @@ export function runTrustedItemSession(
212
213
  scoredResult.scored.state,
213
214
  parsedResult.parsed.responseIdentifiers,
214
215
  );
215
- score = readNumericScore(outcomes.SCORE);
216
+ score = externalScore ? null : readNumericScore(outcomes.SCORE);
216
217
 
217
218
  if (scoredResult.scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
218
219
  return emptyTrustedItemSessionFailure(scoredDiagnostics, {
@@ -241,7 +242,7 @@ export function runTrustedItemSession(
241
242
  } else {
242
243
  state = stripUndeclaredResponses(state, parsedResult.parsed.responseIdentifiers);
243
244
  outcomes = state.outcomes;
244
- score = readNumericScore(outcomes.SCORE);
245
+ score = externalScore ? null : readNumericScore(outcomes.SCORE);
245
246
  }
246
247
 
247
248
  return {
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;
@@ -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
+ }