@longsightgroup/qti3-core 0.10.3 → 0.10.5

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 (131) hide show
  1. package/README.md +35 -0
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +2 -27
  4. package/dist/attempt-state.js.map +1 -1
  5. package/dist/content-text.d.ts.map +1 -1
  6. package/dist/content-text.js +31 -9
  7. package/dist/content-text.js.map +1 -1
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/package-xml.d.ts +1 -0
  13. package/dist/package-xml.d.ts.map +1 -1
  14. package/dist/package-xml.js +1 -0
  15. package/dist/package-xml.js.map +1 -1
  16. package/dist/parser-custom-interactions.js +1 -0
  17. package/dist/parser-custom-interactions.js.map +1 -1
  18. package/dist/parser-item-metadata.d.ts.map +1 -1
  19. package/dist/parser-item-metadata.js +1 -0
  20. package/dist/parser-item-metadata.js.map +1 -1
  21. package/dist/parser-values.d.ts +5 -1
  22. package/dist/parser-values.d.ts.map +1 -1
  23. package/dist/parser-values.js +31 -5
  24. package/dist/parser-values.js.map +1 -1
  25. package/dist/parser.d.ts.map +1 -1
  26. package/dist/parser.js +65 -34
  27. package/dist/parser.js.map +1 -1
  28. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  29. package/dist/processing-evaluator-comparison.js +16 -6
  30. package/dist/processing-evaluator-comparison.js.map +1 -1
  31. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  32. package/dist/processing-evaluator-variable.js +1 -1
  33. package/dist/processing-evaluator-variable.js.map +1 -1
  34. package/dist/processing-mapping.d.ts.map +1 -1
  35. package/dist/processing-mapping.js +15 -7
  36. package/dist/processing-mapping.js.map +1 -1
  37. package/dist/processing-values.d.ts +4 -4
  38. package/dist/processing-values.d.ts.map +1 -1
  39. package/dist/processing-values.js +28 -14
  40. package/dist/processing-values.js.map +1 -1
  41. package/dist/processing-variables.d.ts +3 -1
  42. package/dist/processing-variables.d.ts.map +1 -1
  43. package/dist/processing-variables.js +38 -2
  44. package/dist/processing-variables.js.map +1 -1
  45. package/dist/qti-namespaces.d.ts +7 -0
  46. package/dist/qti-namespaces.d.ts.map +1 -0
  47. package/dist/qti-namespaces.js +7 -0
  48. package/dist/qti-namespaces.js.map +1 -0
  49. package/dist/qti-package-assets.d.ts +3 -2
  50. package/dist/qti-package-assets.d.ts.map +1 -1
  51. package/dist/qti-package-assets.js.map +1 -1
  52. package/dist/qti-package-items.d.ts +1 -0
  53. package/dist/qti-package-items.d.ts.map +1 -1
  54. package/dist/qti-package-items.js +55 -41
  55. package/dist/qti-package-items.js.map +1 -1
  56. package/dist/qti-package-manifest.d.ts +3 -2
  57. package/dist/qti-package-manifest.d.ts.map +1 -1
  58. package/dist/qti-package-manifest.js +4 -3
  59. package/dist/qti-package-manifest.js.map +1 -1
  60. package/dist/qti-package-metadata.d.ts +3 -2
  61. package/dist/qti-package-metadata.d.ts.map +1 -1
  62. package/dist/qti-package-metadata.js +8 -4
  63. package/dist/qti-package-metadata.js.map +1 -1
  64. package/dist/qti-package-plan.d.ts +41 -0
  65. package/dist/qti-package-plan.d.ts.map +1 -0
  66. package/dist/qti-package-plan.js +85 -0
  67. package/dist/qti-package-plan.js.map +1 -0
  68. package/dist/qti-package-stream.d.ts +38 -0
  69. package/dist/qti-package-stream.d.ts.map +1 -0
  70. package/dist/qti-package-stream.js +149 -0
  71. package/dist/qti-package-stream.js.map +1 -0
  72. package/dist/qti-package-xml.d.ts +4 -2
  73. package/dist/qti-package-xml.d.ts.map +1 -1
  74. package/dist/qti-package-xml.js +10 -5
  75. package/dist/qti-package-xml.js.map +1 -1
  76. package/dist/qti-package-zip.d.ts +17 -1
  77. package/dist/qti-package-zip.d.ts.map +1 -1
  78. package/dist/qti-package-zip.js +60 -5
  79. package/dist/qti-package-zip.js.map +1 -1
  80. package/dist/qti-package.d.ts +3 -3
  81. package/dist/qti-package.d.ts.map +1 -1
  82. package/dist/qti-package.js +23 -47
  83. package/dist/qti-package.js.map +1 -1
  84. package/dist/response-validation.d.ts +12 -2
  85. package/dist/response-validation.d.ts.map +1 -1
  86. package/dist/response-validation.js +122 -19
  87. package/dist/response-validation.js.map +1 -1
  88. package/dist/session.js +1 -1
  89. package/dist/session.js.map +1 -1
  90. package/dist/trusted-item-session.d.ts.map +1 -1
  91. package/dist/trusted-item-session.js +5 -3
  92. package/dist/trusted-item-session.js.map +1 -1
  93. package/dist/types.d.ts +2 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/validation-primitives.d.ts +5 -1
  96. package/dist/validation-primitives.d.ts.map +1 -1
  97. package/dist/validation-primitives.js +40 -2
  98. package/dist/validation-primitives.js.map +1 -1
  99. package/dist/xml.d.ts.map +1 -1
  100. package/dist/xml.js +28 -0
  101. package/dist/xml.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/attempt-state.ts +2 -26
  104. package/src/content-text.ts +45 -9
  105. package/src/index.ts +11 -0
  106. package/src/package-xml.ts +2 -0
  107. package/src/parser-custom-interactions.ts +1 -0
  108. package/src/parser-item-metadata.ts +1 -0
  109. package/src/parser-values.ts +31 -5
  110. package/src/parser.ts +122 -91
  111. package/src/processing-evaluator-comparison.ts +24 -5
  112. package/src/processing-evaluator-variable.ts +1 -0
  113. package/src/processing-mapping.ts +22 -6
  114. package/src/processing-values.ts +59 -14
  115. package/src/processing-variables.ts +47 -2
  116. package/src/qti-namespaces.ts +8 -0
  117. package/src/qti-package-assets.ts +3 -4
  118. package/src/qti-package-items.ts +101 -84
  119. package/src/qti-package-manifest.ts +15 -7
  120. package/src/qti-package-metadata.ts +19 -6
  121. package/src/qti-package-plan.ts +168 -0
  122. package/src/qti-package-stream.ts +254 -0
  123. package/src/qti-package-xml.ts +16 -3
  124. package/src/qti-package-zip.ts +128 -3
  125. package/src/qti-package.ts +30 -81
  126. package/src/response-validation.ts +185 -20
  127. package/src/session.ts +1 -0
  128. package/src/trusted-item-session.ts +5 -3
  129. package/src/types.ts +2 -0
  130. package/src/validation-primitives.ts +43 -3
  131. package/src/xml.ts +37 -0
