@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
@@ -1,8 +1,10 @@
1
1
  import type { QtiChoice, QtiContentNode, QtiSourceLocation } from "./types.js";
2
2
  import { assertNever } from "./assert-never.js";
3
+ import type { XmlNode } from "./xml.js";
3
4
 
4
5
  export interface FlatTextFromContentOptions {
5
6
  excludeAnnotations?: boolean;
7
+ interactionText?: ((interactionIndex: number) => string) | undefined;
6
8
  }
7
9
 
8
10
  export function normalizeFlatText(text: string): string {
@@ -32,13 +34,14 @@ export function flatTextFromContent(
32
34
  nodes: QtiContentNode[],
33
35
  options: FlatTextFromContentOptions = {},
34
36
  ): string {
35
- const excludeAnnotations = options.excludeAnnotations ?? false;
36
- return normalizeFlatText(
37
- nodes.map((node) => flatTextFromContentNode(node, excludeAnnotations)).join(" "),
38
- );
37
+ return normalizeFlatText(nodes.map((node) => flatTextFromContentNode(node, options)).join(""));
39
38
  }
40
39
 
41
- function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boolean): string {
40
+ function flatTextFromContentNode(
41
+ node: QtiContentNode,
42
+ options: FlatTextFromContentOptions,
43
+ ): string {
44
+ const excludeAnnotations = options.excludeAnnotations ?? false;
42
45
  switch (node.kind) {
43
46
  case "text":
44
47
  return node.text;
@@ -54,14 +57,26 @@ function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boole
54
57
  if (node.qtiName === "object" && node.attributes["object-label"]) {
55
58
  return node.attributes["object-label"];
56
59
  }
57
- return node.children
58
- .map((child) => flatTextFromContentNode(child, excludeAnnotations))
59
- .join(" ");
60
+ if (node.qtiName === "math") {
61
+ return withVisibleBoundary(
62
+ node.qtiName,
63
+ node.children
64
+ .map((child) => flatMathText(child, excludeAnnotations))
65
+ .filter(Boolean)
66
+ .join(" "),
67
+ );
68
+ }
69
+ return withVisibleBoundary(
70
+ node.qtiName,
71
+ node.children.map((child) => flatTextFromContentNode(child, options)).join(""),
72
+ );
60
73
  case "feedback":
61
- return node.children
62
- .map((child) => flatTextFromContentNode(child, excludeAnnotations))
63
- .join(" ");
74
+ return withVisibleBoundary(
75
+ node.feedbackType === "block" ? "qti-feedback-block" : "qti-feedback-inline",
76
+ node.children.map((child) => flatTextFromContentNode(child, options)).join(""),
77
+ );
64
78
  case "interaction":
79
+ return options.interactionText?.(node.interactionIndex) ?? "";
65
80
  case "printedVariable":
66
81
  return "";
67
82
  default:
@@ -69,6 +84,115 @@ function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boole
69
84
  }
70
85
  }
71
86
 
87
+ function flatMathText(node: QtiContentNode, excludeAnnotations: boolean): string {
88
+ if (
89
+ node.kind === "element" &&
90
+ excludeAnnotations &&
91
+ (node.qtiName === "annotation" || node.qtiName === "annotation-xml")
92
+ ) {
93
+ return "";
94
+ }
95
+ if (node.kind === "text") return node.text;
96
+ if (node.kind === "element" || node.kind === "feedback") {
97
+ return node.children
98
+ .map((child) => flatMathText(child, excludeAnnotations))
99
+ .filter(Boolean)
100
+ .join(" ");
101
+ }
102
+ return "";
103
+ }
104
+
105
+ /** Flatten rendered XML text without inventing separators at inline element boundaries. */
106
+ export function visibleTextContent(node: XmlNode): string {
107
+ return normalizeFlatText(visibleTextFromXmlNode(node));
108
+ }
109
+
110
+ function visibleTextFromXmlNode(node: XmlNode): string {
111
+ const accessibleLabel = accessibleXmlLabel(node);
112
+ const text =
113
+ accessibleLabel ??
114
+ (node.localName === "math"
115
+ ? flatMathXmlText(node)
116
+ : node.content
117
+ .map((entry) => (typeof entry === "string" ? entry : visibleTextFromXmlNode(entry)))
118
+ .join(""));
119
+ return withVisibleBoundary(node.localName, text);
120
+ }
121
+
122
+ function flatMathXmlText(node: XmlNode): string {
123
+ return node.content
124
+ .map((entry) =>
125
+ typeof entry === "string"
126
+ ? entry
127
+ : entry.localName === "annotation" || entry.localName === "annotation-xml"
128
+ ? ""
129
+ : flatMathXmlText(entry),
130
+ )
131
+ .filter(Boolean)
132
+ .join(" ");
133
+ }
134
+
135
+ function accessibleXmlLabel(node: XmlNode): string | undefined {
136
+ if (node.localName === "math" && node.attributes.alttext) return node.attributes.alttext;
137
+ if (node.localName === "img" && node.attributes.alt) return node.attributes.alt;
138
+ if (node.localName === "object" && node.attributes["object-label"]) {
139
+ return node.attributes["object-label"];
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function withVisibleBoundary(qtiName: string, text: string): string {
145
+ return visibleBoundaryNames.has(qtiName) ? ` ${text} ` : text;
146
+ }
147
+
148
+ const visibleBoundaryNames = new Set([
149
+ "address",
150
+ "article",
151
+ "aside",
152
+ "blockquote",
153
+ "br",
154
+ "dd",
155
+ "div",
156
+ "dl",
157
+ "dt",
158
+ "fieldset",
159
+ "figcaption",
160
+ "figure",
161
+ "footer",
162
+ "form",
163
+ "h1",
164
+ "h2",
165
+ "h3",
166
+ "h4",
167
+ "h5",
168
+ "h6",
169
+ "header",
170
+ "hgroup",
171
+ "hr",
172
+ "li",
173
+ "main",
174
+ "math",
175
+ "nav",
176
+ "ol",
177
+ "p",
178
+ "pre",
179
+ "qti-feedback-block",
180
+ "qti-gap-img",
181
+ "qti-gap-text",
182
+ "qti-inline-choice",
183
+ "qti-simple-associable-choice",
184
+ "qti-simple-choice",
185
+ "section",
186
+ "table",
187
+ "tbody",
188
+ "td",
189
+ "tfoot",
190
+ "th",
191
+ "thead",
192
+ "tr",
193
+ "ul",
194
+ ]);
195
+
72
196
  export function choiceAccessibleLabel(
73
197
  choice: QtiChoice | undefined,
74
198
  fallbackIdentifier?: string,
package/src/index.ts CHANGED
@@ -85,7 +85,32 @@ export {
85
85
  export { detectPackageMediaType } from "./qti-package-assets.js";
86
86
  export { parseQtiXml } from "./parser.js";
87
87
  export { numericTuple3, numericTuple4, parseXmlBoolean } from "./parser-values.js";
88
+ export { escapeXmlAttribute, escapeXmlText } from "./xml.js";
89
+ export {
90
+ applyQtiMediaPlaybackEvent,
91
+ parseQtiMediaDefinition,
92
+ QTI_MEDIA_RESTART_THRESHOLD_SECONDS,
93
+ type QtiMediaDefinition,
94
+ type QtiMediaDefinitionResult,
95
+ type QtiMediaPlaybackEvent,
96
+ type QtiMediaPlaybackResult,
97
+ type QtiMediaPlaySession,
98
+ } from "./media-definition.js";
99
+ export {
100
+ parseQtiSliderDefinition,
101
+ parseQtiSliderValue,
102
+ qtiSliderDiscreteValue,
103
+ qtiSliderRatio,
104
+ snapQtiSliderValue,
105
+ type QtiSliderDefinition,
106
+ type QtiSliderDefinitionResult,
107
+ type QtiSliderOrientation,
108
+ type QtiSliderStep,
109
+ type QtiSliderValueFailureReason,
110
+ type QtiSliderValueResult,
111
+ } from "./slider-definition.js";
88
112
  export { collectQtiResponseProcessingExpressions } from "./processing-expression-collection.js";
113
+ export { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
89
114
  export { isConformanceParseDiagnostic } from "./parse-diagnostics.js";
90
115
  export {
91
116
  scoreQtiItemServerSide,
@@ -0,0 +1,174 @@
1
+ import { assertNever } from "./assert-never.js";
2
+ import { parseXmlBoolean } from "./parser-values.js";
3
+ import type { QtiDiagnostic, QtiInteraction } from "./types.js";
4
+ import { isNonNegativeInteger } from "./validation-primitives.js";
5
+
6
+ /** A validated media play-count domain and native playback policy. */
7
+ export interface QtiMediaDefinition {
8
+ readonly minPlays: number;
9
+ readonly maxPlays: number | undefined;
10
+ readonly autostart: boolean;
11
+ readonly nativeLoop: boolean;
12
+ }
13
+
14
+ /** The typed result of refining raw media interaction attributes. */
15
+ export type QtiMediaDefinitionResult =
16
+ | { readonly ok: true; readonly value: QtiMediaDefinition }
17
+ | { readonly ok: false; readonly diagnostics: readonly QtiDiagnostic[] };
18
+
19
+ /** Seconds from the start within which a play event counts as a new play experience. */
20
+ export const QTI_MEDIA_RESTART_THRESHOLD_SECONDS = 0.25;
21
+
22
+ /** The play-experience session used to decide whether a native media event counts. */
23
+ export type QtiMediaPlaySession =
24
+ | { readonly status: "idle"; readonly playCount: number }
25
+ | { readonly status: "playing"; readonly playCount: number }
26
+ | { readonly status: "ended"; readonly playCount: number };
27
+
28
+ /** A native media event projected into the QTI play-count machine. */
29
+ export type QtiMediaPlaybackEvent =
30
+ | { readonly kind: "play"; readonly currentTime: number }
31
+ | { readonly kind: "ended" }
32
+ | { readonly kind: "seeked"; readonly currentTime: number; readonly paused: boolean };
33
+
34
+ /** The session update and any player-side block from one playback event. */
35
+ export type QtiMediaPlaybackResult =
36
+ | { readonly kind: "applied"; readonly session: QtiMediaPlaySession }
37
+ | { readonly kind: "blocked"; readonly session: QtiMediaPlaySession };
38
+
39
+ function diagnostic(interaction: QtiInteraction, code: string, message: string): QtiDiagnostic {
40
+ return {
41
+ code,
42
+ severity: "error",
43
+ message,
44
+ path: interaction.source?.path,
45
+ source: interaction.source,
46
+ };
47
+ }
48
+
49
+ function optionalBoolean(
50
+ interaction: QtiInteraction,
51
+ attribute: "autostart" | "loop",
52
+ diagnostics: QtiDiagnostic[],
53
+ ): boolean {
54
+ const raw = interaction.attributes[attribute];
55
+ if (raw === undefined) return false;
56
+ const value = parseXmlBoolean(raw);
57
+ if (value !== undefined) return value;
58
+ diagnostics.push(
59
+ diagnostic(
60
+ interaction,
61
+ "interaction.booleanAttribute",
62
+ `${interaction.qtiName} requires boolean ${attribute}, got ${raw}.`,
63
+ ),
64
+ );
65
+ return false;
66
+ }
67
+
68
+ function optionalNonNegativeInteger(
69
+ interaction: QtiInteraction,
70
+ attribute: "min-plays" | "max-plays",
71
+ diagnostics: QtiDiagnostic[],
72
+ ): number | undefined {
73
+ const raw = interaction.attributes[attribute];
74
+ if (raw === undefined) return undefined;
75
+ if (!isNonNegativeInteger(raw)) {
76
+ diagnostics.push(
77
+ diagnostic(
78
+ interaction,
79
+ "interaction.integerAttribute",
80
+ `${interaction.qtiName} requires non-negative integer ${attribute}, got ${raw}.`,
81
+ ),
82
+ );
83
+ return undefined;
84
+ }
85
+ return Number(raw);
86
+ }
87
+
88
+ function atMaximumPlays(session: QtiMediaPlaySession, maxPlays: number | undefined): boolean {
89
+ return maxPlays !== undefined && session.playCount >= maxPlays;
90
+ }
91
+
92
+ /**
93
+ * Refines raw QTI media attributes into the play-count domain shared by validation and players.
94
+ */
95
+ export function parseQtiMediaDefinition(interaction: QtiInteraction): QtiMediaDefinitionResult {
96
+ const diagnostics: QtiDiagnostic[] = [];
97
+ const minPlaysAttribute = optionalNonNegativeInteger(interaction, "min-plays", diagnostics);
98
+ const maxPlaysAttribute = optionalNonNegativeInteger(interaction, "max-plays", diagnostics);
99
+ const autostart = optionalBoolean(interaction, "autostart", diagnostics);
100
+ const loop = optionalBoolean(interaction, "loop", diagnostics);
101
+ const required = parseXmlBoolean(interaction.attributes.required) === true;
102
+ const maxPlays =
103
+ maxPlaysAttribute === undefined || maxPlaysAttribute <= 0 ? undefined : maxPlaysAttribute;
104
+
105
+ if (minPlaysAttribute !== undefined && maxPlays !== undefined && minPlaysAttribute > maxPlays) {
106
+ diagnostics.push(
107
+ diagnostic(
108
+ interaction,
109
+ "interaction.minMax",
110
+ `${interaction.qtiName} requires min-plays to be less than or equal to max-plays, unless max-plays is 0 for unlimited.`,
111
+ ),
112
+ );
113
+ }
114
+
115
+ if (diagnostics.length > 0) return { ok: false, diagnostics };
116
+
117
+ return {
118
+ ok: true,
119
+ value: {
120
+ minPlays: minPlaysAttribute ?? (required ? 1 : 0),
121
+ maxPlays,
122
+ autostart,
123
+ nativeLoop: loop && maxPlays === undefined,
124
+ },
125
+ };
126
+ }
127
+
128
+ function applyPlayEvent(
129
+ session: QtiMediaPlaySession,
130
+ event: Extract<QtiMediaPlaybackEvent, { kind: "play" }>,
131
+ maxPlays: number | undefined,
132
+ ): QtiMediaPlaybackResult {
133
+ if (session.status !== "playing" && atMaximumPlays(session, maxPlays)) {
134
+ return { kind: "blocked", session };
135
+ }
136
+ const counts =
137
+ session.status === "ended" ||
138
+ (session.status === "idle" && event.currentTime <= QTI_MEDIA_RESTART_THRESHOLD_SECONDS);
139
+ return {
140
+ kind: "applied",
141
+ session: {
142
+ status: "playing",
143
+ playCount: session.playCount + (counts ? 1 : 0),
144
+ },
145
+ };
146
+ }
147
+
148
+ function applySeekedEvent(
149
+ session: QtiMediaPlaySession,
150
+ event: Extract<QtiMediaPlaybackEvent, { kind: "seeked" }>,
151
+ ): QtiMediaPlaybackResult {
152
+ if (!event.paused || event.currentTime > QTI_MEDIA_RESTART_THRESHOLD_SECONDS) {
153
+ return { kind: "applied", session };
154
+ }
155
+ return { kind: "applied", session: { status: "idle", playCount: session.playCount } };
156
+ }
157
+
158
+ /** Applies one native media event to the QTI play-experience session. */
159
+ export function applyQtiMediaPlaybackEvent(
160
+ session: QtiMediaPlaySession,
161
+ event: QtiMediaPlaybackEvent,
162
+ maxPlays: number | undefined,
163
+ ): QtiMediaPlaybackResult {
164
+ switch (event.kind) {
165
+ case "play":
166
+ return applyPlayEvent(session, event, maxPlays);
167
+ case "ended":
168
+ return { kind: "applied", session: { status: "ended", playCount: session.playCount } };
169
+ case "seeked":
170
+ return applySeekedEvent(session, event);
171
+ default:
172
+ return assertNever(event);
173
+ }
174
+ }
@@ -4,11 +4,13 @@ import {
4
4
  parseBaseType,
5
5
  parseCardinality,
6
6
  parseCoords,
7
+ parseFiniteNumber,
7
8
  parseShape,
8
9
  parseXmlBoolean,
9
10
  } from "./parser-values.js";
10
11
  import type {
11
12
  QtiBaseType,
13
+ QtiDiagnostic,
12
14
  QtiLookupTable,
13
15
  QtiOutcomeDeclaration,
14
16
  QtiResponseDeclaration,
@@ -16,9 +18,17 @@ import type {
16
18
  QtiTemplateDeclaration,
17
19
  QtiValue,
18
20
  } from "./types.js";
21
+ import {
22
+ validateAreaMapEntryAttributes,
23
+ validateLookupTableEntryAttributes,
24
+ validateMapEntryAttributes,
25
+ } from "./validation-declaration-entries.js";
19
26
  import { childElements, textContent, type XmlNode } from "./xml.js";
20
27
 
21
- export function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration {
28
+ export function parseResponseDeclaration(
29
+ node: XmlNode,
30
+ diagnostics: QtiDiagnostic[],
31
+ ): QtiResponseDeclaration {
22
32
  const cardinality = parseCardinality(node.attributes.cardinality);
23
33
  const baseType = parseBaseType(node.attributes["base-type"]);
24
34
  return {
@@ -26,40 +36,66 @@ export function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration
26
36
  identifier: node.attributes.identifier ?? "",
27
37
  cardinality,
28
38
  baseType,
29
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
39
+ defaultValue: parseVariableValue(
40
+ childElements(node, "qti-default-value")[0],
41
+ baseType,
42
+ diagnostics,
43
+ ),
30
44
  correctResponse: normalizeValueForCardinality(
31
- parseVariableValue(childElements(node, "qti-correct-response")[0], baseType),
45
+ parseVariableValue(childElements(node, "qti-correct-response")[0], baseType, diagnostics),
32
46
  cardinality,
33
47
  ),
34
- mapping: parseMapping(childElements(node, "qti-mapping")[0]),
35
- areaMapping: parseAreaMapping(childElements(node, "qti-area-mapping")[0]),
48
+ mapping: parseMapping(
49
+ childElements(node, "qti-mapping")[0],
50
+ node.attributes.identifier ?? "",
51
+ diagnostics,
52
+ ),
53
+ areaMapping: parseAreaMapping(
54
+ childElements(node, "qti-area-mapping")[0],
55
+ node.attributes.identifier ?? "",
56
+ diagnostics,
57
+ ),
36
58
  attributes: node.attributes,
37
59
  source: node.source,
38
60
  };
39
61
  }
40
62
 
41
- export function parseOutcomeDeclaration(node: XmlNode): QtiOutcomeDeclaration {
63
+ export function parseOutcomeDeclaration(
64
+ node: XmlNode,
65
+ diagnostics: QtiDiagnostic[],
66
+ ): QtiOutcomeDeclaration {
42
67
  const baseType = parseBaseType(node.attributes["base-type"]);
43
68
  return {
44
69
  kind: "outcome",
45
70
  identifier: node.attributes.identifier ?? "",
46
71
  cardinality: parseCardinality(node.attributes.cardinality),
47
72
  baseType,
48
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
49
- lookupTable: parseLookupTable(node, baseType),
73
+ defaultValue: parseVariableValue(
74
+ childElements(node, "qti-default-value")[0],
75
+ baseType,
76
+ diagnostics,
77
+ ),
78
+ lookupTable: parseLookupTable(node, baseType, diagnostics),
50
79
  attributes: node.attributes,
51
80
  source: node.source,
52
81
  };
53
82
  }
54
83
 
55
- export function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration {
84
+ export function parseTemplateDeclaration(
85
+ node: XmlNode,
86
+ diagnostics: QtiDiagnostic[],
87
+ ): QtiTemplateDeclaration {
56
88
  const baseType = parseBaseType(node.attributes["base-type"]);
57
89
  return {
58
90
  kind: "template",
59
91
  identifier: node.attributes.identifier ?? "",
60
92
  cardinality: parseCardinality(node.attributes.cardinality),
61
93
  baseType,
62
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
94
+ defaultValue: parseVariableValue(
95
+ childElements(node, "qti-default-value")[0],
96
+ baseType,
97
+ diagnostics,
98
+ ),
63
99
  attributes: node.attributes,
64
100
  source: node.source,
65
101
  };
@@ -68,6 +104,7 @@ export function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration
68
104
  function parseVariableValue(
69
105
  node: XmlNode | undefined,
70
106
  baseType: QtiBaseType | undefined,
107
+ diagnostics: QtiDiagnostic[],
71
108
  ): QtiValue {
72
109
  if (!node) return null;
73
110
  const valueNodes = childElements(node, "qti-value");
@@ -79,6 +116,16 @@ function parseVariableValue(
79
116
  (entry): entry is { fieldIdentifier: string; value: QtiScalarValue } =>
80
117
  Boolean(entry.fieldIdentifier),
81
118
  );
119
+ const fieldedEntryCount = entries.filter((entry) => entry.fieldIdentifier !== undefined).length;
120
+ if (fieldedEntryCount > 0 && fieldedEntryCount < entries.length) {
121
+ diagnostics.push({
122
+ code: "declaration.value.fieldIdentifier.mixed",
123
+ severity: "error",
124
+ message: `${node.localName} must not mix qti-value children with and without field-identifier.`,
125
+ path: node.source.path,
126
+ source: node.source,
127
+ });
128
+ }
82
129
  if (recordEntries.length > 0) {
83
130
  return Object.fromEntries(recordEntries.map((entry) => [entry.fieldIdentifier, entry.value]));
84
131
  }
@@ -90,87 +137,134 @@ function parseVariableValue(
90
137
  return entries.map((entry) => entry.value);
91
138
  }
92
139
 
93
- function parseMapping(node: XmlNode | undefined): QtiResponseDeclaration["mapping"] | undefined {
140
+ function parseMapping(
141
+ node: XmlNode | undefined,
142
+ declarationIdentifier: string,
143
+ diagnostics: QtiDiagnostic[],
144
+ ): QtiResponseDeclaration["mapping"] | undefined {
94
145
  if (!node) return undefined;
95
- return {
96
- defaultValue: Number(node.attributes["default-value"] ?? 0),
97
- attributes: node.attributes,
98
- source: node.source,
99
- entries: childElements(node, "qti-map-entry").map((entry) => ({
146
+ const defaultValue = parseFiniteNumber(node.attributes["default-value"]);
147
+ const entries: NonNullable<QtiResponseDeclaration["mapping"]>["entries"] = [];
148
+ for (const entry of childElements(node, "qti-map-entry")) {
149
+ const mappedValue = parseFiniteNumber(entry.attributes["mapped-value"]);
150
+ if (mappedValue === undefined) {
151
+ validateMapEntryAttributes(declarationIdentifier, entry, diagnostics);
152
+ continue;
153
+ }
154
+ entries.push({
100
155
  mapKey: entry.attributes["map-key"],
101
- mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
156
+ mappedValue,
102
157
  attributes: entry.attributes,
103
158
  source: entry.source,
104
- })),
159
+ });
160
+ }
161
+ return {
162
+ defaultValue: defaultValue ?? 0,
163
+ attributes: node.attributes,
164
+ source: node.source,
165
+ entries,
105
166
  };
106
167
  }
107
168
 
108
169
  function parseAreaMapping(
109
170
  node: XmlNode | undefined,
171
+ declarationIdentifier: string,
172
+ diagnostics: QtiDiagnostic[],
110
173
  ): QtiResponseDeclaration["areaMapping"] | undefined {
111
174
  if (!node) return undefined;
112
- return {
113
- defaultValue: Number(node.attributes["default-value"] ?? 0),
114
- attributes: node.attributes,
115
- source: node.source,
116
- entries: childElements(node, "qti-area-map-entry").map((entry) => ({
175
+ const defaultValue = parseFiniteNumber(node.attributes["default-value"]);
176
+ const entries: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"] = [];
177
+ for (const entry of childElements(node, "qti-area-map-entry")) {
178
+ const mappedValue = parseFiniteNumber(entry.attributes["mapped-value"]);
179
+ if (mappedValue === undefined) {
180
+ validateAreaMapEntryAttributes(declarationIdentifier, entry, diagnostics);
181
+ continue;
182
+ }
183
+ entries.push({
117
184
  shape: parseShape(entry.attributes.shape),
118
185
  coords: parseCoords(entry.attributes.coords),
119
- mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
186
+ mappedValue,
120
187
  attributes: entry.attributes,
121
188
  source: entry.source,
122
- })),
189
+ });
190
+ }
191
+ return {
192
+ defaultValue: defaultValue ?? 0,
193
+ attributes: node.attributes,
194
+ source: node.source,
195
+ entries,
123
196
  };
124
197
  }
125
198
 
126
199
  function parseLookupTable(
127
200
  node: XmlNode,
128
201
  baseType: QtiOutcomeDeclaration["baseType"],
202
+ diagnostics: QtiDiagnostic[],
129
203
  ): QtiLookupTable | undefined {
130
204
  const matchTable = childElements(node, "qti-match-table")[0];
131
- if (matchTable) return parseMatchTable(matchTable, baseType);
205
+ if (matchTable) return parseMatchTable(matchTable, baseType, diagnostics);
132
206
  const interpolationTable = childElements(node, "qti-interpolation-table")[0];
133
- if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType);
207
+ if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType, diagnostics);
134
208
  return undefined;
135
209
  }
136
210
 
137
211
  function parseMatchTable(
138
212
  node: XmlNode,
139
213
  baseType: QtiOutcomeDeclaration["baseType"],
214
+ diagnostics: QtiDiagnostic[],
140
215
  ): QtiLookupTable {
141
216
  return {
142
217
  type: "match",
143
218
  defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
144
219
  attributes: node.attributes,
145
220
  source: node.source,
146
- entries: childElements(node, "qti-match-table-entry").map((entry) => ({
147
- sourceValue: Number(entry.attributes["source-value"]),
148
- targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
149
- attributes: entry.attributes,
150
- source: entry.source,
151
- })),
221
+ entries: parseLookupEntries(node, "match", baseType, diagnostics),
152
222
  };
153
223
  }
154
224
 
155
225
  function parseInterpolationTable(
156
226
  node: XmlNode,
157
227
  baseType: QtiOutcomeDeclaration["baseType"],
228
+ diagnostics: QtiDiagnostic[],
158
229
  ): QtiLookupTable {
159
230
  return {
160
231
  type: "interpolation",
161
232
  defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
162
233
  attributes: node.attributes,
163
234
  source: node.source,
164
- entries: childElements(node, "qti-interpolation-table-entry").map((entry) => ({
165
- sourceValue: Number(entry.attributes["source-value"]),
166
- targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
167
- includeBoundary: parseXmlBoolean(entry.attributes["include-boundary"]) ?? true,
168
- attributes: entry.attributes,
169
- source: entry.source,
170
- })),
235
+ entries: parseLookupEntries(node, "interpolation", baseType, diagnostics),
171
236
  };
172
237
  }
173
238
 
174
239
  function parseLookupValue(value: string | undefined, baseType: string | undefined): QtiValue {
175
240
  return value === undefined ? null : coerceValue(value, baseType);
176
241
  }
242
+
243
+ function parseLookupEntries(
244
+ node: XmlNode,
245
+ tableType: QtiLookupTable["type"],
246
+ baseType: QtiOutcomeDeclaration["baseType"],
247
+ diagnostics: QtiDiagnostic[],
248
+ ): QtiLookupTable["entries"] {
249
+ const entryName =
250
+ tableType === "match" ? "qti-match-table-entry" : "qti-interpolation-table-entry";
251
+ const entries: QtiLookupTable["entries"] = [];
252
+ for (const entry of childElements(node, entryName)) {
253
+ const sourceValue = parseFiniteNumber(entry.attributes["source-value"]);
254
+ if (sourceValue === undefined) {
255
+ validateLookupTableEntryAttributes(tableType, entry, diagnostics);
256
+ continue;
257
+ }
258
+ entries.push({
259
+ sourceValue,
260
+ targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
261
+ includeBoundary:
262
+ tableType === "interpolation"
263
+ ? (parseXmlBoolean(entry.attributes["include-boundary"]) ?? true)
264
+ : undefined,
265
+ attributes: entry.attributes,
266
+ source: entry.source,
267
+ });
268
+ }
269
+ return entries;
270
+ }