@longsightgroup/qti3-core 0.10.6 → 0.12.0

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 (274) hide show
  1. package/README.md +126 -0
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/binary-choice.d.ts +9 -0
  11. package/dist/binary-choice.d.ts.map +1 -0
  12. package/dist/binary-choice.js +81 -0
  13. package/dist/binary-choice.js.map +1 -0
  14. package/dist/diagnostics.d.ts +13 -0
  15. package/dist/diagnostics.d.ts.map +1 -0
  16. package/dist/diagnostics.js +28 -0
  17. package/dist/diagnostics.js.map +1 -0
  18. package/dist/index.d.ts +19 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +12 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/item-submission-materialization.d.ts.map +1 -1
  23. package/dist/item-submission-materialization.js +2 -0
  24. package/dist/item-submission-materialization.js.map +1 -1
  25. package/dist/parser-processing.d.ts.map +1 -1
  26. package/dist/parser-processing.js +8 -18
  27. package/dist/parser-processing.js.map +1 -1
  28. package/dist/parser-values.d.ts.map +1 -1
  29. package/dist/parser-values.js +1 -3
  30. package/dist/parser-values.js.map +1 -1
  31. package/dist/parser.js +32 -0
  32. package/dist/parser.js.map +1 -1
  33. package/dist/processing-equality.d.ts +10 -0
  34. package/dist/processing-equality.d.ts.map +1 -0
  35. package/dist/processing-equality.js +78 -0
  36. package/dist/processing-equality.js.map +1 -0
  37. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  38. package/dist/processing-evaluator-comparison.js +14 -9
  39. package/dist/processing-evaluator-comparison.js.map +1 -1
  40. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  41. package/dist/processing-evaluator-numeric.js +2 -1
  42. package/dist/processing-evaluator-numeric.js.map +1 -1
  43. package/dist/processing-evaluator-string.d.ts.map +1 -1
  44. package/dist/processing-evaluator-string.js +26 -10
  45. package/dist/processing-evaluator-string.js.map +1 -1
  46. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  47. package/dist/processing-evaluator-variable.js +9 -3
  48. package/dist/processing-evaluator-variable.js.map +1 -1
  49. package/dist/processing-evaluator.d.ts +5 -1
  50. package/dist/processing-evaluator.d.ts.map +1 -1
  51. package/dist/processing-evaluator.js +13 -4
  52. package/dist/processing-evaluator.js.map +1 -1
  53. package/dist/processing-expression-children.d.ts.map +1 -1
  54. package/dist/processing-expression-children.js +2 -3
  55. package/dist/processing-expression-children.js.map +1 -1
  56. package/dist/processing-expression-tags.d.ts +2 -1
  57. package/dist/processing-expression-tags.d.ts.map +1 -1
  58. package/dist/processing-expression-tags.js +2 -1
  59. package/dist/processing-expression-tags.js.map +1 -1
  60. package/dist/processing-mapping.d.ts.map +1 -1
  61. package/dist/processing-mapping.js +26 -16
  62. package/dist/processing-mapping.js.map +1 -1
  63. package/dist/processing-state.d.ts +2 -2
  64. package/dist/processing-state.d.ts.map +1 -1
  65. package/dist/processing-state.js +5 -2
  66. package/dist/processing-state.js.map +1 -1
  67. package/dist/processing-values.d.ts +1 -2
  68. package/dist/processing-values.d.ts.map +1 -1
  69. package/dist/processing-values.js +1 -3
  70. package/dist/processing-values.js.map +1 -1
  71. package/dist/processing-variables.d.ts.map +1 -1
  72. package/dist/processing-variables.js +10 -5
  73. package/dist/processing-variables.js.map +1 -1
  74. package/dist/qti-identifier.d.ts +3 -0
  75. package/dist/qti-identifier.d.ts.map +1 -0
  76. package/dist/qti-identifier.js +5 -0
  77. package/dist/qti-identifier.js.map +1 -0
  78. package/dist/qti-package-assets.d.ts.map +1 -1
  79. package/dist/qti-package-assets.js +16 -10
  80. package/dist/qti-package-assets.js.map +1 -1
  81. package/dist/qti-package-manifest.d.ts +1 -0
  82. package/dist/qti-package-manifest.d.ts.map +1 -1
  83. package/dist/qti-package-manifest.js +1 -0
  84. package/dist/qti-package-manifest.js.map +1 -1
  85. package/dist/qti-package-metadata.d.ts.map +1 -1
  86. package/dist/qti-package-metadata.js +11 -0
  87. package/dist/qti-package-metadata.js.map +1 -1
  88. package/dist/qti-package-paths.d.ts +2 -0
  89. package/dist/qti-package-paths.d.ts.map +1 -1
  90. package/dist/qti-package-paths.js +6 -0
  91. package/dist/qti-package-paths.js.map +1 -1
  92. package/dist/qti-package-plan.d.ts +1 -1
  93. package/dist/qti-package-plan.d.ts.map +1 -1
  94. package/dist/qti-package-types.d.ts +9 -0
  95. package/dist/qti-package-types.d.ts.map +1 -1
  96. package/dist/qti-package-xml.d.ts +6 -1
  97. package/dist/qti-package-xml.d.ts.map +1 -1
  98. package/dist/qti-package-xml.js +11 -6
  99. package/dist/qti-package-xml.js.map +1 -1
  100. package/dist/qti-package-zip.d.ts +13 -1
  101. package/dist/qti-package-zip.d.ts.map +1 -1
  102. package/dist/qti-package-zip.js +185 -54
  103. package/dist/qti-package-zip.js.map +1 -1
  104. package/dist/qti-package.d.ts +12 -5
  105. package/dist/qti-package.d.ts.map +1 -1
  106. package/dist/qti-package.js +63 -8
  107. package/dist/qti-package.js.map +1 -1
  108. package/dist/response-validation-policy.d.ts.map +1 -1
  109. package/dist/response-validation-policy.js +3 -1
  110. package/dist/response-validation-policy.js.map +1 -1
  111. package/dist/response-validation.d.ts.map +1 -1
  112. package/dist/response-validation.js +2 -1
  113. package/dist/response-validation.js.map +1 -1
  114. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  115. package/dist/serializer-processing-expressions.js +2 -10
  116. package/dist/serializer-processing-expressions.js.map +1 -1
  117. package/dist/server-scoring.d.ts.map +1 -1
  118. package/dist/server-scoring.js +1 -0
  119. package/dist/server-scoring.js.map +1 -1
  120. package/dist/session-builtins.d.ts +16 -0
  121. package/dist/session-builtins.d.ts.map +1 -0
  122. package/dist/session-builtins.js +58 -0
  123. package/dist/session-builtins.js.map +1 -0
  124. package/dist/session.d.ts +5 -1
  125. package/dist/session.d.ts.map +1 -1
  126. package/dist/session.js +63 -11
  127. package/dist/session.js.map +1 -1
  128. package/dist/support-evidence.d.ts.map +1 -1
  129. package/dist/support-evidence.js +2 -0
  130. package/dist/support-evidence.js.map +1 -1
  131. package/dist/support.d.ts.map +1 -1
  132. package/dist/support.js +66 -23
  133. package/dist/support.js.map +1 -1
  134. package/dist/test-expression-parser.d.ts +6 -0
  135. package/dist/test-expression-parser.d.ts.map +1 -0
  136. package/dist/test-expression-parser.js +76 -0
  137. package/dist/test-expression-parser.js.map +1 -0
  138. package/dist/test-expression-serializer.d.ts +5 -0
  139. package/dist/test-expression-serializer.d.ts.map +1 -0
  140. package/dist/test-expression-serializer.js +54 -0
  141. package/dist/test-expression-serializer.js.map +1 -0
  142. package/dist/test-expression-validation.d.ts +14 -0
  143. package/dist/test-expression-validation.d.ts.map +1 -0
  144. package/dist/test-expression-validation.js +101 -0
  145. package/dist/test-expression-validation.js.map +1 -0
  146. package/dist/test-expression.d.ts +99 -0
  147. package/dist/test-expression.d.ts.map +1 -0
  148. package/dist/test-expression.js +28 -0
  149. package/dist/test-expression.js.map +1 -0
  150. package/dist/test-model.d.ts +62 -0
  151. package/dist/test-model.d.ts.map +1 -0
  152. package/dist/test-model.js +5 -0
  153. package/dist/test-model.js.map +1 -0
  154. package/dist/test-parser.d.ts +13 -0
  155. package/dist/test-parser.d.ts.map +1 -0
  156. package/dist/test-parser.js +144 -0
  157. package/dist/test-parser.js.map +1 -0
  158. package/dist/test-processing.d.ts +22 -0
  159. package/dist/test-processing.d.ts.map +1 -0
  160. package/dist/test-processing.js +84 -0
  161. package/dist/test-processing.js.map +1 -0
  162. package/dist/test-session.d.ts +31 -0
  163. package/dist/test-session.d.ts.map +1 -0
  164. package/dist/test-session.js +102 -0
  165. package/dist/test-session.js.map +1 -0
  166. package/dist/test-support.d.ts +4 -0
  167. package/dist/test-support.d.ts.map +1 -0
  168. package/dist/test-support.js +30 -0
  169. package/dist/test-support.js.map +1 -0
  170. package/dist/test-validation.d.ts +4 -0
  171. package/dist/test-validation.d.ts.map +1 -0
  172. package/dist/test-validation.js +133 -0
  173. package/dist/test-validation.js.map +1 -0
  174. package/dist/test-xml.d.ts +7 -0
  175. package/dist/test-xml.d.ts.map +1 -0
  176. package/dist/test-xml.js +23 -0
  177. package/dist/test-xml.js.map +1 -0
  178. package/dist/trusted-item-session.d.ts +4 -1
  179. package/dist/trusted-item-session.d.ts.map +1 -1
  180. package/dist/trusted-item-session.js +8 -3
  181. package/dist/trusted-item-session.js.map +1 -1
  182. package/dist/types.d.ts +26 -4
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/validation-declaration-entries.d.ts.map +1 -1
  185. package/dist/validation-declaration-entries.js +11 -1
  186. package/dist/validation-declaration-entries.js.map +1 -1
  187. package/dist/validation-item-children.d.ts +4 -0
  188. package/dist/validation-item-children.d.ts.map +1 -0
  189. package/dist/validation-item-children.js +45 -0
  190. package/dist/validation-item-children.js.map +1 -0
  191. package/dist/validation-processing.d.ts.map +1 -1
  192. package/dist/validation-processing.js +45 -12
  193. package/dist/validation-processing.js.map +1 -1
  194. package/dist/validation.d.ts.map +1 -1
  195. package/dist/validation.js +12 -0
  196. package/dist/validation.js.map +1 -1
  197. package/dist/value-guards.d.ts +4 -0
  198. package/dist/value-guards.d.ts.map +1 -0
  199. package/dist/value-guards.js +5 -0
  200. package/dist/value-guards.js.map +1 -0
  201. package/dist/xml-schema-regex-data.d.ts +48 -0
  202. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  203. package/dist/xml-schema-regex-data.js +403 -0
  204. package/dist/xml-schema-regex-data.js.map +1 -0
  205. package/dist/xml-schema-regex-parser.d.ts +31 -0
  206. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  207. package/dist/xml-schema-regex-parser.js +245 -0
  208. package/dist/xml-schema-regex-parser.js.map +1 -0
  209. package/dist/xml-schema-regex.d.ts +14 -0
  210. package/dist/xml-schema-regex.d.ts.map +1 -0
  211. package/dist/xml-schema-regex.js +114 -0
  212. package/dist/xml-schema-regex.js.map +1 -0
  213. package/package.json +1 -1
  214. package/src/adaptive-turn-materializer.ts +4 -3
  215. package/src/adaptive-turn.ts +1 -0
  216. package/src/attempt-state.ts +54 -1
  217. package/src/binary-choice.ts +96 -0
  218. package/src/diagnostics.ts +29 -0
  219. package/src/index.ts +38 -0
  220. package/src/item-submission-materialization.ts +2 -0
  221. package/src/parser-processing.ts +15 -18
  222. package/src/parser-values.ts +1 -5
  223. package/src/parser.ts +34 -0
  224. package/src/processing-equality.ts +104 -0
  225. package/src/processing-evaluator-comparison.ts +19 -14
  226. package/src/processing-evaluator-numeric.ts +2 -6
  227. package/src/processing-evaluator-string.ts +25 -18
  228. package/src/processing-evaluator-variable.ts +8 -3
  229. package/src/processing-evaluator.ts +20 -7
  230. package/src/processing-expression-children.ts +2 -3
  231. package/src/processing-expression-tags.ts +2 -1
  232. package/src/processing-mapping.ts +31 -25
  233. package/src/processing-state.ts +6 -1
  234. package/src/processing-values.ts +2 -11
  235. package/src/processing-variables.ts +9 -7
  236. package/src/qti-identifier.ts +4 -0
  237. package/src/qti-package-assets.ts +20 -13
  238. package/src/qti-package-manifest.ts +1 -0
  239. package/src/qti-package-metadata.ts +11 -0
  240. package/src/qti-package-paths.ts +9 -0
  241. package/src/qti-package-plan.ts +1 -1
  242. package/src/qti-package-types.ts +10 -0
  243. package/src/qti-package-xml.ts +11 -6
  244. package/src/qti-package-zip.ts +330 -97
  245. package/src/qti-package.ts +106 -7
  246. package/src/response-validation-policy.ts +5 -1
  247. package/src/response-validation.ts +2 -1
  248. package/src/serializer-processing-expressions.ts +8 -25
  249. package/src/server-scoring.ts +1 -0
  250. package/src/session-builtins.ts +75 -0
  251. package/src/session.ts +64 -8
  252. package/src/support-evidence.ts +2 -0
  253. package/src/support.ts +86 -23
  254. package/src/test-expression-parser.ts +89 -0
  255. package/src/test-expression-serializer.ts +71 -0
  256. package/src/test-expression-validation.ts +139 -0
  257. package/src/test-expression.ts +66 -0
  258. package/src/test-model.ts +68 -0
  259. package/src/test-parser.ts +201 -0
  260. package/src/test-processing.ts +107 -0
  261. package/src/test-session.ts +160 -0
  262. package/src/test-support.ts +31 -0
  263. package/src/test-validation.ts +190 -0
  264. package/src/test-xml.ts +37 -0
  265. package/src/trusted-item-session.ts +13 -2
  266. package/src/types.ts +36 -4
  267. package/src/validation-declaration-entries.ts +11 -1
  268. package/src/validation-item-children.ts +48 -0
  269. package/src/validation-processing.ts +47 -10
  270. package/src/validation.ts +14 -0
  271. package/src/value-guards.ts +6 -0
  272. package/src/xml-schema-regex-data.ts +404 -0
  273. package/src/xml-schema-regex-parser.ts +234 -0
  274. package/src/xml-schema-regex.ts +115 -0