@@ -9,7 +9,7 @@ import {
9
9
  valuesEqual,
10
10
  valueContainer,
11
11
  } from "./processing-values.js";
12
- import { expressionIsOrdered } from "./processing-variables.js";
12
+ import { expressionBaseType, expressionIsOrdered } from "./processing-variables.js";
13
13
  import { roundWithMode } from "./processing-operators.js";
14
14
 
15
15
  type ComparisonExpression = Extract<
@@ -40,12 +40,22 @@ export function evaluateComparisonExpression(
40
40
  right,
41
41
  expressionIsOrdered(expression.left, context.document) ||
42
42
  expressionIsOrdered(expression.right, context.document),
43
+ expressionBaseType(expression.left, context.document) ??
44
+ expressionBaseType(expression.right, context.document),
43
45
  );
44
46
  }
45
47
  case "equal": {
46
48
  const left = context.evaluate(expression.left);
47
49
  const right = context.evaluate(expression.right);
48
- return left === null || right === null ? null : valuesEqual(left, right);
50
+ return left === null || right === null
51
+ ? null
52
+ : valuesEqual(
53
+ left,
54
+ right,
55
+ false,
56
+ expressionBaseType(expression.left, context.document) ??
57
+ expressionBaseType(expression.right, context.document),
58
+ );
49
59
  }
