@longsightgroup/qti3-core 0.10.5 → 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 (57) hide show
  1. package/README.md +27 -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 +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -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/response-validation-policy.d.ts +3 -1
  17. package/dist/response-validation-policy.d.ts.map +1 -1
  18. package/dist/response-validation-policy.js +63 -5
  19. package/dist/response-validation-policy.js.map +1 -1
  20. package/dist/response-validation.d.ts +3 -1
  21. package/dist/response-validation.d.ts.map +1 -1
  22. package/dist/response-validation.js +30 -9
  23. package/dist/response-validation.js.map +1 -1
  24. package/dist/scoring-disposition-policy.d.ts.map +1 -1
  25. package/dist/scoring-disposition-policy.js +6 -2
  26. package/dist/scoring-disposition-policy.js.map +1 -1
  27. package/dist/support-evidence.d.ts.map +1 -1
  28. package/dist/support-evidence.js +14 -3
  29. package/dist/support-evidence.js.map +1 -1
  30. package/dist/support.js +9 -2
  31. package/dist/support.js.map +1 -1
  32. package/dist/text-response.d.ts +9 -0
  33. package/dist/text-response.d.ts.map +1 -0
  34. package/dist/text-response.js +102 -0
  35. package/dist/text-response.js.map +1 -0
  36. package/dist/trusted-item-session.d.ts.map +1 -1
  37. package/dist/trusted-item-session.js +4 -3
  38. package/dist/trusted-item-session.js.map +1 -1
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/validation-interactions.d.ts.map +1 -1
  42. package/dist/validation-interactions.js +102 -12
  43. package/dist/validation-interactions.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/catalog.ts +29 -0
  46. package/src/index.ts +7 -0
  47. package/src/item-scoring-expectation.ts +8 -0
  48. package/src/parser.ts +4 -6
  49. package/src/response-validation-policy.ts +71 -3
  50. package/src/response-validation.ts +49 -4
  51. package/src/scoring-disposition-policy.ts +7 -2
  52. package/src/support-evidence.ts +14 -3
  53. package/src/support.ts +11 -2
  54. package/src/text-response.ts +106 -0
  55. package/src/trusted-item-session.ts +4 -3
  56. package/src/types.ts +2 -0
  57. package/src/validation-interactions.ts +139 -14
@@ -8,11 +8,13 @@ import type {
8
8
  QtiScalarValue,
9
9
  QtiValue,
10
10
  } from "./types.js";
11
+ import { isQtiTextResponseRecord, qtiTextResponseString } from "./text-response.js";
11
12
  import { assertNever } from "./assert-never.js";
12
13
  import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
13
14
  import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
14
15
  import {
15
16
  matchMaxDiagnostics,
17
+ matchMinDiagnostics,
16
18
  maximumAllowedResponses,
17
19
  maximumResponseDiagnostic,
18
20
  mediaPlayCount,
@@ -33,6 +35,7 @@ export type QtiResponseValidationDiagnosticCode =
33
35
  | "response.required"
34
36
  | "response.maximum"
35
37
  | "response.matchMax"
38
+ | "response.matchMin"
36
39
  | "response.cardinality"
37
40
  | "response.baseType"
38
41
  | "response.domain"
@@ -65,6 +68,8 @@ export interface QtiResponseValidationInput {
65
68
  item: QtiAssessmentItem;
66
69
  responses: QtiResponseVariablesInput;
67
70
  allowIncompleteResponses?: boolean | undefined;
71
+ /** Host policy: require a scored response when no minimum is authored; defaults to false. */
72
+ requireScoredResponses?: boolean | undefined;
68
73
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
69
74
  responseIdentifiers?: Iterable<string> | undefined;
70
75
  }
@@ -113,6 +118,17 @@ export function parseQtiResponseVariables(
113
118
  if (cardinalityMatches && parsedValue !== undefined) {
114
119
  responses.set(declaration.identifier, parsedValue);
115
120
  validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
121
+ if (
122
+ declaration.cardinality === "record" &&
123
+ parsedValue !== null &&
124
+ interactions?.some(
125
+ (interaction) =>
126
+ interaction.type === "textEntry" || interaction.type === "extendedText",
127
+ ) &&
128
+ !isQtiTextResponseRecord(parsedValue)
129
+ ) {
130
+ pushResponseDomainDiagnostic(declaration, parsedValue, diagnostics);
131
+ }
116
132
  }
117
133
  }
118
134
 
@@ -122,6 +138,7 @@ export function parseQtiResponseVariables(
122
138
  undefined,
123
139
  value,
124
140
  input.allowIncompleteResponses,
141
+ input.requireScoredResponses,
125
142
  diagnostics,
126
143
  );
127
144
  continue;
@@ -133,6 +150,7 @@ export function parseQtiResponseVariables(
133
150
  interaction,
134
151
  value,
135
152
  input.allowIncompleteResponses,
153
+ input.requireScoredResponses,
136
154
  diagnostics,
137
155
  );
138
156
  }
