@longsightgroup/qti3-core 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/README.md +59 -3
  2. package/dist/adaptive-turn-materializer.d.ts +14 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -0
  4. package/dist/adaptive-turn-materializer.js +56 -0
  5. package/dist/adaptive-turn-materializer.js.map +1 -0
  6. package/dist/adaptive-turn.d.ts +23 -0
  7. package/dist/adaptive-turn.d.ts.map +1 -0
  8. package/dist/adaptive-turn.js +60 -0
  9. package/dist/adaptive-turn.js.map +1 -0
  10. package/dist/assert-never.d.ts +3 -0
  11. package/dist/assert-never.d.ts.map +1 -0
  12. package/dist/assert-never.js +5 -0
  13. package/dist/assert-never.js.map +1 -0
  14. package/dist/attempt-state-constants.d.ts +6 -0
  15. package/dist/attempt-state-constants.d.ts.map +1 -0
  16. package/dist/attempt-state-constants.js +6 -0
  17. package/dist/attempt-state-constants.js.map +1 -0
  18. package/dist/attempt-state.d.ts +5 -0
  19. package/dist/attempt-state.d.ts.map +1 -0
  20. package/dist/attempt-state.js +190 -0
  21. package/dist/attempt-state.js.map +1 -0
  22. package/dist/catalog.js +1 -1
  23. package/dist/catalog.js.map +1 -1
  24. package/dist/content-text.d.ts.map +1 -1
  25. package/dist/content-text.js +3 -0
  26. package/dist/content-text.js.map +1 -1
  27. package/dist/custom-operators.d.ts +13 -0
  28. package/dist/custom-operators.d.ts.map +1 -0
  29. package/dist/custom-operators.js +2 -0
  30. package/dist/custom-operators.js.map +1 -0
  31. package/dist/delivery-redaction.d.ts +50 -0
  32. package/dist/delivery-redaction.d.ts.map +1 -0
  33. package/dist/delivery-redaction.js +176 -0
  34. package/dist/delivery-redaction.js.map +1 -0
  35. package/dist/delivery-security.d.ts +3 -18
  36. package/dist/delivery-security.d.ts.map +1 -1
  37. package/dist/delivery-security.js +35 -189
  38. package/dist/delivery-security.js.map +1 -1
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/parser-custom-interactions.js +1 -1
  44. package/dist/parser-custom-interactions.js.map +1 -1
  45. package/dist/parser-declarations.d.ts.map +1 -1
  46. package/dist/parser-declarations.js +4 -4
  47. package/dist/parser-declarations.js.map +1 -1
  48. package/dist/parser-item-metadata.d.ts.map +1 -1
  49. package/dist/parser-item-metadata.js +8 -7
  50. package/dist/parser-item-metadata.js.map +1 -1
  51. package/dist/parser-processing.js +2 -2
  52. package/dist/parser-processing.js.map +1 -1
  53. package/dist/parser-values.d.ts +5 -1
  54. package/dist/parser-values.d.ts.map +1 -1
  55. package/dist/parser-values.js +33 -0
  56. package/dist/parser-values.js.map +1 -1
  57. package/dist/processing-evaluator-boolean.d.ts +8 -0
  58. package/dist/processing-evaluator-boolean.d.ts.map +1 -0
  59. package/dist/processing-evaluator-boolean.js +47 -0
  60. package/dist/processing-evaluator-boolean.js.map +1 -0
  61. package/dist/processing-evaluator-collection.d.ts +12 -0
  62. package/dist/processing-evaluator-collection.d.ts.map +1 -0
  63. package/dist/processing-evaluator-collection.js +35 -0
  64. package/dist/processing-evaluator-collection.js.map +1 -0
  65. package/dist/processing-evaluator-comparison.d.ts +8 -0
  66. package/dist/processing-evaluator-comparison.d.ts.map +1 -0
  67. package/dist/processing-evaluator-comparison.js +88 -0
  68. package/dist/processing-evaluator-comparison.js.map +1 -0
  69. package/dist/processing-evaluator-custom.d.ts +8 -0
  70. package/dist/processing-evaluator-custom.d.ts.map +1 -0
  71. package/dist/processing-evaluator-custom.js +14 -0
  72. package/dist/processing-evaluator-custom.js.map +1 -0
  73. package/dist/processing-evaluator-geometry.d.ts +8 -0
  74. package/dist/processing-evaluator-geometry.d.ts.map +1 -0
  75. package/dist/processing-evaluator-geometry.js +21 -0
  76. package/dist/processing-evaluator-geometry.js.map +1 -0
  77. package/dist/processing-evaluator-numeric.d.ts +8 -0
  78. package/dist/processing-evaluator-numeric.d.ts.map +1 -0
  79. package/dist/processing-evaluator-numeric.js +112 -0
  80. package/dist/processing-evaluator-numeric.js.map +1 -0
  81. package/dist/processing-evaluator-string.d.ts +8 -0
  82. package/dist/processing-evaluator-string.d.ts.map +1 -0
  83. package/dist/processing-evaluator-string.js +33 -0
  84. package/dist/processing-evaluator-string.js.map +1 -0
  85. package/dist/processing-evaluator-variable.d.ts +8 -0
  86. package/dist/processing-evaluator-variable.d.ts.map +1 -0
  87. package/dist/processing-evaluator-variable.js +39 -0
  88. package/dist/processing-evaluator-variable.js.map +1 -0
  89. package/dist/processing-evaluator.d.ts +22 -0
  90. package/dist/processing-evaluator.d.ts.map +1 -0
  91. package/dist/processing-evaluator.js +137 -0
  92. package/dist/processing-evaluator.js.map +1 -0
  93. package/dist/processing-expression-children.d.ts +3 -0
  94. package/dist/processing-expression-children.d.ts.map +1 -0
  95. package/dist/processing-expression-children.js +68 -0
  96. package/dist/processing-expression-children.js.map +1 -0
  97. package/dist/processing-mapping.d.ts +13 -0
  98. package/dist/processing-mapping.d.ts.map +1 -0
  99. package/dist/processing-mapping.js +120 -0
  100. package/dist/processing-mapping.js.map +1 -0
  101. package/dist/processing-operators.d.ts +11 -0
  102. package/dist/processing-operators.d.ts.map +1 -0
  103. package/dist/processing-operators.js +178 -0
  104. package/dist/processing-operators.js.map +1 -0
  105. package/dist/processing-random.d.ts +2 -0
  106. package/dist/processing-random.d.ts.map +1 -0
  107. package/dist/processing-random.js +16 -0
  108. package/dist/processing-random.js.map +1 -0
  109. package/dist/processing-state.d.ts +8 -0
  110. package/dist/processing-state.d.ts.map +1 -0
  111. package/dist/processing-state.js +44 -0
  112. package/dist/processing-state.js.map +1 -0
  113. package/dist/processing-templates.d.ts +3 -0
  114. package/dist/processing-templates.d.ts.map +1 -0
  115. package/dist/processing-templates.js +12 -0
  116. package/dist/processing-templates.js.map +1 -0
  117. package/dist/processing-values.d.ts +15 -0
  118. package/dist/processing-values.d.ts.map +1 -0
  119. package/dist/processing-values.js +150 -0
  120. package/dist/processing-values.js.map +1 -0
  121. package/dist/processing-variables.d.ts +7 -0
  122. package/dist/processing-variables.d.ts.map +1 -0
  123. package/dist/processing-variables.js +40 -0
  124. package/dist/processing-variables.js.map +1 -0
  125. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  126. package/dist/serializer-processing-expressions.js +2 -8
  127. package/dist/serializer-processing-expressions.js.map +1 -1
  128. package/dist/serializer-processing-xml.js +2 -2
  129. package/dist/serializer-processing-xml.js.map +1 -1
  130. package/dist/serializer-processing.d.ts.map +1 -1
  131. package/dist/serializer-processing.js +3 -0
  132. package/dist/serializer-processing.js.map +1 -1
  133. package/dist/server-scoring.d.ts +4 -14
  134. package/dist/server-scoring.d.ts.map +1 -1
  135. package/dist/server-scoring.js +19 -157
  136. package/dist/server-scoring.js.map +1 -1
  137. package/dist/session.d.ts +5 -14
  138. package/dist/session.d.ts.map +1 -1
  139. package/dist/session.js +76 -1158
  140. package/dist/session.js.map +1 -1
  141. package/dist/shared-vocabulary-coverage-policy.js +1 -1
  142. package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
  143. package/dist/shared-vocabulary-generated-families.js +1 -1
  144. package/dist/shared-vocabulary-generated-families.js.map +1 -1
  145. package/dist/shared-vocabulary-support.js +3 -3
  146. package/dist/shared-vocabulary-support.js.map +1 -1
  147. package/dist/shared-vocabulary.d.ts.map +1 -1
  148. package/dist/shared-vocabulary.js +4 -3
  149. package/dist/shared-vocabulary.js.map +1 -1
  150. package/dist/support-evidence.d.ts +22 -0
  151. package/dist/support-evidence.d.ts.map +1 -0
  152. package/dist/support-evidence.js +93 -0
  153. package/dist/support-evidence.js.map +1 -0
  154. package/dist/support.d.ts.map +1 -1
  155. package/dist/support.js +38 -47
  156. package/dist/support.js.map +1 -1
  157. package/dist/trusted-item-session.d.ts +49 -0
  158. package/dist/trusted-item-session.d.ts.map +1 -0
  159. package/dist/trusted-item-session.js +261 -0
  160. package/dist/trusted-item-session.js.map +1 -0
  161. package/dist/tts.d.ts.map +1 -1
  162. package/dist/tts.js +16 -2
  163. package/dist/tts.js.map +1 -1
  164. package/dist/validation-geometry.d.ts +8 -0
  165. package/dist/validation-geometry.d.ts.map +1 -0
  166. package/dist/validation-geometry.js +187 -0
  167. package/dist/validation-geometry.js.map +1 -0
  168. package/dist/validation-interactions.d.ts +3 -0
  169. package/dist/validation-interactions.d.ts.map +1 -0
  170. package/dist/validation-interactions.js +644 -0
  171. package/dist/validation-interactions.js.map +1 -0
  172. package/dist/validation-primitives.d.ts +11 -0
  173. package/dist/validation-primitives.d.ts.map +1 -0
  174. package/dist/validation-primitives.js +48 -0
  175. package/dist/validation-primitives.js.map +1 -0
  176. package/dist/validation-processing.d.ts +4 -0
  177. package/dist/validation-processing.d.ts.map +1 -0
  178. package/dist/validation-processing.js +569 -0
  179. package/dist/validation-processing.js.map +1 -0
  180. package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
  181. package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
  182. package/dist/validation-shared-vocabulary-layout.js +102 -0
  183. package/dist/validation-shared-vocabulary-layout.js.map +1 -0
  184. package/dist/validation.d.ts.map +1 -1
  185. package/dist/validation.js +27 -1641
  186. package/dist/validation.js.map +1 -1
  187. package/dist/xml.js +1 -1
  188. package/dist/xml.js.map +1 -1
  189. package/package.json +4 -2
  190. package/src/adaptive-turn-materializer.ts +85 -0
  191. package/src/adaptive-turn.ts +109 -0
  192. package/src/assert-never.ts +4 -0
  193. package/src/attempt-state-constants.ts +5 -0
  194. package/src/attempt-state.ts +245 -0
  195. package/src/catalog.ts +1 -1
  196. package/src/content-text.ts +3 -0
  197. package/src/custom-operators.ts +12 -0
  198. package/src/delivery-redaction.ts +268 -0
  199. package/src/delivery-security.ts +59 -245
  200. package/src/index.ts +9 -0
  201. package/src/parser-custom-interactions.ts +1 -1
  202. package/src/parser-declarations.ts +4 -3
  203. package/src/parser-item-metadata.ts +8 -7
  204. package/src/parser-processing.ts +2 -2
  205. package/src/parser-values.ts +36 -0
  206. package/src/processing-evaluator-boolean.ts +48 -0
  207. package/src/processing-evaluator-collection.ts +50 -0
  208. package/src/processing-evaluator-comparison.ts +106 -0
  209. package/src/processing-evaluator-custom.ts +20 -0
  210. package/src/processing-evaluator-geometry.ts +26 -0
  211. package/src/processing-evaluator-numeric.ts +155 -0
  212. package/src/processing-evaluator-string.ts +60 -0
  213. package/src/processing-evaluator-variable.ts +81 -0
  214. package/src/processing-evaluator.ts +173 -0
  215. package/src/processing-expression-children.ts +69 -0
  216. package/src/processing-mapping.ts +153 -0
  217. package/src/processing-operators.ts +184 -0
  218. package/src/processing-random.ts +16 -0
  219. package/src/processing-state.ts +69 -0
  220. package/src/processing-templates.ts +12 -0
  221. package/src/processing-values.ts +162 -0
  222. package/src/processing-variables.ts +83 -0
  223. package/src/serializer-processing-expressions.ts +2 -12
  224. package/src/serializer-processing-xml.ts +2 -2
  225. package/src/serializer-processing.ts +3 -0
  226. package/src/server-scoring.ts +30 -226
  227. package/src/session.ts +128 -2006
  228. package/src/shared-vocabulary-coverage-policy.ts +1 -1
  229. package/src/shared-vocabulary-generated-families.ts +1 -1
  230. package/src/shared-vocabulary-support.ts +3 -3
  231. package/src/shared-vocabulary.ts +3 -2
  232. package/src/support-evidence.ts +107 -0
  233. package/src/support.ts +68 -54
  234. package/src/trusted-item-session.ts +429 -0
  235. package/src/tts.ts +29 -15
  236. package/src/validation-geometry.ts +204 -0
  237. package/src/validation-interactions.ts +831 -0
  238. package/src/validation-primitives.ts +63 -0
  239. package/src/validation-processing.ts +785 -0
  240. package/src/validation-shared-vocabulary-layout.ts +117 -0
  241. package/src/validation.ts +44 -2077
  242. package/src/xml.ts +1 -1
  243. package/src/interaction-test-fixtures.ts +0 -44
  244. package/src/serializer-processing.fixtures.ts +0 -469