@@ -1,7 +1,8 @@
1
1
  import type { QtiDocument, QtiResponseDeclaration, QtiValue } from "./types.js";
2
- import { parseQtiPair } from "./parser-values.js";
2
+ import { parseQtiPair, parseXmlBoolean } from "./parser-values.js";
3
3
  import { qtiScalarToString, qtiValueToStringList } from "./value-format.js";
4
- import { isRecordValue, numericValue, valuesEqual } from "./processing-values.js";
4
+ import { numericValue, valuesEqual } from "./processing-values.js";
5
+ import { isRecordValue } from "./value-guards.js";
5
6
 
6
7
  export function lookupOutcomeValue(
7
8
  document: QtiDocument,
@@ -21,13 +22,11 @@ export function lookupOutcomeValue(
21
22
  lookupTable.defaultValue
22
23
  );
23
24
  }
24
- const entry = [...lookupTable.entries]
25
- .toSorted((left, right) => left.sourceValue - right.sourceValue)
26
- .find(
27
- (candidate) =>
28
- numeric < candidate.sourceValue ||
29
- (candidate.includeBoundary !== false && numeric === candidate.sourceValue),
30
- );
25
+ const entry = lookupTable.entries.find(
26
+ (candidate) =>
27
+ numeric > candidate.sourceValue ||
28
+ (candidate.includeBoundary !== false && numeric === candidate.sourceValue),
29
+ );
31
30
  return entry?.targetValue ?? lookupTable.defaultValue;