50
60
  case "equalRounded": {
51
61
  const validRounding =
@@ -86,7 +96,10 @@ export function evaluateComparisonExpression(
86
96
  const value = context.evaluate(expression.value);
87
97
  const collection = context.evaluate(expression.collection);
88
98
  if (value === null || collection === null) return null;
89
- return valueContainer(collection).some((item) => valuesEqual(item, value));
99
+ const baseType =
100
+ expressionBaseType(expression.value, context.document) ??
101
+ expressionBaseType(expression.collection, context.document);
102
+ return valueContainer(collection).some((item) => valuesEqual(item, value, false, baseType));
90
103
  }
91
104
  case "delete": {
92
105
  const value = context.evaluate(expression.value);
@@ -94,7 +107,10 @@ export function evaluateComparisonExpression(
94
107
  if (value === null || collectionValue === null) return null;
95
108
  const collection = valueContainer(collectionValue);
96
109
  if (collection.length === 0) return null;
97
- const filtered = collection.filter((item) => !valuesEqual(item, value));
110
+ const baseType =
111
+ expressionBaseType(expression.value, context.document) ??
112
+ expressionBaseType(expression.collection, context.document);
113
+ const filtered = collection.filter((item) => !valuesEqual(item, value, false, baseType));
98
114
  return filtered.length > 0 ? filtered : null;
99
115
  }
100
116
  case "contains": {
@@ -104,7 +120,10 @@ export function evaluateComparisonExpression(
104
120
  const collection = valueContainer(collectionValue);
105
121
  const values = valueContainer(valuesValue);
106
122
  if (collection.length === 0 || values.length === 0) return null;
107
- return containsValues(collection, values);
123
+ const baseType =
124
+ expressionBaseType(expression.collection, context.document) ??
125
+ expressionBaseType(expression.values, context.document);
126
+ return containsValues(collection, values, baseType);
108
127
  }
109
128
  default:
110
129
  return assertNever(expression);
@@ -37,6 +37,7 @@ export function evaluateVariableExpression(
37
37
  context.responses[expression.identifier] ?? null,
38
38
  context.correctResponses[expression.correctIdentifier] ?? null,
39
39
  declaration.cardinality === "ordered",
40
+ declaration.baseType,
40
41
  )
41
42
  : false;
42
43
  }
@@ -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(
@@ -125,15 +133,17 @@ function pointInsidePolygon(point: { x: number; y: number }, coords: number[]):
125
133
  function scoreMapping(
126
134
  response: QtiValue,
127
135
  mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
136
+ baseType: QtiResponseDeclaration["baseType"],
128
137
  ): number {
129
- const values = Object.fromEntries(
138
+ const values = new Map(
130
139
  mapping.entries
131
140
  .filter((entry) => entry.mapKey !== undefined)
132
- .map((entry) => [entry.mapKey!, entry.mappedValue]),
141
+ .map((entry) => [mappingKey(entry.mapKey!, baseType), entry.mappedValue] as const),
133
142
  );
134
143
  if (Array.isArray(response)) {
135
144
  const score = response.reduce<number>(
136
- (sum, value) => sum + (values[String(value)] ?? mapping.defaultValue),
145
+ (sum, value) =>
146
+ sum + (values.get(mappingKey(String(value), baseType)) ?? mapping.defaultValue),
137
147
  0,
138
148
  );
139
149
  return clampMappedScore(score, mapping.attributes);
@@ -141,10 +151,16 @@ function scoreMapping(
141
151
  const score =
142
152
  response === null || isRecordValue(response)
143
153
  ? 0
144
- : (values[String(response)] ?? mapping.defaultValue);
154
+ : (values.get(mappingKey(String(response), baseType)) ?? mapping.defaultValue);
145
155
  return clampMappedScore(score, mapping.attributes);
146
156
  }
147
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
+
148
164
  function clampMappedScore(score: number, attributes: Record<string, string>): number {
149
165
  const lower = numericBound(attributes["lower-bound"]);
150
166
  const upper = numericBound(attributes["upper-bound"]);
@@ -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";
@@ -14,7 +14,6 @@ import {
14
14
  resolvePackageHref,
15
15
  type PackageXmlFile,
16
16
  } from "./qti-package-xml.js";
17
- import type { QtiPackageEntry } from "./qti-package-zip.js";
18
17
  import type { QtiDiagnostic } from "./types.js";
19
18
 
20
19
  interface PendingAssetReference {
@@ -87,8 +86,8 @@ export function discoverContentAssetHrefs(
87
86
  export function collectPackageAssets(
88
87
  resourcesByIdentifier: ReadonlyMap<string, QtiManifestResource>,
89
88
  assessmentTest: QtiAssessmentTestPackageModel | undefined,
90
- items: readonly QtiPackageItem[],
91
- entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
89
+ items: readonly Pick<QtiPackageItem, "href" | "manifestResourceIdentifier" | "assetHrefs">[],
90
+ entriesByPath: { has(path: string): boolean },
92
91
  diagnostics: QtiDiagnostic[],
93
92
  ): QtiPackageAsset[] {
94
93
  const pending: PendingAssetReference[] = [];
@@ -220,7 +219,7 @@ function collectPackageRelativeTextRefs(
220
219
 
221
220
  function materializeAssets(
222
221
  pending: readonly PendingAssetReference[],
223
- entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
222
+ entriesByPath: { has(path: string): boolean },
224
223
  ): QtiPackageAsset[] {
225
224
  const assetsByHref = new Map<string, PendingAssetReference[]>();
226
225
  for (const reference of pending) {
@@ -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 },
@@ -342,6 +346,55 @@ export function itemReferencesForPackageShape(
342
346
  return references;
343
347
  }
344
348
 
349
+ export function parsePackageItem(
350
+ reference: PackageItemReference,
351
+ entry: QtiPackageEntry,
352
+ xmlFile: PackageXmlFile | undefined,
353
+ diagnostics: QtiDiagnostic[],
354
+ ): QtiPackageItem {
355
+ const resolvedXml = xmlFile ?? parsePackageXml(entry);
356
+ // Full QTI parse for document model; lightweight tree above supplies metadata and asset refs.
357
+ const parsed = parseQtiXml(resolvedXml.xml);
358
+ const itemDiagnostics = parsed.diagnostics.map((diagnostic) =>
359
+ scopeDiagnosticToPackagePath(reference.href, diagnostic),
360
+ );
361
+ diagnostics.push(...itemDiagnostics);
362
+
363
+ const assetHrefs = resolvedXml.root ? discoverContentAssetHrefs(resolvedXml, diagnostics) : [];
364
+ const root =
365
+ resolvedXml.root?.localName === "qti-assessment-item" &&
366
+ resolvedXml.root.uri === QTI_ASI_NAMESPACE
367
+ ? resolvedXml.root
368
+ : undefined;
369
+ const item = parsed.document?.item;
370
+ const timeLimits = root
371
+ ? parseTimeLimits(qtiChildElements(root, "qti-time-limits")[0], reference.href, diagnostics)
372
+ : undefined;
373
+
374
+ return {
375
+ href: reference.href,
376
+ source: reference.source,
377
+ manifestResourceIdentifier: reference.manifestResourceIdentifier,
378
+ assessmentItemRefIdentifier: reference.assessmentItemRefIdentifier,
379
+ identifier: item?.identifier,
380
+ title: item?.title,
381
+ document: parsed.document,
382
+ timing: root ? parseTimingMetadata(root, reference.href, diagnostics, timeLimits) : undefined,
383
+ timeLimits,
384
+ standards: uniqueStandards([
385
+ ...reference.standards.filter(
386
+ (standard) =>
387
+ standard.resourcePartIdentifier === undefined ||
388
+ standard.resourcePartIdentifier === item?.identifier,
389
+ ),
390
+ ...(root ? parseStandardAlignments(root, reference.href) : []),
391
+ ]),
392
+ assetHrefs,
393
+ diagnostics: itemDiagnostics,
394
+ xml: resolvedXml.xml,
395
+ };
396
+ }
397
+
345
398
  export function parsePackageItems(
346
399
  references: readonly PackageItemReference[],
347
400
  entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
@@ -376,48 +429,12 @@ export function parsePackageItems(
376
429
  continue;
377
430
  }
378
431
 
379
- const xmlFile = xmlFilesByPath.get(reference.href) ?? parsePackageXml(entry);
380
- // Full QTI parse for document model; lightweight tree above supplies metadata and asset refs.
381
- const parsed = parseQtiXml(xmlFile.xml);
382
- const itemDiagnostics = parsed.diagnostics.map((diagnostic) =>
383
- scopeDiagnosticToPackagePath(reference.href, diagnostic),
384
- );
385
- diagnostics.push(...itemDiagnostics);
386
-
387
- const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
388
- const root = xmlFile.root?.localName === "qti-assessment-item" ? xmlFile.root : undefined;
389
- const item = parsed.document?.item;
390
- const timeLimits = root
391
- ? parseTimeLimits(
392
- childPackageElements(root, "qti-time-limits")[0],
393
- reference.href,
394
- diagnostics,
395
- )
396
- : undefined;
397
-
398
- items.push({
399
- href: reference.href,
400
- source: reference.source,
401
- manifestResourceIdentifier: reference.manifestResourceIdentifier,
402
- assessmentItemRefIdentifier: reference.assessmentItemRefIdentifier,
403
- identifier: item?.identifier,
404
- title: item?.title,
405
- document: parsed.document,
406
- timing: root ? parseTimingMetadata(root, reference.href, diagnostics, timeLimits) : undefined,
407
- timeLimits,
408
- standards: uniqueStandards([
409
- ...reference.standards.filter(
410
- (standard) =>
411
- standard.resourcePartIdentifier === undefined ||
412
- standard.resourcePartIdentifier === item?.identifier,
413
- ),
414
- ...(root ? parseStandardAlignments(root, reference.href) : []),
415
- ]),
416
- assetHrefs,
417
- diagnostics: itemDiagnostics,
418
- xml: xmlFile.xml,
419
- });
432
+ items.push(parsePackageItem(reference, entry, xmlFilesByPath.get(reference.href), diagnostics));
420
433
  }
421
434
 
422
435
  return items;
423
436
  }
437
+
438
+ function qtiChildElements(node: QtiPackageXmlNode, localName: string): QtiPackageXmlNode[] {
439
+ return childPackageElements(node, localName, QTI_ASI_NAMESPACE);
440
+ }