@@ -0,0 +1,245 @@
1
+ import type {
2
+ QtiAttemptStateV1,
3
+ QtiAttemptStatus,
4
+ QtiDiagnostic,
5
+ QtiDocument,
6
+ QtiPortableCustomStateValue,
7
+ QtiScalarValue,
8
+ QtiValue,
9
+ QtiVariableDeclaration,
10
+ } from "./types.js";
11
+ import {
12
+ ATTEMPT_STATE_SCHEMA,
13
+ COMPLETION_COMPLETED,
14
+ COMPLETION_NOT_ATTEMPTED,
15
+ COMPLETION_STATUS,
16
+ COMPLETION_UNKNOWN,
17
+ } from "./attempt-state-constants.js";
18
+ import { isQtiPortableCustomStateValue, isQtiValue, qtiValueToString } from "./value-format.js";
19
+ import { isRecordValue } from "./processing-values.js";
20
+ import { isPair, isPoint } from "./validation-primitives.js";
21
+
22
+ export function isQtiAttemptStateV1(value: unknown): value is QtiAttemptStateV1 {
23
+ return attemptStateErrors(value).length === 0;
24
+ }
25
+
26
+ export function assertQtiAttemptStateV1(value: unknown): asserts value is QtiAttemptStateV1 {
27
+ const [firstError] = attemptStateErrors(value);
28
+ if (firstError) throw new Error(firstError);
29
+ }
30
+
31
+ export function assertCompatiblePriorState(
32
+ document: QtiDocument,
33
+ priorState: QtiAttemptStateV1 | undefined,
34
+ ): void {
35
+ if (!priorState) return;
36
+ assertQtiAttemptStateV1(priorState);
37
+ if (priorState.itemIdentifier !== document.item.identifier) {
38
+ throw new Error(
39
+ `Cannot restore state for ${priorState.itemIdentifier} into ${document.item.identifier}.`,
40
+ );
41
+ }
42
+ const responseIdentifiers = new Set(
43
+ document.item.responseDeclarations.map((declaration) => declaration.identifier),
44
+ );
45
+ const outcomeIdentifiers = new Set([
46
+ ...document.item.outcomeDeclarations.map((declaration) => declaration.identifier),
47
+ COMPLETION_STATUS,
48
+ ]);
49
+ const templateIdentifiers = new Set(
50
+ document.item.templateDeclarations.map((declaration) => declaration.identifier),
51
+ );
52
+ const interactionStateIdentifiers = new Set(
53
+ document.item.interactions
54
+ .filter((interaction) => interaction.type === "portableCustom")
55
+ .map((interaction) => interaction.responseIdentifier)
56
+ .filter((identifier): identifier is string => Boolean(identifier)),
57
+ );
58
+ assertKnownStateIdentifiers("response", priorState.responses, responseIdentifiers);
59
+ assertKnownStateIdentifiers("outcome", priorState.outcomes, outcomeIdentifiers);
60
+ assertKnownStateIdentifiers("template", priorState.templateValues ?? {}, templateIdentifiers);
61
+ assertKnownPortableCustomStateIdentifiers(
62
+ priorState.interactionStates ?? {},
63
+ interactionStateIdentifiers,
64
+ );
65
+ for (const message of priorState.validationMessages) {
66
+ if (message.path && !responseIdentifiers.has(message.path)) {
67
+ throw new Error(`Cannot restore validation message for unknown response ${message.path}.`);
68
+ }
69
+ }
70
+ for (const declaration of document.item.responseDeclarations) {
71
+ assertRestoredValueMatchesDeclaration("response", declaration, priorState.responses);
72
+ }
73
+ for (const declaration of document.item.outcomeDeclarations) {
74
+ assertRestoredValueMatchesDeclaration("outcome", declaration, priorState.outcomes);
75
+ }
76
+ for (const declaration of document.item.templateDeclarations) {
77
+ assertRestoredValueMatchesDeclaration("template", declaration, priorState.templateValues ?? {});
78
+ }
79
+ const completionStatus = priorState.outcomes[COMPLETION_STATUS];
80
+ if (
81
+ completionStatus !== undefined &&
82
+ completionStatus !== COMPLETION_NOT_ATTEMPTED &&
83
+ completionStatus !== COMPLETION_UNKNOWN &&
84
+ completionStatus !== COMPLETION_COMPLETED &&
85
+ completionStatus !== "incomplete"
86
+ ) {
87
+ throw new Error(
88
+ `Cannot restore unsupported completionStatus ${qtiValueToString(completionStatus)}.`,
89
+ );
90
+ }
91
+ }
92
+
93
+ function assertKnownStateIdentifiers(
94
+ kind: string,
95
+ record: Record<string, QtiValue>,
96
+ allowed: Set<string>,
97
+ ): void {
98
+ const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
99
+ if (unknown) throw new Error(`Cannot restore unknown ${kind} identifier ${unknown}.`);
100
+ }
101
+
102
+ function assertKnownPortableCustomStateIdentifiers(
103
+ record: Record<string, QtiPortableCustomStateValue>,
104
+ allowed: Set<string>,
105
+ ): void {
106
+ const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
107
+ if (unknown) throw new Error(`Cannot restore unknown interaction state identifier ${unknown}.`);
108
+ }
109
+
110
+ function assertRestoredValueMatchesDeclaration(
111
+ kind: string,
112
+ declaration: QtiVariableDeclaration,
113
+ record: Record<string, QtiValue>,
114
+ ): void {
115
+ if (!(declaration.identifier in record)) return;
116
+ const value = record[declaration.identifier] ?? null;
117
+ const error = restoredValueError(declaration, value);
118
+ if (error) throw new Error(`Cannot restore ${kind} ${declaration.identifier}: ${error}.`);
119
+ }
120
+
121
+ function restoredValueError(
122
+ declaration: QtiVariableDeclaration,
123
+ value: QtiValue,
124
+ ): string | undefined {
125
+ if (value === null) return undefined;
126
+ if (declaration.cardinality === "record") {
127
+ return isRecordValue(value) ? undefined : "expected record value";
128
+ }
129
+ if (isRecordValue(value)) return "expected scalar value";
130
+ if (declaration.cardinality === "single" && Array.isArray(value)) {
131
+ return "expected single value";
132
+ }
133
+ if (
134
+ (declaration.cardinality === "multiple" || declaration.cardinality === "ordered") &&
135
+ !Array.isArray(value)
136
+ ) {
137
+ return `expected ${declaration.cardinality} value container`;
138
+ }
139
+ if (!declaration.baseType) return undefined;
140
+ for (const entry of restoredValueEntries(value)) {
141
+ if (!restoredScalarMatchesBaseType(entry, declaration.baseType)) {
142
+ return `value ${String(entry)} is not valid for base-type ${declaration.baseType}`;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ function restoredValueEntries(value: QtiValue): QtiScalarValue[] {
149
+ if (value === null || isRecordValue(value)) return [];
150
+ return Array.isArray(value) ? value : [value];
151
+ }
152
+
153
+ function restoredScalarMatchesBaseType(
154
+ value: QtiScalarValue,
155
+ baseType: QtiVariableDeclaration["baseType"],
156
+ ): boolean {
157
+ if (!baseType) return true;
158
+ if (baseType === "integer") {
159
+ return typeof value === "number" ? Number.isInteger(value) : /^-?\d+$/.test(String(value));
160
+ }
161
+ if (baseType === "float") {
162
+ return typeof value === "number" ? Number.isFinite(value) : Number.isFinite(Number(value));
163
+ }
164
+ if (baseType === "boolean")
165
+ return typeof value === "boolean" || value === "true" || value === "false";
166
+ if (baseType === "point") return isPoint(String(value));
167
+ if (baseType === "pair" || baseType === "directedPair") return isPair(String(value));
168
+ if (baseType === "identifier")
169
+ return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value);
170
+ return typeof value === "string";
171
+ }
172
+
173
+ function isAttemptStatus(value: string): value is QtiAttemptStatus {
174
+ return (
175
+ value === "initialized" ||
176
+ value === "interacting" ||
177
+ value === "suspended" ||
178
+ value === "completed"
179
+ );
180
+ }
181
+
182
+ function attemptStateErrors(value: unknown): string[] {
183
+ if (!isRecord(value)) return ["QTI attempt state must be an object."];
184
+
185
+ const schema = value.schema;
186
+ if (schema !== ATTEMPT_STATE_SCHEMA) {
187
+ return [`Unsupported QTI attempt state schema ${String(schema)}.`];
188
+ }
189
+
190
+ const errors: string[] = [];
191
+ if (typeof value.itemIdentifier !== "string" || value.itemIdentifier.length === 0) {
192
+ errors.push("QTI attempt state itemIdentifier must be a non-empty string.");
193
+ }
194
+ if (typeof value.status !== "string" || !isAttemptStatus(value.status)) {
195
+ errors.push(`QTI attempt state status ${String(value.status)} is not supported.`);
196
+ }
197
+ if (!isQtiValueRecord(value.responses)) {
198
+ errors.push("QTI attempt state responses must be a record of QTI values.");
199
+ }
200
+ if (!isQtiValueRecord(value.outcomes)) {
201
+ errors.push("QTI attempt state outcomes must be a record of QTI values.");
202
+ }
203
+ if (value.templateValues !== undefined && !isQtiValueRecord(value.templateValues)) {
204
+ errors.push("QTI attempt state templateValues must be a record of QTI values.");
205
+ }
206
+ if (
207
+ value.interactionStates !== undefined &&
208
+ !isPortableCustomStateRecord(value.interactionStates)
209
+ ) {
210
+ errors.push("QTI attempt state interactionStates must be a record of JSON values.");
211
+ }
212
+ if (!isDiagnosticArray(value.validationMessages)) {
213
+ errors.push("QTI attempt state validationMessages must be an array of diagnostics.");
214
+ }
215
+ return errors;
216
+ }
217
+
218
+ function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
219
+ if (!isRecord(value)) return false;
220
+ return Object.values(value).every(isQtiValue);
221
+ }
222
+
223
+ function isPortableCustomStateRecord(
224
+ value: unknown,
225
+ ): value is Record<string, QtiPortableCustomStateValue> {
226
+ if (!isRecord(value)) return false;
227
+ return Object.values(value).every(isQtiPortableCustomStateValue);
228
+ }
229
+
230
+ function isDiagnosticArray(value: unknown): value is QtiDiagnostic[] {
231
+ if (!Array.isArray(value)) return false;
232
+ return value.every((item) => {
233
+ if (!isRecord(item)) return false;
234
+ if (typeof item.code !== "string" || item.code.length === 0) return false;
235
+ if (item.severity !== "info" && item.severity !== "warning" && item.severity !== "error") {
236
+ return false;
237
+ }
238
+ if (typeof item.message !== "string" || item.message.length === 0) return false;
239
+ return item.path === undefined || typeof item.path === "string";
240
+ });
241
+ }
242
+
243
+ function isRecord(value: unknown): value is Record<string, unknown> {
244
+ return typeof value === "object" && value !== null && !Array.isArray(value);
245
+ }
package/src/catalog.ts CHANGED
@@ -112,7 +112,7 @@ function selectedCandidates(
112
112
  .filter((entry): entry is { candidate: Candidate; index: number; rank: number } =>
113
113
  Number.isInteger(entry.rank),
114
114
  )
115
- .sort((a, b) => a.rank - b.rank || a.index - b.index);
115
+ .toSorted((a, b) => a.rank - b.rank || a.index - b.index);
116
116
  if (languageMatches.length > 0) return languageMatches.map((entry) => entry.candidate);
117
117
  if (options.includeDefaultFallback === false) return [];
118
118
  const defaults = candidates.filter((candidate) => candidate.default);
@@ -1,4 +1,5 @@
1
1
  import type { QtiChoice, QtiContentNode, QtiSourceLocation } from "./types.js";
2
+ import { assertNever } from "./assert-never.js";
2
3
 
3
4
  export interface FlatTextFromContentOptions {
4
5
  excludeAnnotations?: boolean;
@@ -63,6 +64,8 @@ function flatTextFromContentNode(node: QtiContentNode, excludeAnnotations: boole
63
64
  case "interaction":
64
65
  case "printedVariable":
65
66
  return "";
67
+ default:
68
+ return assertNever(node);
66
69
  }
67
70
  }
68
71
 
@@ -0,0 +1,12 @@
1
+ import type { QtiProcessingExpression, QtiValue } from "./types.js";
2
+
3
+ export interface QtiCustomOperatorContext {
4
+ definition?: string | undefined;
5
+ className?: string | undefined;
6
+ attributes: Record<string, string>;
7
+ values: QtiValue[];
8
+ expression: Extract<QtiProcessingExpression, { type: "customOperator" }>;
9
+ }
10
+
11
+ export type QtiCustomOperatorHandler = (context: QtiCustomOperatorContext) => QtiValue;
12
+ export type QtiCustomOperatorRegistry = Record<string, QtiCustomOperatorHandler>;
@@ -0,0 +1,268 @@
1
+ import type { QtiDiagnostic, QtiSourceLocation } from "./types.js";
2
+ import { descendants, parseXmlTree, type XmlNode } from "./xml.js";
3
+
4
+ export const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
5
+ "response-declaration",
6
+ "outcome-declaration",
7
+ "template-declaration",
8
+ ]);
9
+
10
+ export const FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES = new Set([
11
+ "correct-response",
12
+ "mapping",
13
+ "area-mapping",
14
+ "match-table",
15
+ "interpolation-table",
16
+ "response-processing",
17
+ ]);
18
+
19
+ export type QtiDeliverySecurityFindingKind =
20
+ | "forbidden-delivery-element"
21
+ | "unsupported-secure-delivery-element"
22
+ | "unsupported-adaptive-response-processing";
23
+
24
+ export interface QtiDeliverySecurityFinding {
25
+ kind: QtiDeliverySecurityFindingKind;
26
+ qtiName: string;
27
+ localName: string;
28
+ message: string;
29
+ source?: QtiSourceLocation | undefined;
30
+ }
31
+
32
+ export interface QtiDeliverySecurityAnalysis {
33
+ diagnostics: QtiDiagnostic[];
34
+ findings: QtiDeliverySecurityFinding[];
35
+ /** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
36
+ deliverySafe: boolean;
37
+ /** True when secure-delivery redaction can be attempted for this XML. */
38
+ secureDeliverySupported: boolean;
39
+ }
40
+
41
+ export interface ParsedDeliveryXml {
42
+ root: XmlNode | undefined;
43
+ diagnostics: QtiDiagnostic[];
44
+ }
45
+
46
+ export interface DeliveryRedactionPolicy {
47
+ isForbiddenElement(node: XmlNode, normalizedName: string): boolean;
48
+ unsupportedFinding?(
49
+ node: XmlNode,
50
+ normalizedName: string,
51
+ nodes: readonly XmlNode[],
52
+ ): QtiDeliverySecurityFinding | null;
53
+ diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string;
54
+ forbiddenElementMessage(node: XmlNode): string;
55
+ }
56
+
57
+ export interface RedactedDeliveryXmlResult {
58
+ ok: boolean;
59
+ diagnostics: QtiDiagnostic[];
60
+ analysis: QtiDeliverySecurityAnalysis;
61
+ xml?: string | undefined;
62
+ }
63
+
64
+ export function parseDeliveryXml(xml: string): ParsedDeliveryXml {
65
+ let parsed: ReturnType<typeof parseXmlTree>;
66
+ try {
67
+ parsed = parseXmlTree(xml);
68
+ } catch (error) {
69
+ return {
70
+ root: undefined,
71
+ diagnostics: [
72
+ {
73
+ code: "xml.parse",
74
+ severity: "error",
75
+ message: error instanceof Error ? error.message : String(error),
76
+ },
77
+ ],
78
+ };
79
+ }
80
+
81
+ const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
82
+ code: "xml.parse",
83
+ severity: "error",
84
+ message: error.message,
85
+ }));
86
+ if (!parsed.root) {
87
+ diagnostics.push({
88
+ code: "xml.empty",
89
+ severity: "error",
90
+ message: "No XML root element was found.",
91
+ });
92
+ }
93
+ return { root: parsed.root, diagnostics };
94
+ }
95
+
96
+ export function analyzeDeliveryXml(
97
+ parsed: ParsedDeliveryXml,
98
+ policy: DeliveryRedactionPolicy,
99
+ ): QtiDeliverySecurityAnalysis {
100
+ const diagnostics = [...parsed.diagnostics];
101
+ const findings: QtiDeliverySecurityFinding[] = [];
102
+
103
+ if (parsed.root) {
104
+ const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
105
+ for (const node of nodes) {
106
+ const normalizedName = normalizedQtiElementName(node.localName);
107
+ if (policy.isForbiddenElement(node, normalizedName)) {
108
+ findings.push({
109
+ kind: "forbidden-delivery-element",
110
+ qtiName: node.name,
111
+ localName: node.localName,
112
+ message: policy.forbiddenElementMessage(node),
113
+ source: node.source,
114
+ });
115
+ }
116
+
117
+ const unsupportedFinding = policy.unsupportedFinding?.(node, normalizedName, nodes);
118
+ if (unsupportedFinding) findings.push(unsupportedFinding);
119
+ }
120
+ }
121
+
122
+ diagnostics.push(
123
+ ...findings.map((finding) =>
124
+ findingToDiagnostic(finding, (kind) => policy.diagnosticCodeForFinding(kind)),
125
+ ),
126
+ );
127
+ const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
128
+
129
+ return {
130
+ diagnostics,
131
+ findings,
132
+ deliverySafe:
133
+ parseOk && !findings.some((finding) => finding.kind === "forbidden-delivery-element"),
134
+ secureDeliverySupported:
135
+ parseOk &&
136
+ !findings.some(
137
+ (finding) =>
138
+ finding.kind === "unsupported-secure-delivery-element" ||
139
+ finding.kind === "unsupported-adaptive-response-processing",
140
+ ),
141
+ };
142
+ }
143
+
144
+ export function redactDeliveryXml(
145
+ xml: string,
146
+ policy: DeliveryRedactionPolicy,
147
+ ): RedactedDeliveryXmlResult {
148
+ const parsed = parseDeliveryXml(xml);
149
+ const analysis = analyzeDeliveryXml(parsed, policy);
150
+ if (!analysis.secureDeliverySupported) {
151
+ return {
152
+ ok: false,
153
+ diagnostics: analysis.diagnostics,
154
+ analysis,
155
+ };
156
+ }
157
+
158
+ if (!parsed.root) {
159
+ return {
160
+ ok: false,
161
+ diagnostics: parsed.diagnostics,
162
+ analysis,
163
+ };
164
+ }
165
+
166
+ const redactionRanges = readRedactionRanges(
167
+ [parsed.root, ...descendants(parsed.root, () => true)],
168
+ (node, normalizedName) => policy.isForbiddenElement(node, normalizedName),
169
+ );
170
+ const redactedXml = removeSourceRanges(xml, redactionRanges);
171
+ const redactedAnalysis = analyzeDeliveryXml(parseDeliveryXml(redactedXml), policy);
172
+ if (!redactedAnalysis.deliverySafe || !redactedAnalysis.secureDeliverySupported) {
173
+ return {
174
+ ok: false,
175
+ diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
176
+ analysis: redactedAnalysis,
177
+ };
178
+ }
179
+
180
+ return {
181
+ ok: true,
182
+ xml: redactedXml,
183
+ diagnostics: redactedAnalysis.diagnostics,
184
+ analysis: redactedAnalysis,
185
+ };
186
+ }
187
+
188
+ export function readRedactionRanges(
189
+ nodes: readonly XmlNode[],
190
+ shouldRedact: (node: XmlNode, normalizedName: string) => boolean,
191
+ ): RedactionRange[] {
192
+ const ranges = nodes.flatMap((node) => {
193
+ const normalizedName = normalizedQtiElementName(node.localName);
194
+ if (!shouldRedact(node, normalizedName)) return [];
195
+ return sourceRangeFor(node);
196
+ });
197
+ return mergeSourceRanges(ranges);
198
+ }
199
+
200
+ export function sourceRangeFor(node: XmlNode): RedactionRange[] {
201
+ const endOffset = node.sourceRange.endOffset;
202
+ if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
203
+ return [{ startOffset: node.sourceRange.startOffset, endOffset }];
204
+ }
205
+
206
+ export function removeSourceRanges(xml: string, ranges: readonly RedactionRange[]): string {
207
+ let output = "";
208
+ let cursor = 0;
209
+ for (const range of ranges) {
210
+ output += xml.slice(cursor, range.startOffset);
211
+ cursor = range.endOffset;
212
+ }
213
+ return output + xml.slice(cursor);
214
+ }
215
+
216
+ export function normalizedQtiElementName(localName: string): string {
217
+ const lower = localName.toLowerCase();
218
+ return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
219
+ }
220
+
221
+ export function parseXmlBoolean(value: string | undefined): boolean | undefined {
222
+ if (value === undefined) return undefined;
223
+ const normalized = value.trim().toLowerCase();
224
+ if (normalized === "true" || normalized === "1") return true;
225
+ if (normalized === "false" || normalized === "0") return false;
226
+ return undefined;
227
+ }
228
+
229
+ export function isDeclarationDefaultValue(node: XmlNode, normalizedName: string): boolean {
230
+ return (
231
+ normalizedName === "default-value" &&
232
+ DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
233
+ normalizedQtiElementName(node.parent?.localName ?? ""),
234
+ )
235
+ );
236
+ }
237
+
238
+ export interface RedactionRange {
239
+ startOffset: number;
240
+ endOffset: number;
241
+ }
242
+
243
+ function mergeSourceRanges(ranges: readonly RedactionRange[]): RedactionRange[] {
244
+ const sorted = ranges.toSorted((left, right) => left.startOffset - right.startOffset);
245
+ const merged: RedactionRange[] = [];
246
+ for (const range of sorted) {
247
+ const last = merged.at(-1);
248
+ if (last && range.startOffset <= last.endOffset) {
249
+ last.endOffset = Math.max(last.endOffset, range.endOffset);
250
+ continue;
251
+ }
252
+ merged.push({ ...range });
253
+ }
254
+ return merged;
255
+ }
256
+
257
+ function findingToDiagnostic(
258
+ finding: QtiDeliverySecurityFinding,
259
+ diagnosticCodeForFinding: (kind: QtiDeliverySecurityFindingKind) => string,
260
+ ): QtiDiagnostic {
261
+ return {
262
+ code: diagnosticCodeForFinding(finding.kind),
263
+ severity: "error",
264
+ message: finding.message,
265
+ path: finding.source?.path,
266
+ source: finding.source,
267
+ };
268
+ }