@longsightgroup/qti3-core 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +21 -2
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +20 -27
  4. package/dist/attempt-state.js.map +1 -1
  5. package/dist/content-text.d.ts +4 -0
  6. package/dist/content-text.d.ts.map +1 -1
  7. package/dist/content-text.js +137 -12
  8. package/dist/content-text.js.map +1 -1
  9. package/dist/index.d.ts +6 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/media-definition.d.ts +55 -0
  14. package/dist/media-definition.d.ts.map +1 -0
  15. package/dist/media-definition.js +97 -0
  16. package/dist/media-definition.js.map +1 -0
  17. package/dist/package-xml.d.ts +1 -0
  18. package/dist/package-xml.d.ts.map +1 -1
  19. package/dist/package-xml.js +1 -0
  20. package/dist/package-xml.js.map +1 -1
  21. package/dist/parser-custom-interactions.js +1 -0
  22. package/dist/parser-custom-interactions.js.map +1 -1
  23. package/dist/parser-declarations.d.ts +4 -4
  24. package/dist/parser-declarations.d.ts.map +1 -1
  25. package/dist/parser-declarations.js +87 -46
  26. package/dist/parser-declarations.js.map +1 -1
  27. package/dist/parser-item-metadata.d.ts.map +1 -1
  28. package/dist/parser-item-metadata.js +5 -3
  29. package/dist/parser-item-metadata.js.map +1 -1
  30. package/dist/parser-processing.js +10 -9
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts +6 -0
  33. package/dist/parser-values.d.ts.map +1 -1
  34. package/dist/parser-values.js +35 -2
  35. package/dist/parser-values.js.map +1 -1
  36. package/dist/parser.d.ts.map +1 -1
  37. package/dist/parser.js +81 -47
  38. package/dist/parser.js.map +1 -1
  39. package/dist/processing-evaluator-collection.d.ts.map +1 -1
  40. package/dist/processing-evaluator-collection.js +8 -2
  41. package/dist/processing-evaluator-collection.js.map +1 -1
  42. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  43. package/dist/processing-evaluator-comparison.js +22 -6
  44. package/dist/processing-evaluator-comparison.js.map +1 -1
  45. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  46. package/dist/processing-evaluator-numeric.js +8 -1
  47. package/dist/processing-evaluator-numeric.js.map +1 -1
  48. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  49. package/dist/processing-evaluator-variable.js +1 -1
  50. package/dist/processing-evaluator-variable.js.map +1 -1
  51. package/dist/processing-expression-children.d.ts.map +1 -1
  52. package/dist/processing-expression-children.js +2 -1
  53. package/dist/processing-expression-children.js.map +1 -1
  54. package/dist/processing-limits.d.ts +3 -0
  55. package/dist/processing-limits.d.ts.map +1 -0
  56. package/dist/processing-limits.js +3 -0
  57. package/dist/processing-limits.js.map +1 -0
  58. package/dist/processing-mapping.d.ts.map +1 -1
  59. package/dist/processing-mapping.js +28 -9
  60. package/dist/processing-mapping.js.map +1 -1
  61. package/dist/processing-operators.d.ts +2 -0
  62. package/dist/processing-operators.d.ts.map +1 -1
  63. package/dist/processing-operators.js +34 -5
  64. package/dist/processing-operators.js.map +1 -1
  65. package/dist/processing-values.d.ts +4 -4
  66. package/dist/processing-values.d.ts.map +1 -1
  67. package/dist/processing-values.js +28 -14
  68. package/dist/processing-values.js.map +1 -1
  69. package/dist/processing-variables.d.ts +3 -1
  70. package/dist/processing-variables.d.ts.map +1 -1
  71. package/dist/processing-variables.js +38 -2
  72. package/dist/processing-variables.js.map +1 -1
  73. package/dist/qti-namespaces.d.ts +7 -0
  74. package/dist/qti-namespaces.d.ts.map +1 -0
  75. package/dist/qti-namespaces.js +7 -0
  76. package/dist/qti-namespaces.js.map +1 -0
  77. package/dist/qti-package-items.d.ts.map +1 -1
  78. package/dist/qti-package-items.js +23 -13
  79. package/dist/qti-package-items.js.map +1 -1
  80. package/dist/qti-package-manifest.d.ts.map +1 -1
  81. package/dist/qti-package-manifest.js +4 -3
  82. package/dist/qti-package-manifest.js.map +1 -1
  83. package/dist/qti-package-metadata.d.ts.map +1 -1
  84. package/dist/qti-package-metadata.js +3 -2
  85. package/dist/qti-package-metadata.js.map +1 -1
  86. package/dist/qti-package-xml.d.ts +4 -2
  87. package/dist/qti-package-xml.d.ts.map +1 -1
  88. package/dist/qti-package-xml.js +10 -5
  89. package/dist/qti-package-xml.js.map +1 -1
  90. package/dist/qti-package-zip.d.ts +17 -1
  91. package/dist/qti-package-zip.d.ts.map +1 -1
  92. package/dist/qti-package-zip.js +60 -5
  93. package/dist/qti-package-zip.js.map +1 -1
  94. package/dist/qti-package.d.ts +3 -3
  95. package/dist/qti-package.d.ts.map +1 -1
  96. package/dist/qti-package.js +10 -4
  97. package/dist/qti-package.js.map +1 -1
  98. package/dist/response-validation-policy.d.ts.map +1 -1
  99. package/dist/response-validation-policy.js +5 -6
  100. package/dist/response-validation-policy.js.map +1 -1
  101. package/dist/response-validation.d.ts +12 -2
  102. package/dist/response-validation.d.ts.map +1 -1
  103. package/dist/response-validation.js +122 -19
  104. package/dist/response-validation.js.map +1 -1
  105. package/dist/serializer-processing-expressions.js +1 -1
  106. package/dist/serializer-processing-expressions.js.map +1 -1
  107. package/dist/session.js +1 -1
  108. package/dist/session.js.map +1 -1
  109. package/dist/slider-definition.d.ts +59 -0
  110. package/dist/slider-definition.d.ts.map +1 -0
  111. package/dist/slider-definition.js +198 -0
  112. package/dist/slider-definition.js.map +1 -0
  113. package/dist/support-evidence.d.ts +6 -2
  114. package/dist/support-evidence.d.ts.map +1 -1
  115. package/dist/support-evidence.js +15 -11
  116. package/dist/support-evidence.js.map +1 -1
  117. package/dist/support.d.ts.map +1 -1
  118. package/dist/support.js +96 -100
  119. package/dist/support.js.map +1 -1
  120. package/dist/trusted-item-session.d.ts.map +1 -1
  121. package/dist/trusted-item-session.js +5 -3
  122. package/dist/trusted-item-session.js.map +1 -1
  123. package/dist/types.d.ts +5 -0
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/validation-declaration-entries.d.ts +13 -0
  126. package/dist/validation-declaration-entries.d.ts.map +1 -0
  127. package/dist/validation-declaration-entries.js +136 -0
  128. package/dist/validation-declaration-entries.js.map +1 -0
  129. package/dist/validation-interactions.d.ts.map +1 -1
  130. package/dist/validation-interactions.js +14 -46
  131. package/dist/validation-interactions.js.map +1 -1
  132. package/dist/validation-primitives.d.ts +5 -1
  133. package/dist/validation-primitives.d.ts.map +1 -1
  134. package/dist/validation-primitives.js +41 -3
  135. package/dist/validation-primitives.js.map +1 -1
  136. package/dist/validation-processing.d.ts.map +1 -1
  137. package/dist/validation-processing.js +125 -1
  138. package/dist/validation-processing.js.map +1 -1
  139. package/dist/validation.d.ts.map +1 -1
  140. package/dist/validation.js +8 -134
  141. package/dist/validation.js.map +1 -1
  142. package/dist/xml.d.ts.map +1 -1
  143. package/dist/xml.js +31 -2
  144. package/dist/xml.js.map +1 -1
  145. package/package.json +1 -1
  146. package/src/attempt-state.ts +22 -26
  147. package/src/content-text.ts +174 -14
  148. package/src/index.ts +28 -0
  149. package/src/media-definition.ts +174 -0
  150. package/src/package-xml.ts +2 -0
  151. package/src/parser-custom-interactions.ts +1 -0
  152. package/src/parser-declarations.ts +134 -40
  153. package/src/parser-item-metadata.ts +5 -3
  154. package/src/parser-processing.ts +10 -9
  155. package/src/parser-values.ts +35 -2
  156. package/src/parser.ts +137 -103
  157. package/src/processing-evaluator-collection.ts +7 -2
  158. package/src/processing-evaluator-comparison.ts +30 -5
  159. package/src/processing-evaluator-numeric.ts +5 -1
  160. package/src/processing-evaluator-variable.ts +1 -0
  161. package/src/processing-expression-children.ts +2 -1
  162. package/src/processing-limits.ts +2 -0
  163. package/src/processing-mapping.ts +32 -8
  164. package/src/processing-operators.ts +38 -5
  165. package/src/processing-values.ts +59 -14
  166. package/src/processing-variables.ts +47 -2
  167. package/src/qti-namespaces.ts +8 -0
  168. package/src/qti-package-items.ts +56 -49
  169. package/src/qti-package-manifest.ts +13 -4
  170. package/src/qti-package-metadata.ts +3 -2
  171. package/src/qti-package-xml.ts +16 -3
  172. package/src/qti-package-zip.ts +128 -3
  173. package/src/qti-package.ts +12 -3
  174. package/src/response-validation-policy.ts +5 -5
  175. package/src/response-validation.ts +185 -20
  176. package/src/serializer-processing-expressions.ts +1 -1
  177. package/src/session.ts +1 -0
  178. package/src/slider-definition.ts +363 -0
  179. package/src/support-evidence.ts +15 -17
  180. package/src/support.ts +106 -102
  181. package/src/trusted-item-session.ts +5 -3
  182. package/src/types.ts +10 -1
  183. package/src/validation-declaration-entries.ts +162 -0
  184. package/src/validation-interactions.ts +14 -64
  185. package/src/validation-primitives.ts +44 -4
  186. package/src/validation-processing.ts +145 -1
  187. package/src/validation.ts +11 -151
  188. package/src/xml.ts +40 -4