32
31
  }
33
32
 
@@ -69,9 +68,12 @@ export function scoreAreaMapping(
69
68
  for (const [index, entry] of areaMapping.entries.entries()) {
70
69
  if (!pointInsideArea(parsed, entry)) continue;
71
70
  matchedArea = true;
72
- if (matchedAreaIndexes.has(index)) continue;
73
- matchedAreaIndexes.add(index);
74
- score += entry.mappedValue;
71
+ if (!matchedAreaIndexes.has(index)) {
72
+ matchedAreaIndexes.add(index);
73
+ score += entry.mappedValue;
74
+ }
75
+ // Authored priority applies even when this area was already counted.
76
+ break;
75
77
  }
76
78
  if (!matchedArea) score += areaMapping.defaultValue;
77
79
  }
@@ -135,23 +137,27 @@ function scoreMapping(
135
137
  mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
136
138
  baseType: QtiResponseDeclaration["baseType"],
137
139
  ): number {
138
- const values = new Map(
139
- mapping.entries
140
- .filter((entry) => entry.mapKey !== undefined)
141
- .map((entry) => [mappingKey(entry.mapKey!, baseType), entry.mappedValue] as const),
142
- );
140
+ const mappedValue = (value: string): number => {
141
+ const key = mappingKey(value, baseType);
142
+ const entry = mapping.entries.find((candidate) => {
143
+ if (candidate.mapKey === undefined) return false;
144
+ const candidateKey = mappingKey(candidate.mapKey, baseType);
145
+ const caseSensitive = parseXmlBoolean(candidate.attributes["case-sensitive"]) ?? false;
146
+ return baseType === "string" && !caseSensitive
147
+ ? candidateKey.toLowerCase() === key.toLowerCase()
148
+ : candidateKey === key;
149
+ });
150
+ return entry?.mappedValue ?? mapping.defaultValue;
151
+ };
143
152
  if (Array.isArray(response)) {
144
- const score = response.reduce<number>(
145
- (sum, value) =>
146
- sum + (values.get(mappingKey(String(value), baseType)) ?? mapping.defaultValue),
147
- 0,
153
+ const distinct = response.filter(
154
+ (value, index) =>
155
+ response.findIndex((candidate) => valuesEqual(candidate, value, false, baseType)) === index,
148
156
  );
157
+ const score = distinct.reduce<number>((sum, value) => sum + mappedValue(String(value)), 0);
149
158
  return clampMappedScore(score, mapping.attributes);
150
159
  }
151
- const score =
152
- response === null || isRecordValue(response)
153
- ? 0
154
- : (values.get(mappingKey(String(response), baseType)) ?? mapping.defaultValue);
160
+ const score = response === null || isRecordValue(response) ? 0 : mappedValue(String(response));
155
161
  return clampMappedScore(score, mapping.attributes);
156
162
  }
157
163
 
@@ -1,12 +1,13 @@
1
1
  import type {
2
2
  QtiAttemptStateV1,
3
+ QtiBuiltInVariables,
3
4
  QtiAttemptStatus,
4
5
  QtiDiagnostic,
5
6
  QtiPortableCustomStateValue,
6
7
  QtiValue,
7
8
  } from "./types.js";
8
9
  import { ATTEMPT_STATE_SCHEMA } from "./attempt-state-constants.js";
9
- import { isRecordValue } from "./processing-values.js";
10
+ import { isRecordValue } from "./value-guards.js";
10
11
 
11
12
  export function serialize(
12
13
  itemIdentifier: string,
@@ -16,9 +17,13 @@ export function serialize(
16
17
  templateValues: Record<string, QtiValue>,
17
18
  interactionStates: Record<string, QtiPortableCustomStateValue>,
18
19
  validationMessages: QtiDiagnostic[],
20
+ builtInVariables?: QtiBuiltInVariables,
19
21
  ): QtiAttemptStateV1 {
20
22
  return {
21
23
  schema: ATTEMPT_STATE_SCHEMA,
24
+ ...(builtInVariables === undefined
25
+ ? {}
26
+ : { builtInVariables: { ...builtInVariables, context: { ...builtInVariables.context } } }),
22
27
  itemIdentifier,
23
28
  status,
24
29
  responses: cloneValueRecord(responses),
@@ -1,12 +1,7 @@
1
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
+ import type { QtiBaseType, QtiResponseDeclaration, QtiScalarValue, QtiValue } from "./types.js";
9
3
  import { qtiValueToString } from "./value-format.js";
4
+ import { isRecordValue } from "./value-guards.js";
10
5
 
11
6
  export function isNullResponse(response: QtiValue): boolean {
12
7
  return response === null || response === "" || (Array.isArray(response) && response.length === 0);
@@ -102,10 +97,6 @@ export function valueContainer(value: QtiValue): QtiScalarValue[] {
102
97
  return Array.isArray(value) ? value : [value];
103
98
  }
104
99
 
105
- export function isRecordValue(value: QtiValue): value is QtiRecordValue {
106
- return typeof value === "object" && value !== null && !Array.isArray(value);
107
- }
108
-
109
100
  export function containsValues(
110
101
  collection: QtiScalarValue[],
111
102
  values: QtiScalarValue[],
@@ -7,6 +7,7 @@ import type {
7
7
  QtiTemplateDeclaration,
8
8
  QtiValue,
9
9
  } from "./types.js";
10
+ import { builtInVariableBaseType } from "./session-builtins.js";
10
11
  import { parseBaseType } from "./parser-values.js";
11
12
 
12
13
  export function getResponseDeclaration(
@@ -37,6 +38,7 @@ export function resolveOptionalVariableValue(
37
38
  outcomes: Record<string, QtiValue>,
38
39
  templateValues: Record<string, QtiValue>,
39
40
  ): QtiValue | undefined {
41
+ if (identifier === "completionStatus") return outcomes.completionStatus ?? "not_attempted";
40
42
  const declaration = resolveVariableDeclaration(document, identifier);
41
43
  if (!declaration) return undefined;
42
44
  if (declaration.kind === "response") return responses[identifier] ?? null;
@@ -52,7 +54,7 @@ export function expressionIsOrdered(
52
54
  expression: QtiProcessingExpression,
53
55
  document: QtiDocument,
54
56
  ): boolean {
55
- if (expression.type === "ordered") return true;
57
+ if (expression.type === "ordered" || expression.type === "repeat") return true;
56
58
  if (expression.type === "delete") return expressionIsOrdered(expression.collection, document);
57
59
  if (
58
60
  (expression.type === "variable" ||
@@ -77,20 +79,20 @@ export function expressionBaseType(
77
79
  expression.type === "correct" ||
78
80
  expression.type === "default"
79
81
  ) {
80
- return resolveVariableDeclaration(document, expression.identifier)?.baseType;
82
+ return (
83
+ builtInVariableBaseType(expression.identifier) ??
84
+ resolveVariableDeclaration(document, expression.identifier)?.baseType
85
+ );
81
86
  }
82
87
  if (expression.type === "index") return expressionBaseType(expression.expression, document);
83
88
  if (expression.type === "delete") return expressionBaseType(expression.collection, document);
89
+ if (expression.type === "random") return expressionBaseType(expression.expression, document);
84
90
  if (
85
- expression.type === "random" ||
86
91
  expression.type === "multiple" ||
87
92
  expression.type === "ordered" ||
88
93
  expression.type === "repeat"
89
94
  ) {
90
- return commonExpressionBaseType(
91
- expression.type === "random" ? expression.values : expression.expressions,
92
- document,
93
- );
95
+ return commonExpressionBaseType(expression.expressions, document);
94
96
  }
95
97
  return undefined;
96
98
  }
@@ -0,0 +1,4 @@
1
+ /** Whether a value is a complete QTI identifier in the supported ASCII lexical space. */
2
+ export function isQtiIdentifier(identifier: string): boolean {
3
+ return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(identifier);
4
+ }
@@ -1,4 +1,5 @@
1
- import { pushPackageDiagnostic } from "./qti-package-paths.js";
1
+ import { isResolvableAssetUrl } from "./asset-url.js";
2
+ import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
2
3
  import { parseQtiPackageXmlTree } from "./package-xml.js";
3
4
  import type {
4
5
  QtiAssessmentTestPackageModel,
@@ -8,12 +9,7 @@ import type {
8
9
  QtiPackageContentAssetDiscovery,
9
10
  QtiPackageItem,
10
11
  } from "./qti-package-types.js";
11
- import {
12
- isPackageRelativeHref,
13
- packageDescendants,
14
- resolvePackageHref,
15
- type PackageXmlFile,
16
- } from "./qti-package-xml.js";
12
+ import { packageDescendants, resolvePackageHref, type PackageXmlFile } from "./qti-package-xml.js";
17
13
  import type { QtiDiagnostic } from "./types.js";
18
14
 
19
15
  interface PendingAssetReference {
@@ -196,9 +192,7 @@ function collectPackageRelativeAttributeRefs(
196
192
  diagnostics: QtiDiagnostic[],
197
193
  ): void {
198
194
  for (const node of packageDescendants(xmlFile.root, localName)) {
199
- const href = node.attributes[attribute];
200
- if (!isPackageRelativeHref(href)) continue;
201
- const resolved = resolvePackageHref(xmlFile.path, href.trim(), diagnostics);
195
+ const resolved = resolveContentAssetHref(xmlFile.path, node.attributes[attribute], diagnostics);
202
196
  if (resolved) refs.push(resolved);
203
197
  }
204
198
  }
@@ -210,13 +204,26 @@ function collectPackageRelativeTextRefs(
210
204
  diagnostics: QtiDiagnostic[],
211
205
  ): void {
212
206
  for (const node of packageDescendants(xmlFile.root, localName)) {
213
- const href = node.text.trim();
214
- if (!isPackageRelativeHref(href)) continue;
215
- const resolved = resolvePackageHref(xmlFile.path, href, diagnostics);
207
+ const resolved = resolveContentAssetHref(xmlFile.path, node.text, diagnostics);
216
208
  if (resolved) refs.push(resolved);
217
209
  }
218
210
  }
219
211
 
212
+ function resolveContentAssetHref(
213
+ sourcePath: string,
214
+ value: string | undefined,
215
+ diagnostics: QtiDiagnostic[],
216
+ ): string | undefined {
217
+ const href = value?.trim() ?? "";
218
+ // A server-root path cannot name content inside a portable QTI package.
219
+ // Network-path URLs are external, like URLs with an explicit scheme.
220
+ if (href.startsWith("/") && !href.startsWith("//")) {
221
+ return normalizePackagePath(href, "package asset reference", diagnostics);
222
+ }
223
+ if (!isResolvableAssetUrl(href)) return undefined;
224
+ return resolvePackageHref(sourcePath, href, diagnostics);
225
+ }
226
+
220
227
  function materializeAssets(
221
228
  pending: readonly PendingAssetReference[],
222
229
  entriesByPath: { has(path: string): boolean },
@@ -10,6 +10,7 @@ export const QTI_PACKAGE_MANIFEST_PATH = "imsmanifest.xml";
10
10
 
11
11
  export const QTI_ITEM_RESOURCE_TYPE = "imsqti_item_xmlv3p0";
12
12
 
13
+ /** Recognize QTI 3 item resource types, including their version suffixes. */
13
14
  export function isQtiItemResource(type: string): boolean {
14
15
  return type.toLowerCase().startsWith(QTI_ITEM_RESOURCE_TYPE);
15
16
  }
@@ -315,9 +315,20 @@ export function packageTitle(
315
315
  : undefined;
316
316
  return (
317
317
  assessmentTest?.title ??
318
+ manifestLomTitle(manifestRoot) ??
318
319
  manifestTitle ??
319
320
  items.find((item) => item.title !== undefined)?.title ??
320
321
  manifestRoot?.attributes.identifier ??
321
322
  ""
322
323
  );
323
324
  }
325
+
326
+ function manifestLomTitle(root: QtiPackageXmlNode | undefined): string | undefined {
327
+ if (!root) return undefined;
328
+ const lomNamespace = "http://ltsc.ieee.org/xsd/LOM";
329
+ let nodes = childPackageElements(root, "metadata", QTI_PACKAGE_MANIFEST_NAMESPACE);
330
+ for (const name of ["lom", "general", "title", "string"]) {
331
+ nodes = nodes.flatMap((node) => childPackageElements(node, name, lomNamespace));
332
+ }
333
+ return nodes.map((node) => node.text.trim()).find((text) => text.length > 0);
334
+ }
@@ -54,3 +54,12 @@ export function normalizePackagePath(
54
54
  }
55
55
  return parts.join("/");
56
56
  }
57
+
58
+ /** Whether an item href is a normalized package-local path, without URI routing or traversal. */
59
+ export function isQtiPackageItemHref(href: string): boolean {
60
+ return (
61
+ href.length > 0 &&
62
+ !/^[a-z][a-z0-9+.-]*:|[\\?#\s]/i.test(href) &&
63
+ href.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")
64
+ );
65
+ }
@@ -31,7 +31,7 @@ import { QTI_PACKAGE_MANIFEST_NAMESPACE } from "./qti-namespaces.js";
31
31
  import type { QtiDiagnostic } from "./types.js";
32
32
 
33
33
  /** Package metadata without extracted bodies or retained item models. */
34
- export type QtiPackageSummary = Omit<QtiPackageParseResult, "items" | "entries"> & {
34
+ export type QtiPackageSummary = Omit<QtiPackageParseResult, "items" | "entries" | "xmlFiles"> & {
35
35
  readonly itemCount: number;
36
36
  };
37
37
 
@@ -170,11 +170,21 @@ export interface QtiPackageContentAssetDiscovery {
170
170
  readonly diagnostics: readonly QtiDiagnostic[];
171
171
  }
172
172
 
173
+ /** Root classification and XML syntax diagnostics for one package XML file, without its tree. */
174
+ export interface QtiPackageXmlFileSummary {
175
+ readonly path: string;
176
+ readonly rootLocalName?: string | undefined;
177
+ readonly rootNamespaceUri?: string | undefined;
178
+ readonly diagnostics: readonly QtiDiagnostic[];
179
+ }
180
+
173
181
  /** Parsed QTI package manifest/resource graph and item/test projection. */
174
182
  export interface QtiPackageParseResult {
175
183
  readonly ok: boolean;
176
184
  readonly title: string;
177
185
  readonly entries: readonly QtiPackageEntry[];
186
+ /** All .xml entries in inventory order, including files outside the selected resource graph. */
187
+ readonly xmlFiles: readonly QtiPackageXmlFileSummary[];
178
188
  readonly packageShape: QtiPackageShape;
179
189
  readonly items: readonly QtiPackageItem[];
180
190
  readonly assets: readonly QtiPackageAsset[];
@@ -1,3 +1,4 @@
1
+ import { isResolvableAssetUrl } from "./asset-url.js";
1
2
  import { parseQtiPackageXmlTree, type QtiPackageXmlNode } from "./package-xml.js";
2
3
  import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
3
4
  import { decodeUtf8, type QtiPackageEntry } from "./qti-package-zip.js";
@@ -78,6 +79,10 @@ export function resolvePackageHref(
78
79
  diagnostics: QtiDiagnostic[],
79
80
  ): string | undefined {
80
81
  const strippedHref = stripHrefSuffix(href);
82
+ // Diagnose absolute package references before adding the containing directory.
83
+ if (!isResolvableAssetUrl(strippedHref)) {
84
+ return normalizePackagePath(strippedHref, "package reference", diagnostics);
85
+ }
81
86
  const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
82
87
  return normalizePackagePath(`${base}${strippedHref}`, "package reference", diagnostics);
83
88
  }
@@ -86,16 +91,16 @@ export function stripHrefSuffix(href: string): string {
86
91
  return href.split(/[?#]/, 1)[0] ?? "";
87
92
  }
88
93
 
89
- export function isPackageRelativeHref(href: string | undefined): href is string {
90
- const trimmed = href?.trim() ?? "";
91
- if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) return false;
92
- return !/^[a-z][a-z0-9+.-]*:/i.test(trimmed);
93
- }
94
-
95
94
  export function joinPackagePath(packagePath: string, localPath: string): string {
96
95
  return localPath.startsWith("/") ? `${packagePath}${localPath}` : `${packagePath}/${localPath}`;
97
96
  }
98
97
 
98
+ /**
99
+ * Return a diagnostic with its local location prefixed by an item or archive path.
100
+ * Prefer path over source.path; update both to the same location without mutating the input.
101
+ * Empty locations and locations equal to packagePath resolve to packagePath itself.
102
+ * A leading slash in the local location is a separator, not an absolute filesystem path.
103
+ */
99
104
  export function scopeDiagnosticToPackagePath(
100
105
  packagePath: string,
101
106
  diagnostic: QtiDiagnostic,