@@ -379,17 +397,37 @@ function validateDeclarationResponse(
379
397
  interaction: QtiInteraction | undefined,
380
398
  value: QtiValue | undefined,
381
399
  allowIncompleteResponses: boolean | undefined,
400
+ requireScoredResponses: boolean | undefined,
382
401
  diagnostics: QtiResponseValidationDiagnostic[],
383
402
  ): void {
384
- const policy = responseValidationPolicy(declaration, interaction);
403
+ if (interaction && !allowIncompleteResponses) {
404
+ diagnostics.push(
405
+ ...matchMinDiagnostics(declaration.identifier, interaction, value ?? null).map((diagnostic) =>
406
+ attachResponseIdentifier(declaration.identifier, diagnostic),
407
+ ),
408
+ );
409
+ }
410
+ const policy = responseValidationPolicy(declaration, interaction, requireScoredResponses);
385
411
  if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
386
412
 
387
413
  const effectiveValue = value ?? null;
388
414
  const count =
389
- interaction?.type === "media" ? mediaPlayCount(effectiveValue) : responseCount(effectiveValue);
415
+ interaction?.type === "extendedText" || interaction?.type === "textEntry"
416
+ ? Array.isArray(effectiveValue)
417
+ ? effectiveValue.filter((entry) => entry !== "").length
418
+ : qtiTextResponseString(effectiveValue) === ""
419
+ ? 0
420
+ : 1
421
+ : interaction?.type === "media"
422
+ ? mediaPlayCount(effectiveValue)
423
+ : responseCount(effectiveValue);
390
424
 
391
425
  if (policy.checkMinimum && !allowIncompleteResponses) {
392
- const minimum = effectiveMinimumRequiredResponses(declaration, interaction);
426
+ const minimum = effectiveMinimumRequiredResponses(
427
+ declaration,
428
+ interaction,
429
+ requireScoredResponses,
430
+ );
393
431
  if (count < minimum) {
394
432
  diagnostics.push(
395
433
  attachResponseIdentifier(
@@ -424,9 +462,15 @@ function validateDeclarationResponse(
424
462
  function effectiveMinimumRequiredResponses(
425
463
  declaration: { readonly correctResponse: QtiValue | null },
426
464
  interaction: QtiInteraction | undefined,
465
+ requireScoredResponses: boolean | undefined,
427
466
  ): number {
428
467
  const minimum = minimumRequiredResponses(interaction);
429
- if (declaration.correctResponse === null || hasAuthoredMinimum(interaction)) return minimum;
468
+ if (
469
+ !requireScoredResponses ||
470
+ declaration.correctResponse === null ||
471
+ hasAuthoredMinimum(interaction)
472
+ )
473
+ return minimum;
430
474
  return Math.max(minimum, 1);
431
475
  }
432
476
 
@@ -453,6 +497,7 @@ function isResponseValidationDiagnosticCode(
453
497
  code === "response.required" ||
454
498
  code === "response.maximum" ||
455
499
  code === "response.matchMax" ||
500
+ code === "response.matchMin" ||
456
501
  code === "response.cardinality" ||
457
502
  code === "response.baseType" ||
458
503
  code === "response.domain" ||
@@ -1,5 +1,5 @@
1
1
  import type { QtiAssessmentItem } from "./types.js";
2
- import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
2
+ import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
3
3
 
4
4
  export type QtiItemSubmissionScoringDisposition =
5
5
  | "scored"
@@ -23,7 +23,11 @@ export function itemHasManuallyScoredInteractions(item: QtiAssessmentItem): bool
23
23
  }
24
24
 
25
25
  export function itemNeedsScoringFollowUpWhenUnscored(item: QtiAssessmentItem): boolean {
26
- return itemExpectsAutomatedScore(item) || itemHasManuallyScoredInteractions(item);
26
+ return (
27
+ itemHasExternalScore(item) ||
28
+ itemExpectsAutomatedScore(item) ||
29
+ itemHasManuallyScoredInteractions(item)
30
+ );
27
31
  }
28
32
 
29
33
  /** Default generic disposition taxonomy shipped by qti3-core for submission materialization. */
@@ -31,6 +35,7 @@ export function classifyQtiItemScoringDisposition(
31
35
  item: QtiAssessmentItem,
32
36
  score: number | null,
33
37
  ): Exclude<QtiItemSubmissionScoringDisposition, "invalid"> {
38
+ if (itemHasExternalScore(item)) return "manual-scoring-required";
34
39
  if (score !== null) return "scored";
35
40
  if (itemNeedsScoringFollowUpWhenUnscored(item)) return "manual-scoring-required";
36
41
  return "unscored-reference";
@@ -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
+ }