@@ -1,4 +1,5 @@
1
1
  import type { QtiDocument, QtiResponseDeclaration, QtiValue } from "./types.js";
2
+ import { parseQtiPair } from "./parser-values.js";
2
3
  import { qtiScalarToString, qtiValueToStringList } from "./value-format.js";
3
4
  import { isRecordValue, numericValue, valuesEqual } from "./processing-values.js";
4
5
 
@@ -36,8 +37,15 @@ export function mapOrMatchResponse(
36
37
  correctResponse: QtiValue,
37
38
  ): number {
38
39
  if (declaration.areaMapping) return scoreAreaMapping(response, declaration.areaMapping);
39
- if (declaration.mapping) return scoreMapping(response, declaration.mapping);
40
- return valuesEqual(response, correctResponse, declaration.cardinality === "ordered") ? 1 : 0;
40
+ if (declaration.mapping) return scoreMapping(response, declaration.mapping, declaration.baseType);
41
+ return valuesEqual(
42
+ response,
43
+ correctResponse,
44
+ declaration.cardinality === "ordered",
45
+ declaration.baseType,
46
+ )
47
+ ? 1
48
+ : 0;
41
49
  }
42
50
 
43
51
  export function scoreAreaMapping(
@@ -49,6 +57,7 @@ export function scoreAreaMapping(
49
57
  : response === null
50
58
  ? []
51
59
  : qtiValueToStringList(response);
60
+ const matchedAreaIndexes = new Set<number>();
52
61
  let score = 0;
53
62
  for (const point of points) {
54
63
  const parsed = parsePoint(point);
@@ -56,8 +65,15 @@ export function scoreAreaMapping(
56
65
  score += areaMapping.defaultValue;
57
66
  continue;
58
67
  }
59
- const entry = areaMapping.entries.find((candidate) => pointInsideArea(parsed, candidate));
60
- score += entry?.mappedValue ?? areaMapping.defaultValue;
68
+ let matchedArea = false;
69
+ for (const [index, entry] of areaMapping.entries.entries()) {
70
+ if (!pointInsideArea(parsed, entry)) continue;
71
+ matchedArea = true;
72
+ if (matchedAreaIndexes.has(index)) continue;
73
+ matchedAreaIndexes.add(index);
74
+ score += entry.mappedValue;
75
+ }
76
+ if (!matchedArea) score += areaMapping.defaultValue;
61
77
  }
62
78
  return clampMappedScore(score, areaMapping.attributes);
63
79
  }
@@ -117,15 +133,17 @@ function pointInsidePolygon(point: { x: number; y: number }, coords: number[]):
117
133
  function scoreMapping(
118
134
  response: QtiValue,
119
135
  mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
136
+ baseType: QtiResponseDeclaration["baseType"],
120
137
  ): number {
121
- const values = Object.fromEntries(
138
+ const values = new Map(
122
139
  mapping.entries
123
140
  .filter((entry) => entry.mapKey !== undefined)
124
- .map((entry) => [entry.mapKey!, entry.mappedValue]),
141
+ .map((entry) => [mappingKey(entry.mapKey!, baseType), entry.mappedValue] as const),
125
142
  );
126
143
  if (Array.isArray(response)) {
127
144
  const score = response.reduce<number>(
128
- (sum, value) => sum + (values[String(value)] ?? mapping.defaultValue),
145
+ (sum, value) =>
146
+ sum + (values.get(mappingKey(String(value), baseType)) ?? mapping.defaultValue),
129
147
  0,
130
148
  );
131
149
  return clampMappedScore(score, mapping.attributes);
@@ -133,10 +151,16 @@ function scoreMapping(
133
151
  const score =
134
152
  response === null || isRecordValue(response)
135
153
  ? 0
136
- : (values[String(response)] ?? mapping.defaultValue);
154
+ : (values.get(mappingKey(String(response), baseType)) ?? mapping.defaultValue);
137
155
  return clampMappedScore(score, mapping.attributes);
138
156
  }
139
157
 
158
+ function mappingKey(value: string, baseType: QtiResponseDeclaration["baseType"]): string {
159
+ return baseType === "pair" || baseType === "directedPair"
160
+ ? (parseQtiPair(value, baseType) ?? value)
161
+ : value;
162
+ }
163
+
140
164
  function clampMappedScore(score: number, attributes: Record<string, string>): number {
141
165
  const lower = numericBound(attributes["lower-bound"]);
142
166
  const upper = numericBound(attributes["upper-bound"]);
@@ -39,15 +39,45 @@ export const STATS_OPERATOR_NAMES = [
39
39
  "popSD",
40
40
  ] as const;
41
41
 
42
+ /** Round decimal ties away from zero without an intermediate scaled binary float. */
42
43
  export function roundToDecimalPlaces(value: number, figures: number): number {
43
- const factor = 10 ** figures;
44
- return Math.round(value * factor) / factor;
44
+ if (!Number.isFinite(value) || value === 0) return value;
45
+ const decimal = decimalDigits(value);
46
+ const retainedDigits = decimal.exponent + figures + 1;
47
+ return roundDecimalDigits(value, decimal, retainedDigits);
45
48
  }
46
49
 
50
+ /** Round significant decimal digits, with half-up ties on the absolute magnitude. */
47
51
  export function roundToSignificantFigures(value: number, figures: number): number {
48
52
  if (value === 0 || figures <= 0) return 0;
49
- const factor = 10 ** (figures - 1 - Math.floor(Math.log10(Math.abs(value))));
50
- return Math.round(value * factor) / factor;
53
+ if (!Number.isFinite(value)) return Number.NaN;
54
+ return roundDecimalDigits(value, decimalDigits(value), figures);
55
+ }
56
+
57
+ interface DecimalDigits {
58
+ digits: string;
59
+ exponent: number;
60
+ }
61
+
62
+ function decimalDigits(value: number): DecimalDigits {
63
+ const [coefficient = "", exponent = "0"] = Math.abs(value).toExponential().split("e");
64
+ return {
65
+ digits: coefficient.replace(".", ""),
66
+ exponent: Number(exponent),
67
+ };
68
+ }
69
+
70
+ function roundDecimalDigits(value: number, decimal: DecimalDigits, retainedDigits: number): number {
71
+ if (retainedDigits >= decimal.digits.length) return value;
72
+ if (retainedDigits < 0) return value < 0 ? -0 : 0;
73
+
74
+ const roundingDigit = decimal.digits[retainedDigits] ?? "0";
75
+ const retained = retainedDigits === 0 ? 0n : BigInt(decimal.digits.slice(0, retainedDigits));
76
+ const rounded = roundingDigit >= "5" ? retained + 1n : retained;
77
+ if (rounded === 0n) return value < 0 ? -0 : 0;
78
+
79
+ const unitExponent = decimal.exponent - retainedDigits + 1;
80
+ return Number(`${value < 0 ? "-" : ""}${rounded}e${unitExponent}`);
51
81
  }
52
82
 
53
83
  export function roundWithMode(value: number, roundingMode: string, figures: number): number | null {
@@ -151,7 +181,10 @@ export function mathOperatorValue(name: string, values: number[]): QtiValue {
151
181
  }
152
182
 
153
183
  export function statsOperatorValue(name: string, values: number[]): QtiValue {
154
- if (values.length === 0) return 0;
184
+ // QTI 3.0.1 defines the input as a numeric container but does not define empty-container
185
+ // results, so this implementation conservatively returns NULL.
186
+ // https://www.imsglobal.org/sites/default/files/spec/qti/v3/info/index.html#OpStatsOp
187
+ if (values.length === 0) return null;
155
188
  const meanValue = mean(values);
156
189
  const squareDiffs = values.map((value) => (value - meanValue) ** 2);
157
190
  switch (name) {
@@ -1,18 +1,32 @@
1
- import type { QtiRecordValue, QtiResponseDeclaration, QtiScalarValue, QtiValue } from "./types.js";
1
+ import { parseQtiPair } from "./parser-values.js";
2
+ import type {
3
+ QtiBaseType,
4
+ QtiRecordValue,
5
+ QtiResponseDeclaration,
6
+ QtiScalarValue,
7
+ QtiValue,
8
+ } from "./types.js";
2
9
  import { qtiValueToString } from "./value-format.js";
3
10
 
4
11
  export function isNullResponse(response: QtiValue): boolean {
5
12
  return response === null || response === "" || (Array.isArray(response) && response.length === 0);
6
13
  }
7
14
 
8
- export function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boolean {
15
+ export function valuesEqual(
16
+ actual: QtiValue,
17
+ expected: QtiValue,
18
+ ordered = false,
19
+ baseType?: QtiBaseType,
20
+ ): boolean {
9
21
  if (isRecordValue(actual) || isRecordValue(expected)) {
10
22
  if (!isRecordValue(actual) || !isRecordValue(expected)) return false;
11
23
  const actualKeys = Object.keys(actual).toSorted();
12
24
  const expectedKeys = Object.keys(expected).toSorted();
13
25
  return (
14
26
  valuesEqual(actualKeys, expectedKeys, true) &&
15
- actualKeys.every((key) => valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered))
27
+ actualKeys.every((key) =>
28
+ valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered, baseType),
29
+ )
16
30
  );
17
31
  }
18
32
  if (Array.isArray(actual) || Array.isArray(expected)) {
@@ -20,25 +34,38 @@ export function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = fals
20
34
  const expectedValues = Array.isArray(expected) ? expected : expected === null ? [] : [expected];
21
35
  if (actualValues.length !== expectedValues.length) return false;
22
36
  if (ordered)
23
- return actualValues.every((value, index) => scalarValuesEqual(value, expectedValues[index]!));
24
- const sortedExpected = [...expectedValues].toSorted(compareScalarValues);
37
+ return actualValues.every((value, index) =>
38
+ scalarValuesEqual(value, expectedValues[index]!, baseType),
39
+ );
40
+ const compareValues = (left: QtiScalarValue, right: QtiScalarValue): number =>
41
+ compareScalarValues(left, right, baseType);
42
+ const sortedExpected = [...expectedValues].toSorted(compareValues);
25
43
  return [...actualValues]
26
- .toSorted(compareScalarValues)
27
- .every((value, index) => scalarValuesEqual(value, sortedExpected[index]!));
44
+ .toSorted(compareValues)
45
+ .every((value, index) => scalarValuesEqual(value, sortedExpected[index]!, baseType));
28
46
  }
29
- return scalarValuesEqual(actual, expected);
47
+ return scalarValuesEqual(actual, expected, baseType);
30
48
  }
31
49
 
32
50
  export function qtiMatchValues(
33
51
  actual: QtiValue,
34
52
  expected: QtiValue,
35
53
  ordered = false,
54
+ baseType?: QtiBaseType,
36
55
  ): boolean | null {
37
56
  if (actual === null || expected === null) return null;
38
- return valuesEqual(actual, expected, ordered);
57
+ return valuesEqual(actual, expected, ordered, baseType);
39
58
  }
40
59
 
41
- function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
60
+ function scalarValuesEqual(actual: QtiValue, expected: QtiValue, baseType?: QtiBaseType): boolean {
61
+ if (
62
+ (baseType === "pair" || baseType === "directedPair") &&
63
+ typeof actual === "string" &&
64
+ typeof expected === "string"
65
+ ) {
66
+ const normalizedActual = parseQtiPair(actual, baseType);
67
+ return normalizedActual !== undefined && normalizedActual === parseQtiPair(expected, baseType);
68
+ }
42
69
  if (typeof actual === "boolean" && typeof expected === "string") {
43
70
  return String(actual) === expected;
44
71
  }
@@ -79,18 +106,36 @@ export function isRecordValue(value: QtiValue): value is QtiRecordValue {
79
106
  return typeof value === "object" && value !== null && !Array.isArray(value);
80
107
  }
81
108
 
82
- export function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
109
+ export function containsValues(
110
+ collection: QtiScalarValue[],
111
+ values: QtiScalarValue[],
112
+ baseType?: QtiBaseType,
113
+ ): boolean {
83
114
  const remaining = [...collection];
84
115
  for (const value of values) {
85
- const index = remaining.findIndex((candidate) => valuesEqual(candidate, value));
116
+ const index = remaining.findIndex((candidate) =>
117
+ valuesEqual(candidate, value, false, baseType),
118
+ );
86
119
  if (index === -1) return false;
87
120
  remaining.splice(index, 1);
88
121
  }
89
122
  return true;
90
123
  }
91
124
 
92
- function compareScalarValues(left: QtiScalarValue, right: QtiScalarValue): number {
93
- return String(left).localeCompare(String(right));
125
+ function compareScalarValues(
126
+ left: QtiScalarValue,
127
+ right: QtiScalarValue,
128
+ baseType?: QtiBaseType,
129
+ ): number {
130
+ const leftKey =
131
+ (baseType === "pair" || baseType === "directedPair") && typeof left === "string"
132
+ ? (parseQtiPair(left, baseType) ?? left)
133
+ : String(left);
134
+ const rightKey =
135
+ (baseType === "pair" || baseType === "directedPair") && typeof right === "string"
136
+ ? (parseQtiPair(right, baseType) ?? right)
137
+ : String(right);
138
+ return leftKey.localeCompare(rightKey);
94
139
  }
95
140
 
96
141
  export function numericValue(value: QtiValue): number {
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ QtiBaseType,
2
3
  QtiDocument,
3
4
  QtiOutcomeDeclaration,
4
5
  QtiProcessingExpression,
@@ -6,6 +7,7 @@ import type {
6
7
  QtiTemplateDeclaration,
7
8
  QtiValue,
8
9
  } from "./types.js";
10
+ import { parseBaseType } from "./parser-values.js";
9
11
 
10
12
  export function getResponseDeclaration(
11
13
  document: QtiDocument,
@@ -51,11 +53,11 @@ export function expressionIsOrdered(
51
53
  document: QtiDocument,
52
54
  ): boolean {
53
55
  if (expression.type === "ordered") return true;
56
+ if (expression.type === "delete") return expressionIsOrdered(expression.collection, document);
54
57
  if (
55
58
  (expression.type === "variable" ||
56
59
  expression.type === "correct" ||
57
- expression.type === "default" ||
58
- expression.type === "isNull") &&
60
+ expression.type === "default") &&
59
61
  variableCardinality(document, expression.identifier) === "ordered"
60
62
  ) {
61
63
  return true;
@@ -63,6 +65,49 @@ export function expressionIsOrdered(
63
65
  return false;
64
66
  }
65
67
 
68
+ /** Resolve the declared atomic base type for an expression when it is statically knowable. */
69
+ export function expressionBaseType(
70
+ expression: QtiProcessingExpression,
71
+ document: QtiDocument,
72
+ ): QtiBaseType | undefined {
73
+ if (expression.type === "baseValue") return parseBaseType(expression.baseType);
74
+ if (expression.type === "isNull") return "boolean";
75
+ if (
76
+ expression.type === "variable" ||
77
+ expression.type === "correct" ||
78
+ expression.type === "default"
79
+ ) {
80
+ return resolveVariableDeclaration(document, expression.identifier)?.baseType;
81
+ }
82
+ if (expression.type === "index") return expressionBaseType(expression.expression, document);
83
+ if (expression.type === "delete") return expressionBaseType(expression.collection, document);
84
+ if (
85
+ expression.type === "random" ||
86
+ expression.type === "multiple" ||
87
+ expression.type === "ordered" ||
88
+ expression.type === "repeat"
89
+ ) {
90
+ return commonExpressionBaseType(
91
+ expression.type === "random" ? expression.values : expression.expressions,
92
+ document,
93
+ );
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ function commonExpressionBaseType(
99
+ expressions: QtiProcessingExpression[],
100
+ document: QtiDocument,
101
+ ): QtiBaseType | undefined {
102
+ const [first, ...rest] = expressions;
103
+ if (!first) return undefined;
104
+ const baseType = expressionBaseType(first, document);
105
+ if (!baseType) return undefined;
106
+ return rest.every((expression) => expressionBaseType(expression, document) === baseType)
107
+ ? baseType
108
+ : undefined;
109
+ }
110
+
66
111
  function variableCardinality(document: QtiDocument, identifier: string): string | undefined {
67
112
  return resolveVariableDeclaration(document, identifier)?.cardinality;
68
113
  }
@@ -0,0 +1,8 @@
1
+ /** Namespace for QTI 3 assessment, section, and item documents. */
2
+ export const QTI_ASI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
3
+
4
+ /** Namespace for MathML embedded in QTI content. */
5
+ export const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
6
+
7
+ /** Namespace for QTI 3 content-package manifests. */
8
+ export const QTI_PACKAGE_MANIFEST_NAMESPACE = "http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p1";
@@ -34,6 +34,7 @@ import {
34
34
  } from "./qti-package-xml.js";
35
35
  import type { QtiPackageEntry } from "./qti-package-zip.js";
36
36
  import type { QtiDiagnostic } from "./types.js";
37
+ import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
37
38
 
38
39
  export interface PackageItemReference {
39
40
  readonly href: string;
@@ -83,13 +84,18 @@ export function parseAssessmentTestPackageModel(
83
84
  const xmlFile = xmlFilesByPath.get(href) ?? parsePackageXml(entry);
84
85
  const testDiagnostics: QtiDiagnostic[] = [];
85
86
  pushXmlDiagnostics(xmlFile, testDiagnostics);
86
- const root = xmlFile.root?.localName === "qti-assessment-test" ? xmlFile.root : undefined;
87
+ const root =
88
+ xmlFile.root?.localName === "qti-assessment-test" && xmlFile.root.uri === QTI_ASI_NAMESPACE
89
+ ? xmlFile.root
90
+ : undefined;
87
91
  if (xmlFile.root && !root) {
88
92
  pushPackageDiagnostic(
89
93
  testDiagnostics,
90
94
  "package.assessmentTest.root",
91
95
  "error",
92
- `Expected qti-assessment-test root, found ${xmlFile.root.localName}.`,
96
+ xmlFile.root.localName === "qti-assessment-test"
97
+ ? `Expected qti-assessment-test in namespace ${QTI_ASI_NAMESPACE}, found ${xmlFile.root.uri ?? "(none)"}.`
98
+ : `Expected qti-assessment-test root, found ${xmlFile.root.localName}.`,
93
99
  href,
94
100
  );
95
101
  }
@@ -98,7 +104,7 @@ export function parseAssessmentTestPackageModel(
98
104
  const itemRefs = testParts.flatMap(flattenTestPartItemRefs);
99
105
  const assetHrefs = root ? discoverContentAssetHrefs(xmlFile, testDiagnostics) : [];
100
106
  const timeLimits = root
101
- ? parseTimeLimits(childPackageElements(root, "qti-time-limits")[0], href, testDiagnostics)
107
+ ? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], href, testDiagnostics)
102
108
  : undefined;
103
109
  const timing = root ? parseTimingMetadata(root, href, testDiagnostics, timeLimits) : undefined;
104
110
  const scopedDiagnostics = testDiagnostics.map((diagnostic) =>
@@ -128,18 +134,18 @@ function parseAssessmentTestParts(
128
134
  sourcePath: string,
129
135
  diagnostics: QtiDiagnostic[],
130
136
  ): QtiTestPartPackageModel[] {
131
- return childPackageElements(root, "qti-test-part").map((partNode) => {
137
+ return qtiChildElements(root, "qti-test-part").map((partNode) => {
132
138
  const identifier = partNode.attributes.identifier ?? "";
133
139
  return {
134
140
  identifier,
135
141
  navigationMode: parseNavigationMode(partNode, sourcePath, diagnostics),
136
142
  submissionMode: parseSubmissionMode(partNode, sourcePath, diagnostics),
137
143
  timeLimits: parseTimeLimits(
138
- childPackageElements(partNode, "qti-time-limits")[0],
144
+ qtiChildElements(partNode, "qti-time-limits")[0],
139
145
  sourcePath,
140
146
  diagnostics,
141
147
  ),
142
- sections: childPackageElements(partNode, "qti-assessment-section").map((sectionNode) =>
148
+ sections: qtiChildElements(partNode, "qti-assessment-section").map((sectionNode) =>
143
149
  parseAssessmentSection(sectionNode, identifier, undefined, sourcePath, diagnostics),
144
150
  ),
145
151
  attributes: { ...partNode.attributes },
@@ -156,43 +162,41 @@ function parseAssessmentSection(
156
162
  ): QtiAssessmentSectionPackageModel {
157
163
  const identifier = sectionNode.attributes.identifier ?? "";
158
164
  const visible = parseOptionalXmlBoolean(sectionNode, "visible", sourcePath, diagnostics);
159
- const itemRefs = childPackageElements(sectionNode, "qti-assessment-item-ref").flatMap(
160
- (refNode) => {
161
- const rawHref = refNode.attributes.href;
162
- if (!rawHref) {
163
- pushPackageDiagnostic(
165
+ const itemRefs = qtiChildElements(sectionNode, "qti-assessment-item-ref").flatMap((refNode) => {
166
+ const rawHref = refNode.attributes.href;
167
+ if (!rawHref) {
168
+ pushPackageDiagnostic(
169
+ diagnostics,
170
+ "package.assessmentTest.itemRef.href.missing",
171
+ "error",
172
+ "qti-assessment-item-ref is missing href.",
173
+ sourcePath,
174
+ );
175
+ return [];
176
+ }
177
+
178
+ const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
179
+ if (!href) return [];
180
+ return [
181
+ {
182
+ identifier: refNode.attributes.identifier,
183
+ href,
184
+ testPartIdentifier,
185
+ sectionIdentifier: identifier,
186
+ timeLimits: parseTimeLimits(
187
+ qtiChildElements(refNode, "qti-time-limits")[0],
188
+ sourcePath,
164
189
  diagnostics,
165
- "package.assessmentTest.itemRef.href.missing",
166
- "error",
167
- "qti-assessment-item-ref is missing href.",
190
+ ),
191
+ itemSessionControl: parseItemSessionControl(
192
+ qtiChildElements(refNode, "qti-item-session-control")[0],
168
193
  sourcePath,
169
- );
170
- return [];
171
- }
172
-
173
- const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
174
- if (!href) return [];
175
- return [
176
- {
177
- identifier: refNode.attributes.identifier,
178
- href,
179
- testPartIdentifier,
180
- sectionIdentifier: identifier,
181
- timeLimits: parseTimeLimits(
182
- childPackageElements(refNode, "qti-time-limits")[0],
183
- sourcePath,
184
- diagnostics,
185
- ),
186
- itemSessionControl: parseItemSessionControl(
187
- childPackageElements(refNode, "qti-item-session-control")[0],
188
- sourcePath,
189
- diagnostics,
190
- ),
191
- attributes: { ...refNode.attributes },
192
- },
193
- ];
194
- },
195
- );
194
+ diagnostics,
195
+ ),
196
+ attributes: { ...refNode.attributes },
197
+ },
198
+ ];
199
+ });
196
200
 
197
201
  return {
198
202
  identifier,
@@ -201,12 +205,12 @@ function parseAssessmentSection(
201
205
  testPartIdentifier,
202
206
  parentSectionIdentifier,
203
207
  timeLimits: parseTimeLimits(
204
- childPackageElements(sectionNode, "qti-time-limits")[0],
208
+ qtiChildElements(sectionNode, "qti-time-limits")[0],
205
209
  sourcePath,
206
210
  diagnostics,
207
211
  ),
208
212
  itemRefs,
209
- sections: childPackageElements(sectionNode, "qti-assessment-section").map((child) =>
213
+ sections: qtiChildElements(sectionNode, "qti-assessment-section").map((child) =>
210
214
  parseAssessmentSection(child, testPartIdentifier, identifier, sourcePath, diagnostics),
211
215
  ),
212
216
  attributes: { ...sectionNode.attributes },
@@ -385,14 +389,13 @@ export function parsePackageItems(
385
389
  diagnostics.push(...itemDiagnostics);
386
390
 
387
391
  const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
388
- const root = xmlFile.root?.localName === "qti-assessment-item" ? xmlFile.root : undefined;
392
+ const root =
393
+ xmlFile.root?.localName === "qti-assessment-item" && xmlFile.root.uri === QTI_ASI_NAMESPACE
394
+ ? xmlFile.root
395
+ : undefined;
389
396
  const item = parsed.document?.item;
390
397
  const timeLimits = root
391
- ? parseTimeLimits(
392
- childPackageElements(root, "qti-time-limits")[0],
393
- reference.href,
394
- diagnostics,
395
- )
398
+ ? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], reference.href, diagnostics)
396
399
  : undefined;
397
400
 
398
401
  items.push({
@@ -421,3 +424,7 @@ export function parsePackageItems(
421
424
 
422
425
  return items;
423
426
  }
427
+
428
+ function qtiChildElements(node: QtiPackageXmlNode, localName: string): QtiPackageXmlNode[] {
429
+ return childPackageElements(node, localName, QTI_ASI_NAMESPACE);
430
+ }
@@ -5,6 +5,7 @@ import type { QtiManifestFile, QtiManifestResource, QtiPackageShape } from "./qt
5
5
  import { childPackageElements, packageDescendants, stripHrefSuffix } from "./qti-package-xml.js";
6
6
  import type { QtiPackageEntry } from "./qti-package-zip.js";
7
7
  import type { QtiDiagnostic } from "./types.js";
8
+ import { QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
8
9
 
9
10
  export const QTI_PACKAGE_MANIFEST_PATH = "imsmanifest.xml";
10
11
 
@@ -23,8 +24,12 @@ export function parseManifestResources(
23
24
  entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
24
25
  diagnostics: QtiDiagnostic[],
25
26
  ): QtiManifestResource[] {
26
- const resourceNodes = packageDescendants(manifestRoot, "resources").flatMap((resources) =>
27
- childPackageElements(resources, "resource"),
27
+ const resourceNodes = packageDescendants(
28
+ manifestRoot,
29
+ "resources",
30
+ QTI_PACKAGE_MANIFEST_NAMESPACE,
31
+ ).flatMap((resources) =>
32
+ childPackageElements(resources, "resource", QTI_PACKAGE_MANIFEST_NAMESPACE),
28
33
  );
29
34
  const resources: QtiManifestResource[] = [];
30
35
 
@@ -71,7 +76,7 @@ function parseManifestFiles(
71
76
  diagnostics: QtiDiagnostic[],
72
77
  ): QtiManifestFile[] {
73
78
  const files: QtiManifestFile[] = [];
74
- for (const fileNode of packageDescendants(resourceNode, "file")) {
79
+ for (const fileNode of packageDescendants(resourceNode, "file", QTI_PACKAGE_MANIFEST_NAMESPACE)) {
75
80
  const rawHref = fileNode.attributes.href;
76
81
  if (!rawHref) {
77
82
  pushPackageDiagnostic(
@@ -105,7 +110,11 @@ function parseManifestDependencies(
105
110
  diagnostics: QtiDiagnostic[],
106
111
  ): string[] {
107
112
  const dependencies: string[] = [];
108
- for (const dependencyNode of packageDescendants(resourceNode, "dependency")) {
113
+ for (const dependencyNode of packageDescendants(
114
+ resourceNode,
115
+ "dependency",
116
+ QTI_PACKAGE_MANIFEST_NAMESPACE,
117
+ )) {
109
118
  const identifierref = dependencyNode.attributes.identifierref;
110
119
  if (!identifierref) {
111
120
  pushPackageDiagnostic(
@@ -11,6 +11,7 @@ import type {
11
11
  QtiTimingMetadata,
12
12
  } from "./qti-package-types.js";
13
13
  import type { QtiDiagnostic } from "./types.js";
14
+ import { QTI_ASI_NAMESPACE, QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
14
15
 
15
16
  /** Known QTI / IMS CP alignment element names plus attribute-driven manifest metadata. */
16
17
  const STANDARD_ALIGNMENT_ELEMENT_NAMES = new Set([
@@ -34,7 +35,7 @@ export function parseTimingMetadata(
34
35
  diagnostics: QtiDiagnostic[],
35
36
  parsedTimeLimits?: QtiTimeLimits,
36
37
  ): QtiTimingMetadata | undefined {
37
- const timeLimitsNode = childPackageElements(root, "qti-time-limits")[0];
38
+ const timeLimitsNode = childPackageElements(root, "qti-time-limits", QTI_ASI_NAMESPACE)[0];
38
39
  const timeLimits = parsedTimeLimits ?? parseTimeLimits(timeLimitsNode, sourcePath, diagnostics);
39
40
  const timeDependent = parseXmlBoolean(root.attributes["time-dependent"]);
40
41
  if (!timeLimitsNode && timeDependent === undefined) return undefined;
@@ -296,7 +297,7 @@ export function packageTitle(
296
297
  items: readonly QtiPackageItem[],
297
298
  ): string {
298
299
  const manifestTitle = manifestRoot
299
- ? packageDescendants(manifestRoot, "title")
300
+ ? packageDescendants(manifestRoot, "title", QTI_PACKAGE_MANIFEST_NAMESPACE)
300
301
  .map((node) => node.text.trim())
301
302
  .find((text) => text.length > 0)
302
303
  : undefined;
@@ -39,22 +39,35 @@ export function pushXmlDiagnostics(xmlFile: PackageXmlFile, diagnostics: QtiDiag
39
39
  }
40
40
  }
41
41
 
42
+ /** Return direct package-XML children, optionally restricted by local name and namespace. */
42
43
  export function childPackageElements(
43
44
  node: QtiPackageXmlNode,
44
45
  localName?: string,
46
+ namespaceUri?: string,
45
47
  ): QtiPackageXmlNode[] {
46
- return node.children.filter((child) => !localName || child.localName === localName);
48
+ return node.children.filter(
49
+ (child) =>
50
+ (!localName || child.localName === localName) &&
51
+ (namespaceUri === undefined || child.uri === namespaceUri),
52
+ );
47
53
  }
48
54
 
55
+ /** Return package-XML descendants, optionally restricted by local name and namespace. */
49
56
  export function packageDescendants(
50
57
  node: QtiPackageXmlNode | undefined,
51
58
  localName?: string,
59
+ namespaceUri?: string,
52
60
  ): QtiPackageXmlNode[] {
53
61
  if (!node) return [];
54
62
  const found: QtiPackageXmlNode[] = [];
55
63
  for (const child of node.children) {
56
- if (!localName || child.localName === localName) found.push(child);
57
- found.push(...packageDescendants(child, localName));
64
+ if (
65
+ (!localName || child.localName === localName) &&
66
+ (namespaceUri === undefined || child.uri === namespaceUri)
67
+ ) {
68
+ found.push(child);
69
+ }
70
+ found.push(...packageDescendants(child, localName, namespaceUri));
58
71
  }
59
72
  return found;
60
73
  }