@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
package/src/session.ts CHANGED
@@ -7,50 +7,61 @@ import type {
7
7
  QtiModalFeedback,
8
8
  QtiPortableCustomStateValue,
9
9
  QtiProcessingExpression,
10
- QtiRecordValue,
11
10
  QtiResponseCondition,
12
- QtiResponseDeclaration,
13
11
  QtiResponseRule,
14
- QtiScalarValue,
15
12
  QtiScoreResult,
16
13
  QtiTemplateRule,
17
14
  QtiValue,
18
- QtiVariableDeclaration,
19
15
  } from "./types.js";
16
+ import { qtiValueToString } from "./value-format.js";
17
+ import type { QtiCustomOperatorRegistry } from "./custom-operators.js";
18
+ export type {
19
+ QtiCustomOperatorContext,
20
+ QtiCustomOperatorHandler,
21
+ QtiCustomOperatorRegistry,
22
+ } from "./custom-operators.js";
23
+ import { createEvaluationContext, type EvaluationContext } from "./processing-evaluator.js";
24
+ import { seededRandom } from "./processing-random.js";
25
+ import { getResponseDeclaration } from "./processing-variables.js";
26
+ import { lookupOutcomeValue, mapOrMatchResponse } from "./processing-mapping.js";
27
+ import { responseProcessingTemplateKind } from "./processing-templates.js";
28
+ import { assertCompatiblePriorState } from "./attempt-state.js";
29
+ export { assertQtiAttemptStateV1, isQtiAttemptStateV1 } from "./attempt-state.js";
20
30
  import {
21
- isQtiPortableCustomStateValue,
22
- isQtiValue,
23
- qtiScalarToString,
24
- qtiValueToString,
25
- qtiValueToStringList,
26
- } from "./value-format.js";
27
-
28
- export interface QtiCustomOperatorContext {
29
- definition?: string | undefined;
30
- className?: string | undefined;
31
- attributes: Record<string, string>;
32
- values: QtiValue[];
33
- expression: Extract<QtiProcessingExpression, { type: "customOperator" }>;
31
+ COMPLETION_COMPLETED,
32
+ COMPLETION_NOT_ATTEMPTED,
33
+ COMPLETION_STATUS,
34
+ COMPLETION_UNKNOWN,
35
+ } from "./attempt-state-constants.js";
36
+ import {
37
+ cloneDiagnostics,
38
+ clonePortableCustomState,
39
+ clonePortableCustomStateRecord,
40
+ cloneValue,
41
+ cloneValueRecord,
42
+ serialize,
43
+ } from "./processing-state.js";
44
+ import { booleanValue, normalizeValueForCardinality, qtiMatchValues } from "./processing-values.js";
45
+
46
+ interface SessionProcessingContext {
47
+ responseDefaults: Record<string, QtiValue>;
48
+ evaluation: EvaluationContext;
49
+ }
50
+
51
+ interface ConditionalRules<Rule> {
52
+ ifExpression?: QtiProcessingExpression | undefined;
53
+ thenRules: Rule[];
54
+ elseIfs: Array<{
55
+ expression?: QtiProcessingExpression | undefined;
56
+ rules: Rule[];
57
+ }>;
58
+ elseRules: Rule[];
34
59
  }
35
60
 
36
- export type QtiCustomOperatorHandler = (context: QtiCustomOperatorContext) => QtiValue;
37
- export type QtiCustomOperatorRegistry = Record<string, QtiCustomOperatorHandler>;
38
-
39
- const COMPLETION_STATUS = "completionStatus";
40
- const COMPLETION_NOT_ATTEMPTED = "not_attempted";
41
- const COMPLETION_UNKNOWN = "unknown";
42
- const COMPLETION_COMPLETED = "completed";
43
- const ATTEMPT_STATE_SCHEMA = "qti3.attempt-state.v1";
44
- const ATTEMPT_STATUSES = new Set<QtiAttemptStatus>([
45
- "initialized",
46
- "interacting",
47
- "suspended",
48
- "completed",
49
- ]);
50
-
51
61
  export interface QtiItemSessionOptions {
52
62
  randomSeed?: string | number | undefined;
53
63
  customOperators?: QtiCustomOperatorRegistry | undefined;
64
+ allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
54
65
  }
55
66
 
56
67
  export interface QtiItemSession {
@@ -100,6 +111,9 @@ export function createItemSession(
100
111
  let status: QtiAttemptStatus = priorState?.status ?? "initialized";
101
112
  const random = seededRandom(options.randomSeed ?? document.item.identifier);
102
113
  const customOperators = options.customOperators ?? {};
114
+ const allowedUndeclaredResponseIdentifiers = new Set(
115
+ options.allowedUndeclaredResponseIdentifiers ?? [],
116
+ );
103
117
  const portableCustomResponseIdentifiers = new Set(
104
118
  document.item.interactions
105
119
  .filter((interaction) => interaction.type === "portableCustom")
@@ -123,16 +137,23 @@ export function createItemSession(
123
137
  const baseResponses = cloneValueRecord(responses);
124
138
  const baseResponseDefaults = cloneValueRecord(responseDefaults);
125
139
  const baseOutcomes = cloneValueRecord(outcomes);
126
-
127
- applyTemplateProcessing(
140
+ const evaluation = createEvaluationContext(
128
141
  document,
129
- templateValues,
130
142
  responses,
131
- responseDefaults,
132
143
  outcomes,
144
+ templateValues,
133
145
  correctResponses,
134
146
  random,
135
147
  customOperators,
148
+ { allowedUndeclaredResponseIdentifiers },
149
+ );
150
+ const processingContext: SessionProcessingContext = {
151
+ responseDefaults,
152
+ evaluation,
153
+ };
154
+
155
+ applyTemplateProcessing(
156
+ processingContext,
136
157
  new Set(),
137
158
  baseResponses,
138
159
  baseResponseDefaults,
@@ -142,14 +163,7 @@ export function createItemSession(
142
163
  Object.assign(templateValues, priorTemplateValues);
143
164
  resetCorrectResponses(document, correctResponses);
144
165
  applyTemplateProcessing(
145
- document,
146
- templateValues,
147
- responses,
148
- responseDefaults,
149
- outcomes,
150
- correctResponses,
151
- random,
152
- customOperators,
166
+ processingContext,
153
167
  new Set(Object.keys(priorTemplateValues)),
154
168
  baseResponses,
155
169
  baseResponseDefaults,
@@ -196,15 +210,7 @@ export function createItemSession(
196
210
  resetRecord(outcomes, cloneValueRecord(defaultOutcomes));
197
211
  outcomes[COMPLETION_STATUS] = completionStatus;
198
212
  }
199
- applyResponseProcessing(
200
- document,
201
- responses,
202
- outcomes,
203
- templateValues,
204
- correctResponses,
205
- random,
206
- customOperators,
207
- );
213
+ applyResponseProcessing(processingContext);
208
214
  if (outcomes[COMPLETION_STATUS] === COMPLETION_COMPLETED) status = "completed";
209
215
  validationMessages = diagnostics;
210
216
  const state = serialize(
@@ -242,15 +248,6 @@ export function createItemSession(
242
248
  }
243
249
  }
244
250
 
245
- export function isQtiAttemptStateV1(value: unknown): value is QtiAttemptStateV1 {
246
- return attemptStateErrors(value).length === 0;
247
- }
248
-
249
- export function assertQtiAttemptStateV1(value: unknown): asserts value is QtiAttemptStateV1 {
250
- const [firstError] = attemptStateErrors(value);
251
- if (firstError) throw new Error(firstError);
252
- }
253
-
254
251
  function resetRecord<T>(target: Record<string, T>, source: Record<string, T>): void {
255
252
  for (const key of Object.keys(target)) {
256
253
  delete target[key];
@@ -258,242 +255,28 @@ function resetRecord<T>(target: Record<string, T>, source: Record<string, T>): v
258
255
  Object.assign(target, source);
259
256
  }
260
257
 
261
- function assertCompatiblePriorState(
262
- document: QtiDocument,
263
- priorState: QtiAttemptStateV1 | undefined,
264
- ): void {
265
- if (!priorState) return;
266
- assertQtiAttemptStateV1(priorState);
267
- if (priorState.itemIdentifier !== document.item.identifier) {
268
- throw new Error(
269
- `Cannot restore state for ${priorState.itemIdentifier} into ${document.item.identifier}.`,
270
- );
271
- }
272
- const responseIdentifiers = new Set(
273
- document.item.responseDeclarations.map((declaration) => declaration.identifier),
274
- );
275
- const outcomeIdentifiers = new Set([
276
- ...document.item.outcomeDeclarations.map((declaration) => declaration.identifier),
277
- COMPLETION_STATUS,
278
- ]);
279
- const templateIdentifiers = new Set(
280
- document.item.templateDeclarations.map((declaration) => declaration.identifier),
281
- );
282
- const interactionStateIdentifiers = new Set(
283
- document.item.interactions
284
- .filter((interaction) => interaction.type === "portableCustom")
285
- .map((interaction) => interaction.responseIdentifier)
286
- .filter((identifier): identifier is string => Boolean(identifier)),
287
- );
288
- assertKnownStateIdentifiers("response", priorState.responses, responseIdentifiers);
289
- assertKnownStateIdentifiers("outcome", priorState.outcomes, outcomeIdentifiers);
290
- assertKnownStateIdentifiers("template", priorState.templateValues ?? {}, templateIdentifiers);
291
- assertKnownPortableCustomStateIdentifiers(
292
- priorState.interactionStates ?? {},
293
- interactionStateIdentifiers,
294
- );
295
- for (const message of priorState.validationMessages) {
296
- if (message.path && !responseIdentifiers.has(message.path)) {
297
- throw new Error(`Cannot restore validation message for unknown response ${message.path}.`);
298
- }
299
- }
300
- for (const declaration of document.item.responseDeclarations) {
301
- assertRestoredValueMatchesDeclaration("response", declaration, priorState.responses);
302
- }
303
- for (const declaration of document.item.outcomeDeclarations) {
304
- assertRestoredValueMatchesDeclaration("outcome", declaration, priorState.outcomes);
305
- }
306
- for (const declaration of document.item.templateDeclarations) {
307
- assertRestoredValueMatchesDeclaration("template", declaration, priorState.templateValues ?? {});
308
- }
309
- const completionStatus = priorState.outcomes[COMPLETION_STATUS];
310
- if (
311
- completionStatus !== undefined &&
312
- completionStatus !== COMPLETION_NOT_ATTEMPTED &&
313
- completionStatus !== COMPLETION_UNKNOWN &&
314
- completionStatus !== COMPLETION_COMPLETED &&
315
- completionStatus !== "incomplete"
316
- ) {
317
- throw new Error(
318
- `Cannot restore unsupported completionStatus ${qtiValueToString(completionStatus)}.`,
319
- );
320
- }
321
- }
322
-
323
- function assertKnownStateIdentifiers(
324
- kind: string,
325
- record: Record<string, QtiValue>,
326
- allowed: Set<string>,
327
- ): void {
328
- const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
329
- if (unknown) throw new Error(`Cannot restore unknown ${kind} identifier ${unknown}.`);
330
- }
331
-
332
- function assertKnownPortableCustomStateIdentifiers(
333
- record: Record<string, QtiPortableCustomStateValue>,
334
- allowed: Set<string>,
335
- ): void {
336
- const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
337
- if (unknown) throw new Error(`Cannot restore unknown interaction state identifier ${unknown}.`);
338
- }
339
-
340
- function assertRestoredValueMatchesDeclaration(
341
- kind: string,
342
- declaration: QtiVariableDeclaration,
343
- record: Record<string, QtiValue>,
344
- ): void {
345
- if (!(declaration.identifier in record)) return;
346
- const value = record[declaration.identifier] ?? null;
347
- const error = restoredValueError(declaration, value);
348
- if (error) throw new Error(`Cannot restore ${kind} ${declaration.identifier}: ${error}.`);
349
- }
350
-
351
- function restoredValueError(
352
- declaration: QtiVariableDeclaration,
353
- value: QtiValue,
354
- ): string | undefined {
355
- if (value === null) return undefined;
356
- if (declaration.cardinality === "record") {
357
- return isRecordValue(value) ? undefined : "expected record value";
358
- }
359
- if (isRecordValue(value)) return "expected scalar value";
360
- if (declaration.cardinality === "single" && Array.isArray(value)) {
361
- return "expected single value";
362
- }
363
- if (
364
- (declaration.cardinality === "multiple" || declaration.cardinality === "ordered") &&
365
- !Array.isArray(value)
366
- ) {
367
- return `expected ${declaration.cardinality} value container`;
368
- }
369
- if (!declaration.baseType) return undefined;
370
- for (const entry of restoredValueEntries(value)) {
371
- if (!restoredScalarMatchesBaseType(entry, declaration.baseType)) {
372
- return `value ${String(entry)} is not valid for base-type ${declaration.baseType}`;
373
- }
374
- }
375
- return undefined;
376
- }
377
-
378
- function restoredValueEntries(value: QtiValue): QtiScalarValue[] {
379
- if (value === null || isRecordValue(value)) return [];
380
- return Array.isArray(value) ? value : [value];
381
- }
382
-
383
- function restoredScalarMatchesBaseType(
384
- value: QtiScalarValue,
385
- baseType: QtiVariableDeclaration["baseType"],
386
- ): boolean {
387
- if (!baseType) return true;
388
- if (baseType === "integer") {
389
- return typeof value === "number" ? Number.isInteger(value) : /^-?\d+$/.test(String(value));
390
- }
391
- if (baseType === "float") {
392
- return typeof value === "number" ? Number.isFinite(value) : Number.isFinite(Number(value));
393
- }
394
- if (baseType === "boolean")
395
- return typeof value === "boolean" || value === "true" || value === "false";
396
- if (baseType === "point") return pointValueIsValid(value);
397
- if (baseType === "pair" || baseType === "directedPair") return pairValueIsValid(value);
398
- if (baseType === "identifier")
399
- return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value);
400
- return typeof value === "string";
401
- }
402
-
403
- function pointValueIsValid(value: QtiScalarValue): boolean {
404
- const parts = String(value).trim().split(/\s+/);
405
- return parts.length === 2 && parts.every((part) => Number.isFinite(Number(part)));
406
- }
407
-
408
- function pairValueIsValid(value: QtiScalarValue): boolean {
409
- const parts = String(value).trim().split(/\s+/);
410
- return parts.length === 2 && parts.every((part) => part.length > 0);
411
- }
412
-
413
- function attemptStateErrors(value: unknown): string[] {
414
- if (!isRecord(value)) return ["QTI attempt state must be an object."];
415
-
416
- const schema = value.schema;
417
- if (schema !== ATTEMPT_STATE_SCHEMA) {
418
- return [`Unsupported QTI attempt state schema ${String(schema)}.`];
419
- }
420
-
421
- const errors: string[] = [];
422
- if (typeof value.itemIdentifier !== "string" || value.itemIdentifier.length === 0) {
423
- errors.push("QTI attempt state itemIdentifier must be a non-empty string.");
424
- }
425
- if (typeof value.status !== "string" || !ATTEMPT_STATUSES.has(value.status as QtiAttemptStatus)) {
426
- errors.push(`QTI attempt state status ${String(value.status)} is not supported.`);
427
- }
428
- if (!isQtiValueRecord(value.responses)) {
429
- errors.push("QTI attempt state responses must be a record of QTI values.");
430
- }
431
- if (!isQtiValueRecord(value.outcomes)) {
432
- errors.push("QTI attempt state outcomes must be a record of QTI values.");
433
- }
434
- if (value.templateValues !== undefined && !isQtiValueRecord(value.templateValues)) {
435
- errors.push("QTI attempt state templateValues must be a record of QTI values.");
436
- }
437
- if (
438
- value.interactionStates !== undefined &&
439
- !isPortableCustomStateRecord(value.interactionStates)
440
- ) {
441
- errors.push("QTI attempt state interactionStates must be a record of JSON values.");
442
- }
443
- if (!isDiagnosticArray(value.validationMessages)) {
444
- errors.push("QTI attempt state validationMessages must be an array of diagnostics.");
445
- }
446
- return errors;
447
- }
448
-
449
258
  function applyTemplateProcessing(
450
- document: QtiDocument,
451
- templateValues: Record<string, QtiValue>,
452
- responses: Record<string, QtiValue>,
453
- responseDefaults: Record<string, QtiValue>,
454
- outcomes: Record<string, QtiValue>,
455
- correctResponses: Record<string, QtiValue>,
456
- random: () => number,
457
- customOperators: QtiCustomOperatorRegistry,
259
+ context: SessionProcessingContext,
458
260
  preservedTemplateIdentifiers = new Set<string>(),
459
- baseResponses: Record<string, QtiValue> = cloneValueRecord(responses),
460
- baseResponseDefaults: Record<string, QtiValue> = cloneValueRecord(responseDefaults),
461
- baseOutcomes: Record<string, QtiValue> = cloneValueRecord(outcomes),
261
+ baseResponses: Record<string, QtiValue> = cloneValueRecord(context.evaluation.responses),
262
+ baseResponseDefaults: Record<string, QtiValue> = cloneValueRecord(context.responseDefaults),
263
+ baseOutcomes: Record<string, QtiValue> = cloneValueRecord(context.evaluation.outcomes),
462
264
  ): void {
463
- const rules = document.item.templateProcessing?.rules ?? [];
265
+ const { evaluation } = context;
266
+ const rules = evaluation.document.item.templateProcessing?.rules ?? [];
464
267
  let restarts = 0;
465
268
  for (let index = 0; index < rules.length; index += 1) {
466
269
  const rule = rules[index]!;
467
- const shouldExit = applyTemplateRule(
468
- rule,
469
- document,
470
- templateValues,
471
- responses,
472
- responseDefaults,
473
- outcomes,
474
- correctResponses,
475
- random,
476
- customOperators,
477
- preservedTemplateIdentifiers,
478
- );
270
+ const shouldExit = applyTemplateRule(context, rule, preservedTemplateIdentifiers);
479
271
  if (shouldExit) return;
480
272
  if (rule.type === "templateConstraint") {
481
- const satisfied = evaluateBoolean(
482
- rule.expression,
483
- document,
484
- responses,
485
- outcomes,
486
- templateValues,
487
- correctResponses,
488
- random,
489
- customOperators,
490
- );
273
+ const satisfied = evaluateProcessingBoolean(context, rule.expression);
491
274
  if (!satisfied) {
492
- resetTemplateValues(document, templateValues);
493
- resetRecord(responses, cloneValueRecord(baseResponses));
494
- resetRecord(responseDefaults, cloneValueRecord(baseResponseDefaults));
495
- resetRecord(outcomes, cloneValueRecord(baseOutcomes));
496
- resetCorrectResponses(document, correctResponses);
275
+ resetTemplateValues(evaluation.document, evaluation.templateValues);
276
+ resetRecord(evaluation.responses, cloneValueRecord(baseResponses));
277
+ resetRecord(context.responseDefaults, cloneValueRecord(baseResponseDefaults));
278
+ resetRecord(evaluation.outcomes, cloneValueRecord(baseOutcomes));
279
+ resetCorrectResponses(evaluation.document, evaluation.correctResponses);
497
280
  restarts += 1;
498
281
  if (restarts <= 100) index = -1;
499
282
  }
@@ -522,1808 +305,147 @@ function resetCorrectResponses(
522
305
  }
523
306
  }
524
307
 
308
+ function evaluateProcessingBoolean(
309
+ context: SessionProcessingContext,
310
+ expression: QtiResponseCondition["ifExpression"],
311
+ ): boolean {
312
+ return expression ? booleanValue(context.evaluation.evaluate(expression)) : false;
313
+ }
314
+
315
+ function resolveConditionalRules<Rule>(
316
+ context: SessionProcessingContext,
317
+ condition: ConditionalRules<Rule>,
318
+ ): Rule[] {
319
+ if (evaluateProcessingBoolean(context, condition.ifExpression)) return condition.thenRules;
320
+ for (const elseIf of condition.elseIfs) {
321
+ if (evaluateProcessingBoolean(context, elseIf.expression)) return elseIf.rules;
322
+ }
323
+ return condition.elseRules;
324
+ }
325
+
525
326
  function applyTemplateRule(
327
+ context: SessionProcessingContext,
526
328
  rule: QtiTemplateRule,
527
- document: QtiDocument,
528
- templateValues: Record<string, QtiValue>,
529
- responses: Record<string, QtiValue>,
530
- responseDefaults: Record<string, QtiValue>,
531
- outcomes: Record<string, QtiValue>,
532
- correctResponses: Record<string, QtiValue>,
533
- random: () => number,
534
- customOperators: QtiCustomOperatorRegistry,
535
329
  preservedTemplateIdentifiers: Set<string>,
536
330
  ): boolean {
331
+ const { evaluation } = context;
537
332
  if (rule.type === "exitTemplate") return true;
538
333
  if (rule.type === "templateConstraint") return false;
539
334
 
540
335
  if (rule.type === "templateCondition") {
541
- let branch = evaluateBoolean(
542
- rule.ifExpression,
543
- document,
544
- responses,
545
- outcomes,
546
- templateValues,
547
- correctResponses,
548
- random,
549
- customOperators,
550
- )
551
- ? rule.thenRules
552
- : undefined;
553
- if (!branch) {
554
- for (const elseIf of rule.elseIfs) {
555
- if (
556
- evaluateBoolean(
557
- elseIf.expression,
558
- document,
559
- responses,
560
- outcomes,
561
- templateValues,
562
- correctResponses,
563
- random,
564
- customOperators,
565
- )
566
- ) {
567
- branch = elseIf.rules;
568
- break;
569
- }
570
- }
571
- }
572
- branch ??= rule.elseRules;
336
+ const branch = resolveConditionalRules(context, rule);
573
337
  for (const branchRule of branch) {
574
- const shouldExit = applyTemplateRule(
575
- branchRule,
576
- document,
577
- templateValues,
578
- responses,
579
- responseDefaults,
580
- outcomes,
581
- correctResponses,
582
- random,
583
- customOperators,
584
- preservedTemplateIdentifiers,
585
- );
338
+ const shouldExit = applyTemplateRule(context, branchRule, preservedTemplateIdentifiers);
586
339
  if (shouldExit) return true;
587
340
  }
588
341
  return false;
589
342
  }
590
343
 
591
- const value = evaluateValue(
592
- rule.expression,
593
- document,
594
- responses,
595
- outcomes,
596
- templateValues,
597
- correctResponses,
598
- random,
599
- customOperators,
600
- );
344
+ const value = evaluation.evaluate(rule.expression);
601
345
  if (rule.type === "setTemplateValue") {
602
346
  if (preservedTemplateIdentifiers.has(rule.identifier)) return false;
603
- templateValues[rule.identifier] = value;
347
+ evaluation.templateValues[rule.identifier] = value;
604
348
  return false;
605
349
  }
606
350
 
607
351
  if (rule.type === "setDefaultValue") {
608
- const responseDeclaration = getResponseDeclaration(document, rule.identifier);
352
+ const responseDeclaration = getResponseDeclaration(evaluation.document, rule.identifier);
609
353
  if (responseDeclaration) {
610
354
  const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
611
355
  if (normalized === null) {
612
- delete responseDefaults[rule.identifier];
356
+ delete context.responseDefaults[rule.identifier];
613
357
  } else {
614
- responseDefaults[rule.identifier] = normalized;
358
+ context.responseDefaults[rule.identifier] = normalized;
615
359
  }
616
360
  return false;
617
361
  }
618
- const outcomeDeclaration = document.item.outcomeDeclarations.find(
362
+ const outcomeDeclaration = evaluation.document.item.outcomeDeclarations.find(
619
363
  (declaration) => declaration.identifier === rule.identifier,
620
364
  );
621
365
  if (outcomeDeclaration) {
622
- outcomes[rule.identifier] = value;
366
+ evaluation.outcomes[rule.identifier] = value;
623
367
  }
624
368
  return false;
625
369
  }
626
370
 
627
- const declaration = getResponseDeclaration(document, rule.identifier);
371
+ const declaration = getResponseDeclaration(evaluation.document, rule.identifier);
628
372
  if (declaration)
629
- correctResponses[rule.identifier] = normalizeValueForCardinality(
373
+ evaluation.correctResponses[rule.identifier] = normalizeValueForCardinality(
630
374
  value,
631
375
  declaration.cardinality,
632
376
  );
633
377
  return false;
634
378
  }
635
379
 
636
- function applyResponseProcessing(
637
- document: QtiDocument,
638
- responses: Record<string, QtiValue>,
639
- outcomes: Record<string, QtiValue>,
640
- templateValues: Record<string, QtiValue>,
641
- correctResponses: Record<string, QtiValue>,
642
- random: () => number,
643
- customOperators: QtiCustomOperatorRegistry,
644
- ): void {
645
- const processing = document.item.responseProcessing;
380
+ function applyResponseProcessing(context: SessionProcessingContext): void {
381
+ const { evaluation } = context;
382
+ const processing = evaluation.document.item.responseProcessing;
646
383
  if (processing?.rules.length) {
647
- applyResponseRules(
648
- processing.rules,
649
- document,
650
- responses,
651
- outcomes,
652
- templateValues,
653
- correctResponses,
654
- random,
655
- customOperators,
656
- );
384
+ applyResponseRules(context, processing.rules);
657
385
  return;
658
386
  }
659
387
 
660
- const templateKind = responseProcessingTemplateKind(processing?.template);
388
+ const templateKind = processing?.template
389
+ ? (responseProcessingTemplateKind(processing.template) ?? "unsupported")
390
+ : undefined;
661
391
  if (templateKind === "unsupported") {
662
392
  return;
663
393
  }
664
394
  if (templateKind === "mapResponse" || templateKind === "mapResponsePoint") {
665
- const declaration = getResponseDeclaration(document, "RESPONSE");
666
- outcomes.SCORE = declaration
395
+ const declaration = getResponseDeclaration(evaluation.document, "RESPONSE");
396
+ evaluation.outcomes.SCORE = declaration
667
397
  ? mapOrMatchResponse(
668
398
  declaration,
669
- responses.RESPONSE ?? null,
670
- correctResponses.RESPONSE ?? null,
399
+ evaluation.responses.RESPONSE ?? null,
400
+ evaluation.correctResponses.RESPONSE ?? null,
671
401
  )
672
402
  : 0;
673
403
  return;
674
404
  }
675
405
 
676
406
  if (templateKind === "matchCorrect") {
677
- const declaration = getResponseDeclaration(document, "RESPONSE");
407
+ const declaration = getResponseDeclaration(evaluation.document, "RESPONSE");
678
408
  const matches = declaration
679
409
  ? qtiMatchValues(
680
- responses.RESPONSE ?? null,
681
- correctResponses.RESPONSE ?? null,
410
+ evaluation.responses.RESPONSE ?? null,
411
+ evaluation.correctResponses.RESPONSE ?? null,
682
412
  declaration.cardinality === "ordered",
683
413
  )
684
414
  : null;
685
- outcomes.SCORE = matches === true ? 1 : 0;
415
+ evaluation.outcomes.SCORE = matches === true ? 1 : 0;
686
416
  }
687
417
  }
688
418
 
689
- function responseProcessingTemplateKind(
690
- template: string | undefined,
691
- ): "matchCorrect" | "mapResponse" | "mapResponsePoint" | "unsupported" | undefined {
692
- if (!template) return undefined;
693
- const path = template.split(/[?#]/, 1)[0] ?? "";
694
- const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
695
- if (name === "match_correct") return "matchCorrect";
696
- if (name === "map_response") return "mapResponse";
697
- if (name === "map_response_point") return "mapResponsePoint";
698
- return "unsupported";
699
- }
700
-
701
- function applyResponseRules(
702
- rules: QtiResponseRule[],
703
- document: QtiDocument,
704
- responses: Record<string, QtiValue>,
705
- outcomes: Record<string, QtiValue>,
706
- templateValues: Record<string, QtiValue>,
707
- correctResponses: Record<string, QtiValue>,
708
- random: () => number,
709
- customOperators: QtiCustomOperatorRegistry,
710
- ): boolean {
419
+ function applyResponseRules(context: SessionProcessingContext, rules: QtiResponseRule[]): boolean {
420
+ const { evaluation } = context;
711
421
  for (const rule of rules) {
712
422
  if (rule.type === "exitResponse") return true;
713
423
  if (rule.type === "responseCondition") {
714
- const shouldExit = applyResponseCondition(
715
- rule.condition,
716
- document,
717
- responses,
718
- outcomes,
719
- templateValues,
720
- correctResponses,
721
- random,
722
- customOperators,
723
- );
424
+ const shouldExit = applyResponseCondition(context, rule.condition);
724
425
  if (shouldExit) return true;
725
426
  continue;
726
427
  }
727
428
  if (rule.type === "responseProcessingFragment") {
728
- const shouldExit = applyResponseRules(
729
- rule.rules,
730
- document,
731
- responses,
732
- outcomes,
733
- templateValues,
734
- correctResponses,
735
- random,
736
- customOperators,
737
- );
429
+ const shouldExit = applyResponseRules(context, rule.rules);
738
430
  if (shouldExit) return true;
739
431
  continue;
740
432
  }
741
433
  if (rule.type === "lookupOutcomeValue") {
742
- outcomes[rule.identifier] = lookupOutcomeValue(
743
- document,
434
+ evaluation.outcomes[rule.identifier] = lookupOutcomeValue(
435
+ evaluation.document,
744
436
  rule.identifier,
745
- evaluateValue(
746
- rule.expression,
747
- document,
748
- responses,
749
- outcomes,
750
- templateValues,
751
- correctResponses,
752
- random,
753
- customOperators,
754
- ),
437
+ evaluation.evaluate(rule.expression),
755
438
  );
756
439
  continue;
757
440
  }
758
- outcomes[rule.identifier] = evaluateValue(
759
- rule.expression,
760
- document,
761
- responses,
762
- outcomes,
763
- templateValues,
764
- correctResponses,
765
- random,
766
- customOperators,
767
- );
441
+ evaluation.outcomes[rule.identifier] = evaluation.evaluate(rule.expression);
768
442
  }
769
443
  return false;
770
444
  }
771
445
 
772
446
  function applyResponseCondition(
447
+ context: SessionProcessingContext,
773
448
  condition: QtiResponseCondition,
774
- document: QtiDocument,
775
- responses: Record<string, QtiValue>,
776
- outcomes: Record<string, QtiValue>,
777
- templateValues: Record<string, QtiValue>,
778
- correctResponses: Record<string, QtiValue>,
779
- random: () => number,
780
- customOperators: QtiCustomOperatorRegistry,
781
- ): boolean {
782
- let branch = evaluateBoolean(
783
- condition.ifExpression,
784
- document,
785
- responses,
786
- outcomes,
787
- templateValues,
788
- correctResponses,
789
- random,
790
- customOperators,
791
- )
792
- ? condition.thenRules
793
- : undefined;
794
- if (!branch) {
795
- for (const elseIf of condition.elseIfs) {
796
- if (
797
- evaluateBoolean(
798
- elseIf.expression,
799
- document,
800
- responses,
801
- outcomes,
802
- templateValues,
803
- correctResponses,
804
- random,
805
- customOperators,
806
- )
807
- ) {
808
- branch = elseIf.rules;
809
- break;
810
- }
811
- }
812
- }
813
- branch ??= condition.elseRules;
814
- return applyResponseRules(
815
- branch,
816
- document,
817
- responses,
818
- outcomes,
819
- templateValues,
820
- correctResponses,
821
- random,
822
- customOperators,
823
- );
824
- }
825
-
826
- function evaluateBoolean(
827
- expression: QtiProcessingExpression | undefined,
828
- document: QtiDocument,
829
- responses: Record<string, QtiValue>,
830
- outcomes: Record<string, QtiValue> = {},
831
- templateValues: Record<string, QtiValue> = {},
832
- correctResponses: Record<string, QtiValue> = {},
833
- random: () => number = Math.random,
834
- customOperators: QtiCustomOperatorRegistry = {},
835
- ): boolean {
836
- if (!expression) return false;
837
- return booleanValue(
838
- evaluateValue(
839
- expression,
840
- document,
841
- responses,
842
- outcomes,
843
- templateValues,
844
- correctResponses,
845
- random,
846
- customOperators,
847
- ),
848
- );
849
- }
850
-
851
- function evaluateValue(
852
- expression: QtiProcessingExpression,
853
- document: QtiDocument,
854
- responses: Record<string, QtiValue>,
855
- outcomes: Record<string, QtiValue> = {},
856
- templateValues: Record<string, QtiValue> = {},
857
- correctResponses: Record<string, QtiValue> = {},
858
- random: () => number = Math.random,
859
- customOperators: QtiCustomOperatorRegistry = {},
860
- ): QtiValue {
861
- if (expression.type === "baseValue") return expression.value;
862
- if (expression.type === "null") return null;
863
- if (expression.type === "isNull") return isNullResponse(responses[expression.identifier] ?? null);
864
- if (expression.type === "matchCorrect") {
865
- const declaration = getResponseDeclaration(document, expression.correctIdentifier);
866
- return declaration
867
- ? qtiMatchValues(
868
- responses[expression.identifier] ?? null,
869
- correctResponses[expression.correctIdentifier] ?? null,
870
- declaration.cardinality === "ordered",
871
- )
872
- : false;
873
- }
874
- if (expression.type === "match") {
875
- const left = evaluateValue(
876
- expression.left,
877
- document,
878
- responses,
879
- outcomes,
880
- templateValues,
881
- correctResponses,
882
- random,
883
- customOperators,
884
- );
885
- const right = evaluateValue(
886
- expression.right,
887
- document,
888
- responses,
889
- outcomes,
890
- templateValues,
891
- correctResponses,
892
- random,
893
- customOperators,
894
- );
895
- return qtiMatchValues(
896
- left,
897
- right,
898
- expressionIsOrdered(expression.left, document) ||
899
- expressionIsOrdered(expression.right, document),
900
- );
901
- }
902
- if (expression.type === "mapResponse") {
903
- const declaration = getResponseDeclaration(document, expression.identifier);
904
- return declaration
905
- ? mapOrMatchResponse(
906
- declaration,
907
- responses[expression.identifier] ?? null,
908
- correctResponses[expression.identifier] ?? null,
909
- )
910
- : 0;
911
- }
912
- if (expression.type === "mapResponsePoint") {
913
- const declaration = getResponseDeclaration(document, expression.identifier);
914
- return declaration?.areaMapping
915
- ? scoreAreaMapping(responses[expression.identifier] ?? null, declaration.areaMapping)
916
- : 0;
917
- }
918
- if (expression.type === "correct") {
919
- return correctResponses[expression.identifier] ?? null;
920
- }
921
- if (expression.type === "default") {
922
- return defaultValueForIdentifier(document, expression.identifier);
923
- }
924
- if (expression.type === "variable") {
925
- return (
926
- responses[expression.identifier] ??
927
- outcomes[expression.identifier] ??
928
- templateValues[expression.identifier] ??
929
- null
930
- );
931
- }
932
- if (expression.type === "randomInteger") {
933
- const step = expression.step > 0 ? expression.step : 1;
934
- const count = Math.floor((expression.max - expression.min) / step) + 1;
935
- return expression.min + Math.floor(random() * count) * step;
936
- }
937
- if (expression.type === "randomFloat") {
938
- return expression.min + random() * (expression.max - expression.min);
939
- }
940
- if (expression.type === "random") {
941
- if (expression.values.length === 0) return null;
942
- const index = Math.floor(random() * expression.values.length);
943
- return evaluateValue(
944
- expression.values[index]!,
945
- document,
946
- responses,
947
- outcomes,
948
- templateValues,
949
- correctResponses,
950
- random,
951
- customOperators,
952
- );
953
- }
954
- if (expression.type === "multiple" || expression.type === "ordered") {
955
- const values = expression.expressions.flatMap((item) =>
956
- valueContainer(
957
- evaluateValue(
958
- item,
959
- document,
960
- responses,
961
- outcomes,
962
- templateValues,
963
- correctResponses,
964
- random,
965
- customOperators,
966
- ),
967
- ),
968
- );
969
- return values.length > 0 ? values : null;
970
- }
971
- if (expression.type === "index") {
972
- const values = valueContainer(
973
- evaluateValue(
974
- expression.expression,
975
- document,
976
- responses,
977
- outcomes,
978
- templateValues,
979
- correctResponses,
980
- random,
981
- customOperators,
982
- ),
983
- );
984
- const n = indexValue(expression.n, outcomes, templateValues);
985
- if (n === undefined || n < 1 || n > values.length) return null;
986
- return values[n - 1] ?? null;
987
- }
988
- if (expression.type === "containerSize") {
989
- return valueContainer(
990
- evaluateValue(
991
- expression.expression,
992
- document,
993
- responses,
994
- outcomes,
995
- templateValues,
996
- correctResponses,
997
- random,
998
- customOperators,
999
- ),
1000
- ).length;
1001
- }
1002
- if (expression.type === "sum") {
1003
- const values = evaluateNumericOperands(
1004
- expression.expressions,
1005
- document,
1006
- responses,
1007
- outcomes,
1008
- templateValues,
1009
- correctResponses,
1010
- random,
1011
- customOperators,
1012
- );
1013
- return values ? values.reduce((sum, value) => sum + value, 0) : null;
1014
- }
1015
- if (expression.type === "product") {
1016
- const values = evaluateNumericOperands(
1017
- expression.expressions,
1018
- document,
1019
- responses,
1020
- outcomes,
1021
- templateValues,
1022
- correctResponses,
1023
- random,
1024
- customOperators,
1025
- );
1026
- return values ? values.reduce((product, value) => product * value, 1) : null;
1027
- }
1028
- if (expression.type === "min" || expression.type === "max") {
1029
- const values = evaluateNumericOperands(
1030
- expression.expressions,
1031
- document,
1032
- responses,
1033
- outcomes,
1034
- templateValues,
1035
- correctResponses,
1036
- random,
1037
- customOperators,
1038
- );
1039
- if (!values || values.length === 0) return null;
1040
- return expression.type === "min" ? Math.min(...values) : Math.max(...values);
1041
- }
1042
- if (expression.type === "subtract") {
1043
- const values = evaluateNumericOperands(
1044
- [expression.left, expression.right],
1045
- document,
1046
- responses,
1047
- outcomes,
1048
- templateValues,
1049
- correctResponses,
1050
- random,
1051
- customOperators,
1052
- );
1053
- return values && values.length === 2 ? values[0]! - values[1]! : null;
1054
- }
1055
- if (expression.type === "divide") {
1056
- const dividendValue = evaluateValue(
1057
- expression.left,
1058
- document,
1059
- responses,
1060
- outcomes,
1061
- templateValues,
1062
- correctResponses,
1063
- random,
1064
- customOperators,
1065
- );
1066
- const divisorValue = evaluateValue(
1067
- expression.right,
1068
- document,
1069
- responses,
1070
- outcomes,
1071
- templateValues,
1072
- correctResponses,
1073
- random,
1074
- customOperators,
1075
- );
1076
- if (dividendValue === null || divisorValue === null) return null;
1077
- const dividend = numericValueOrNull(dividendValue);
1078
- const divisor = numericValueOrNull(divisorValue);
1079
- if (dividend === null || divisor === null || divisor === 0) return null;
1080
- const quotient = dividend / divisor;
1081
- return Number.isFinite(quotient) ? quotient : null;
1082
- }
1083
- if (expression.type === "power") {
1084
- const values = evaluateNumericOperands(
1085
- [expression.left, expression.right],
1086
- document,
1087
- responses,
1088
- outcomes,
1089
- templateValues,
1090
- correctResponses,
1091
- random,
1092
- customOperators,
1093
- );
1094
- if (!values || values.length !== 2) return null;
1095
- const value = Math.pow(values[0]!, values[1]!);
1096
- return Number.isFinite(value) ? value : null;
1097
- }
1098
- if (expression.type === "integerDivide") {
1099
- const dividendValue = evaluateValue(
1100
- expression.left,
1101
- document,
1102
- responses,
1103
- outcomes,
1104
- templateValues,
1105
- correctResponses,
1106
- random,
1107
- customOperators,
1108
- );
1109
- const divisorValue = evaluateValue(
1110
- expression.right,
1111
- document,
1112
- responses,
1113
- outcomes,
1114
- templateValues,
1115
- correctResponses,
1116
- random,
1117
- customOperators,
1118
- );
1119
- if (dividendValue === null || divisorValue === null) return null;
1120
- const dividend = numericValueOrNull(dividendValue);
1121
- const divisor = numericValueOrNull(divisorValue);
1122
- if (dividend === null || divisor === null || divisor === 0) return null;
1123
- return Math.floor(dividend / divisor);
1124
- }
1125
- if (expression.type === "integerModulus") {
1126
- const dividendValue = evaluateValue(
1127
- expression.left,
1128
- document,
1129
- responses,
1130
- outcomes,
1131
- templateValues,
1132
- correctResponses,
1133
- random,
1134
- customOperators,
1135
- );
1136
- const divisorValue = evaluateValue(
1137
- expression.right,
1138
- document,
1139
- responses,
1140
- outcomes,
1141
- templateValues,
1142
- correctResponses,
1143
- random,
1144
- customOperators,
1145
- );
1146
- if (dividendValue === null || divisorValue === null) return null;
1147
- const dividend = numericValueOrNull(dividendValue);
1148
- const divisor = numericValueOrNull(divisorValue);
1149
- if (dividend === null || divisor === null || divisor === 0) return null;
1150
- return dividend - Math.floor(dividend / divisor) * divisor;
1151
- }
1152
- if (expression.type === "round") {
1153
- const value = numericValueOrNull(
1154
- evaluateValue(
1155
- expression.expression,
1156
- document,
1157
- responses,
1158
- outcomes,
1159
- templateValues,
1160
- correctResponses,
1161
- random,
1162
- customOperators,
1163
- ),
1164
- );
1165
- return value === null ? null : Math.round(value);
1166
- }
1167
- if (expression.type === "roundTo") {
1168
- const value = numericValueOrNull(
1169
- evaluateValue(
1170
- expression.expression,
1171
- document,
1172
- responses,
1173
- outcomes,
1174
- templateValues,
1175
- correctResponses,
1176
- random,
1177
- customOperators,
1178
- ),
1179
- );
1180
- if (value === null) return null;
1181
- return expression.roundingMode === "decimalPlaces"
1182
- ? roundToDecimalPlaces(value, expression.figures)
1183
- : roundToSignificantFigures(value, expression.figures);
1184
- }
1185
- if (expression.type === "truncate") {
1186
- const value = numericValueOrNull(
1187
- evaluateValue(
1188
- expression.expression,
1189
- document,
1190
- responses,
1191
- outcomes,
1192
- templateValues,
1193
- correctResponses,
1194
- random,
1195
- customOperators,
1196
- ),
1197
- );
1198
- return value === null ? null : Math.trunc(value);
1199
- }
1200
- if (expression.type === "integerToFloat") {
1201
- const value = evaluateValue(
1202
- expression.expression,
1203
- document,
1204
- responses,
1205
- outcomes,
1206
- templateValues,
1207
- correctResponses,
1208
- random,
1209
- customOperators,
1210
- );
1211
- return numericValueOrNull(value);
1212
- }
1213
- if (expression.type === "and") {
1214
- let sawNull = false;
1215
- for (const item of expression.expressions) {
1216
- const value = booleanValueOrNull(
1217
- evaluateValue(
1218
- item,
1219
- document,
1220
- responses,
1221
- outcomes,
1222
- templateValues,
1223
- correctResponses,
1224
- random,
1225
- customOperators,
1226
- ),
1227
- );
1228
- if (value === false) return false;
1229
- if (value === null) sawNull = true;
1230
- }
1231
- return sawNull ? null : true;
1232
- }
1233
- if (expression.type === "anyN") {
1234
- const min = indexValue(expression.min, outcomes, templateValues) ?? 0;
1235
- const max = indexValue(expression.max, outcomes, templateValues) ?? 0;
1236
- const values = expression.expressions.map((item) =>
1237
- evaluateValue(
1238
- item,
1239
- document,
1240
- responses,
1241
- outcomes,
1242
- templateValues,
1243
- correctResponses,
1244
- random,
1245
- customOperators,
1246
- ),
1247
- );
1248
- const trueCount = values.filter((value) => value === true).length;
1249
- const nullCount = values.filter((value) => value === null).length;
1250
- if (min > max || trueCount > max || trueCount + nullCount < min) return false;
1251
- if (trueCount >= min && trueCount <= max) return true;
1252
- return null;
1253
- }
1254
- if (expression.type === "or") {
1255
- let sawNull = false;
1256
- for (const item of expression.expressions) {
1257
- const value = booleanValueOrNull(
1258
- evaluateValue(
1259
- item,
1260
- document,
1261
- responses,
1262
- outcomes,
1263
- templateValues,
1264
- correctResponses,
1265
- random,
1266
- customOperators,
1267
- ),
1268
- );
1269
- if (value === true) return true;
1270
- if (value === null) sawNull = true;
1271
- }
1272
- return sawNull ? null : false;
1273
- }
1274
- if (expression.type === "not") {
1275
- const value = booleanValueOrNull(
1276
- evaluateValue(
1277
- expression.expression,
1278
- document,
1279
- responses,
1280
- outcomes,
1281
- templateValues,
1282
- correctResponses,
1283
- random,
1284
- customOperators,
1285
- ),
1286
- );
1287
- return value === null ? null : !value;
1288
- }
1289
- if (expression.type === "equal") {
1290
- const left = evaluateValue(
1291
- expression.left,
1292
- document,
1293
- responses,
1294
- outcomes,
1295
- templateValues,
1296
- correctResponses,
1297
- random,
1298
- customOperators,
1299
- );
1300
- const right = evaluateValue(
1301
- expression.right,
1302
- document,
1303
- responses,
1304
- outcomes,
1305
- templateValues,
1306
- correctResponses,
1307
- random,
1308
- customOperators,
1309
- );
1310
- return left === null || right === null ? null : valuesEqual(left, right);
1311
- }
1312
- if (expression.type === "equalRounded") {
1313
- const left = evaluateValue(
1314
- expression.left,
1315
- document,
1316
- responses,
1317
- outcomes,
1318
- templateValues,
1319
- correctResponses,
1320
- random,
1321
- customOperators,
1322
- );
1323
- const right = evaluateValue(
1324
- expression.right,
1325
- document,
1326
- responses,
1327
- outcomes,
1328
- templateValues,
1329
- correctResponses,
1330
- random,
1331
- customOperators,
1332
- );
1333
- if (left === null || right === null) return null;
1334
- const leftNumber = numericValueOrNull(left);
1335
- const rightNumber = numericValueOrNull(right);
1336
- if (leftNumber === null || rightNumber === null) return null;
1337
- const roundedLeft = roundWithMode(leftNumber, expression.roundingMode, expression.figures);
1338
- const roundedRight = roundWithMode(rightNumber, expression.roundingMode, expression.figures);
1339
- return roundedLeft === null || roundedRight === null ? null : roundedLeft === roundedRight;
1340
- }
1341
- if (expression.type === "numericCompare") {
1342
- const leftValue = evaluateValue(
1343
- expression.left,
1344
- document,
1345
- responses,
1346
- outcomes,
1347
- templateValues,
1348
- correctResponses,
1349
- random,
1350
- customOperators,
1351
- );
1352
- const rightValue = evaluateValue(
1353
- expression.right,
1354
- document,
1355
- responses,
1356
- outcomes,
1357
- templateValues,
1358
- correctResponses,
1359
- random,
1360
- customOperators,
1361
- );
1362
- if (leftValue === null || rightValue === null) return null;
1363
- const left = numericValueOrNull(leftValue);
1364
- const right = numericValueOrNull(rightValue);
1365
- if (left === null || right === null) return null;
1366
- if (expression.operator === "lt") return left < right;
1367
- if (expression.operator === "lte") return left <= right;
1368
- if (expression.operator === "gt") return left > right;
1369
- return left >= right;
1370
- }
1371
- if (expression.type === "durationCompare") {
1372
- const leftValue = evaluateValue(
1373
- expression.left,
1374
- document,
1375
- responses,
1376
- outcomes,
1377
- templateValues,
1378
- correctResponses,
1379
- random,
1380
- customOperators,
1381
- );
1382
- const rightValue = evaluateValue(
1383
- expression.right,
1384
- document,
1385
- responses,
1386
- outcomes,
1387
- templateValues,
1388
- correctResponses,
1389
- random,
1390
- customOperators,
1391
- );
1392
- const left = durationSeconds(leftValue);
1393
- const right = durationSeconds(rightValue);
1394
- if (left === null || right === null) return null;
1395
- return expression.operator === "lt" ? left < right : left >= right;
1396
- }
1397
- if (expression.type === "stringMatch") {
1398
- return stringMatch(
1399
- evaluateValue(
1400
- expression.left,
1401
- document,
1402
- responses,
1403
- outcomes,
1404
- templateValues,
1405
- correctResponses,
1406
- random,
1407
- customOperators,
1408
- ),
1409
- evaluateValue(
1410
- expression.right,
1411
- document,
1412
- responses,
1413
- outcomes,
1414
- templateValues,
1415
- correctResponses,
1416
- random,
1417
- customOperators,
1418
- ),
1419
- expression.caseSensitive,
1420
- expression.substring,
1421
- );
1422
- }
1423
- if (expression.type === "substring") {
1424
- return stringMatch(
1425
- evaluateValue(
1426
- expression.right,
1427
- document,
1428
- responses,
1429
- outcomes,
1430
- templateValues,
1431
- correctResponses,
1432
- random,
1433
- customOperators,
1434
- ),
1435
- evaluateValue(
1436
- expression.left,
1437
- document,
1438
- responses,
1439
- outcomes,
1440
- templateValues,
1441
- correctResponses,
1442
- random,
1443
- customOperators,
1444
- ),
1445
- expression.caseSensitive,
1446
- true,
1447
- );
1448
- }
1449
- if (expression.type === "patternMatch") {
1450
- const value = evaluateValue(
1451
- expression.expression,
1452
- document,
1453
- responses,
1454
- outcomes,
1455
- templateValues,
1456
- correctResponses,
1457
- random,
1458
- customOperators,
1459
- );
1460
- if (value === null) return null;
1461
- const patternValue =
1462
- responses[expression.pattern] ??
1463
- outcomes[expression.pattern] ??
1464
- templateValues[expression.pattern] ??
1465
- expression.pattern;
1466
- try {
1467
- return new RegExp(
1468
- typeof patternValue === "string" ? patternValue : qtiValueToString(patternValue),
1469
- ).test(qtiValueToString(value));
1470
- } catch {
1471
- return null;
1472
- }
1473
- }
1474
- if (expression.type === "fieldValue") {
1475
- const value = evaluateValue(
1476
- expression.expression,
1477
- document,
1478
- responses,
1479
- outcomes,
1480
- templateValues,
1481
- correctResponses,
1482
- random,
1483
- customOperators,
1484
- );
1485
- return isRecordValue(value) ? (value[expression.fieldIdentifier] ?? null) : null;
1486
- }
1487
- if (expression.type === "member") {
1488
- const value = evaluateValue(
1489
- expression.value,
1490
- document,
1491
- responses,
1492
- outcomes,
1493
- templateValues,
1494
- correctResponses,
1495
- random,
1496
- customOperators,
1497
- );
1498
- const collection = evaluateValue(
1499
- expression.collection,
1500
- document,
1501
- responses,
1502
- outcomes,
1503
- templateValues,
1504
- correctResponses,
1505
- random,
1506
- customOperators,
1507
- );
1508
- if (value === null || collection === null) return null;
1509
- const values = valueContainer(collection);
1510
- return values.some((item) => valuesEqual(item, value));
1511
- }
1512
- if (expression.type === "delete") {
1513
- const value = evaluateValue(
1514
- expression.value,
1515
- document,
1516
- responses,
1517
- outcomes,
1518
- templateValues,
1519
- correctResponses,
1520
- random,
1521
- customOperators,
1522
- );
1523
- const collectionValue = evaluateValue(
1524
- expression.collection,
1525
- document,
1526
- responses,
1527
- outcomes,
1528
- templateValues,
1529
- correctResponses,
1530
- random,
1531
- customOperators,
1532
- );
1533
- if (value === null || collectionValue === null) return null;
1534
- const collection = valueContainer(collectionValue);
1535
- if (collection.length === 0) return null;
1536
- const filtered = collection.filter((item) => !valuesEqual(item, value));
1537
- return filtered.length > 0 ? filtered : null;
1538
- }
1539
- if (expression.type === "contains") {
1540
- const collectionValue = evaluateValue(
1541
- expression.collection,
1542
- document,
1543
- responses,
1544
- outcomes,
1545
- templateValues,
1546
- correctResponses,
1547
- random,
1548
- customOperators,
1549
- );
1550
- const valuesValue = evaluateValue(
1551
- expression.values,
1552
- document,
1553
- responses,
1554
- outcomes,
1555
- templateValues,
1556
- correctResponses,
1557
- random,
1558
- customOperators,
1559
- );
1560
- if (collectionValue === null || valuesValue === null) return null;
1561
- const collection = valueContainer(collectionValue);
1562
- const values = valueContainer(valuesValue);
1563
- if (collection.length === 0 || values.length === 0) return null;
1564
- return containsValues(collection, values);
1565
- }
1566
- if (expression.type === "gcd" || expression.type === "lcm") {
1567
- const values = expression.expressions.flatMap((item) => {
1568
- const value = evaluateValue(
1569
- item,
1570
- document,
1571
- responses,
1572
- outcomes,
1573
- templateValues,
1574
- correctResponses,
1575
- random,
1576
- customOperators,
1577
- );
1578
- return value === null ? [null] : valueContainer(value);
1579
- });
1580
- if (values.length === 0 || values.some((value) => value === null)) return null;
1581
- const integers = values.map((value) => Math.trunc(numericValue(value)));
1582
- return expression.type === "gcd" ? generalizedGcd(integers) : generalizedLcm(integers);
1583
- }
1584
- if (expression.type === "inside") {
1585
- const value = evaluateValue(
1586
- expression.expression,
1587
- document,
1588
- responses,
1589
- outcomes,
1590
- templateValues,
1591
- correctResponses,
1592
- random,
1593
- customOperators,
1594
- );
1595
- if (value === null) return null;
1596
- if (expression.shape === "default") return true;
1597
- return valueContainer(value).some((pointValue) => {
1598
- const point = parsePoint(String(pointValue));
1599
- return point
1600
- ? pointInsideArea(point, {
1601
- shape: expression.shape,
1602
- coords: expression.coords,
1603
- mappedValue: 0,
1604
- attributes: {},
1605
- })
1606
- : false;
1607
- });
1608
- }
1609
- if (expression.type === "mathConstant") {
1610
- if (expression.name === "pi") return Math.PI;
1611
- if (expression.name === "e") return Math.E;
1612
- return null;
1613
- }
1614
- if (expression.type === "mathOperator") {
1615
- const values = expression.expressions.map((item) =>
1616
- evaluateValue(
1617
- item,
1618
- document,
1619
- responses,
1620
- outcomes,
1621
- templateValues,
1622
- correctResponses,
1623
- random,
1624
- customOperators,
1625
- ),
1626
- );
1627
- if (values.length === 0 || values.some((value) => value === null)) return null;
1628
- return mathOperatorValue(expression.name, values.map(numericValue));
1629
- }
1630
- if (expression.type === "repeat") {
1631
- const repeats = indexValue(expression.numberRepeats, outcomes, templateValues);
1632
- if (repeats === undefined || repeats < 1) return null;
1633
- const container: QtiScalarValue[] = [];
1634
- for (let repeat = 0; repeat < repeats; repeat += 1) {
1635
- for (const item of expression.expressions) {
1636
- const value = evaluateValue(
1637
- item,
1638
- document,
1639
- responses,
1640
- outcomes,
1641
- templateValues,
1642
- correctResponses,
1643
- random,
1644
- customOperators,
1645
- );
1646
- container.push(...valueContainer(value));
1647
- }
1648
- }
1649
- return container.length > 0 ? container : null;
1650
- }
1651
- if (expression.type === "statsOperator") {
1652
- const value = evaluateValue(
1653
- expression.expression,
1654
- document,
1655
- responses,
1656
- outcomes,
1657
- templateValues,
1658
- correctResponses,
1659
- random,
1660
- customOperators,
1661
- );
1662
- if (value === null) return null;
1663
- const values = valueContainer(value).map(numericValue);
1664
- return statsOperatorValue(expression.name, values);
1665
- }
1666
- if (expression.type === "customOperator") {
1667
- const operatorKey = expression.definition ?? expression.className ?? "";
1668
- const handler = customOperators[operatorKey];
1669
- if (!handler) return null;
1670
- return handler({
1671
- definition: expression.definition,
1672
- className: expression.className,
1673
- attributes: expression.attributes,
1674
- values: expression.expressions.map((item) =>
1675
- evaluateValue(
1676
- item,
1677
- document,
1678
- responses,
1679
- outcomes,
1680
- templateValues,
1681
- correctResponses,
1682
- random,
1683
- customOperators,
1684
- ),
1685
- ),
1686
- expression,
1687
- });
1688
- }
1689
- return null;
1690
- }
1691
-
1692
- function getResponseDeclaration(
1693
- document: QtiDocument,
1694
- identifier: string,
1695
- ): QtiResponseDeclaration | undefined {
1696
- return document.item.responseDeclarations.find(
1697
- (declaration) => declaration.identifier === identifier,
1698
- );
1699
- }
1700
-
1701
- function expressionIsOrdered(expression: QtiProcessingExpression, document: QtiDocument): boolean {
1702
- if (expression.type === "ordered") return true;
1703
- if (
1704
- (expression.type === "variable" ||
1705
- expression.type === "correct" ||
1706
- expression.type === "default" ||
1707
- expression.type === "isNull") &&
1708
- variableCardinality(document, expression.identifier) === "ordered"
1709
- ) {
1710
- return true;
1711
- }
1712
- return false;
1713
- }
1714
-
1715
- function variableCardinality(document: QtiDocument, identifier: string): string | undefined {
1716
- return (
1717
- document.item.responseDeclarations.find((declaration) => declaration.identifier === identifier)
1718
- ?.cardinality ??
1719
- document.item.outcomeDeclarations.find((declaration) => declaration.identifier === identifier)
1720
- ?.cardinality ??
1721
- document.item.templateDeclarations.find((declaration) => declaration.identifier === identifier)
1722
- ?.cardinality
1723
- );
1724
- }
1725
-
1726
- function defaultValueForIdentifier(document: QtiDocument, identifier: string): QtiValue {
1727
- return (
1728
- document.item.responseDeclarations.find((declaration) => declaration.identifier === identifier)
1729
- ?.defaultValue ??
1730
- document.item.outcomeDeclarations.find((declaration) => declaration.identifier === identifier)
1731
- ?.defaultValue ??
1732
- document.item.templateDeclarations.find((declaration) => declaration.identifier === identifier)
1733
- ?.defaultValue ??
1734
- null
1735
- );
1736
- }
1737
-
1738
- function lookupOutcomeValue(document: QtiDocument, identifier: string, value: QtiValue): QtiValue {
1739
- const declaration = document.item.outcomeDeclarations.find(
1740
- (outcome) => outcome.identifier === identifier,
1741
- );
1742
- const lookupTable = declaration?.lookupTable;
1743
- if (!lookupTable) return null;
1744
- if (value === null) return lookupTable.defaultValue;
1745
- const numeric = numericValue(value);
1746
- if (lookupTable.type === "match") {
1747
- return (
1748
- lookupTable.entries.find((entry) => entry.sourceValue === numeric)?.targetValue ??
1749
- lookupTable.defaultValue
1750
- );
1751
- }
1752
- const entry = [...lookupTable.entries]
1753
- .sort((left, right) => left.sourceValue - right.sourceValue)
1754
- .find(
1755
- (candidate) =>
1756
- numeric < candidate.sourceValue ||
1757
- (candidate.includeBoundary !== false && numeric === candidate.sourceValue),
1758
- );
1759
- return entry?.targetValue ?? lookupTable.defaultValue;
1760
- }
1761
-
1762
- function mapOrMatchResponse(
1763
- declaration: QtiResponseDeclaration,
1764
- response: QtiValue,
1765
- correctResponse: QtiValue,
1766
- ): number {
1767
- if (declaration.areaMapping) return scoreAreaMapping(response, declaration.areaMapping);
1768
- if (declaration.mapping) return scoreMapping(response, declaration.mapping);
1769
- return valuesEqual(response, correctResponse, declaration.cardinality === "ordered") ? 1 : 0;
1770
- }
1771
-
1772
- function scoreAreaMapping(
1773
- response: QtiValue,
1774
- areaMapping: NonNullable<QtiResponseDeclaration["areaMapping"]>,
1775
- ): number {
1776
- const points = Array.isArray(response)
1777
- ? response.map(qtiScalarToString)
1778
- : response === null
1779
- ? []
1780
- : qtiValueToStringList(response);
1781
- let score = 0;
1782
- for (const point of points) {
1783
- const parsed = parsePoint(String(point));
1784
- if (!parsed) {
1785
- score += areaMapping.defaultValue;
1786
- continue;
1787
- }
1788
- const entry = areaMapping.entries.find((candidate) => pointInsideArea(parsed, candidate));
1789
- score += entry?.mappedValue ?? areaMapping.defaultValue;
1790
- }
1791
- return clampMappedScore(score, areaMapping.attributes);
1792
- }
1793
-
1794
- function parsePoint(value: string): { x: number; y: number } | undefined {
1795
- const [x, y] = value
1796
- .trim()
1797
- .split(/[,\s]+/)
1798
- .map((part) => Number(part));
1799
- if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) {
1800
- return undefined;
1801
- }
1802
- return { x, y };
1803
- }
1804
-
1805
- function pointInsideArea(
1806
- point: { x: number; y: number },
1807
- entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
1808
449
  ): boolean {
1809
- if (entry.shape === "circle") {
1810
- const [cx, cy, radius] = entry.coords;
1811
- if (cx === undefined || cy === undefined || radius === undefined) return false;
1812
- return Math.hypot(point.x - cx, point.y - cy) <= radius;
1813
- }
1814
-
1815
- if (entry.shape === "rect") {
1816
- const [left, top, right, bottom] = entry.coords;
1817
- if (left === undefined || top === undefined || right === undefined || bottom === undefined) {
1818
- return false;
1819
- }
1820
- return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
1821
- }
1822
-
1823
- if (entry.shape === "poly") {
1824
- return pointInsidePolygon(point, entry.coords);
1825
- }
1826
-
1827
- return false;
1828
- }
1829
-
1830
- function pointInsidePolygon(point: { x: number; y: number }, coords: number[]): boolean {
1831
- if (coords.length < 6 || coords.length % 2 !== 0) return false;
1832
- let inside = false;
1833
- for (let index = 0, previous = coords.length - 2; index < coords.length; index += 2) {
1834
- const xi = coords[index]!;
1835
- const yi = coords[index + 1]!;
1836
- const xj = coords[previous]!;
1837
- const yj = coords[previous + 1]!;
1838
- const intersects =
1839
- yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
1840
- if (intersects) inside = !inside;
1841
- previous = index;
1842
- }
1843
- return inside;
1844
- }
1845
-
1846
- function isNullResponse(response: QtiValue): boolean {
1847
- return response === null || response === "" || (Array.isArray(response) && response.length === 0);
1848
- }
1849
-
1850
- function serialize(
1851
- itemIdentifier: string,
1852
- status: QtiAttemptStatus,
1853
- responses: Record<string, QtiValue>,
1854
- outcomes: Record<string, QtiValue>,
1855
- templateValues: Record<string, QtiValue>,
1856
- interactionStates: Record<string, QtiPortableCustomStateValue>,
1857
- validationMessages: QtiDiagnostic[],
1858
- ): QtiAttemptStateV1 {
1859
- return {
1860
- schema: ATTEMPT_STATE_SCHEMA,
1861
- itemIdentifier,
1862
- status,
1863
- responses: cloneValueRecord(responses),
1864
- outcomes: cloneValueRecord(outcomes),
1865
- templateValues: cloneValueRecord(templateValues),
1866
- interactionStates: clonePortableCustomStateRecord(interactionStates),
1867
- validationMessages: cloneDiagnostics(validationMessages),
1868
- };
1869
- }
1870
-
1871
- function cloneValueRecord(record: Record<string, QtiValue>): Record<string, QtiValue> {
1872
- return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, cloneValue(value)]));
1873
- }
1874
-
1875
- function clonePortableCustomStateRecord(
1876
- record: Record<string, QtiPortableCustomStateValue>,
1877
- ): Record<string, QtiPortableCustomStateValue> {
1878
- return Object.fromEntries(
1879
- Object.entries(record).map(([key, value]) => [key, clonePortableCustomState(value)]),
1880
- );
1881
- }
1882
-
1883
- function cloneValue(value: QtiValue): QtiValue {
1884
- if (Array.isArray(value)) return [...value];
1885
- if (isRecordValue(value)) return cloneValueRecord(value);
1886
- return value;
1887
- }
1888
-
1889
- function clonePortableCustomState(value: QtiPortableCustomStateValue): QtiPortableCustomStateValue {
1890
- if (Array.isArray(value)) return value.map(clonePortableCustomState);
1891
- if (isPortableCustomStateObject(value)) return clonePortableCustomStateRecord(value);
1892
- return value;
1893
- }
1894
-
1895
- function cloneDiagnostics(diagnostics: QtiDiagnostic[]): QtiDiagnostic[] {
1896
- return diagnostics.map((diagnostic) => ({
1897
- ...diagnostic,
1898
- source: diagnostic.source ? { ...diagnostic.source } : undefined,
1899
- }));
1900
- }
1901
-
1902
- function scoreMapping(
1903
- response: QtiValue,
1904
- mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
1905
- ): number {
1906
- const values = Object.fromEntries(
1907
- mapping.entries
1908
- .filter((entry) => entry.mapKey !== undefined)
1909
- .map((entry) => [entry.mapKey!, entry.mappedValue]),
1910
- );
1911
- if (Array.isArray(response)) {
1912
- const score = response.reduce<number>(
1913
- (sum, value) => sum + (values[String(value)] ?? mapping.defaultValue),
1914
- 0,
1915
- );
1916
- return clampMappedScore(score, mapping.attributes);
1917
- }
1918
- const score =
1919
- response === null || isRecordValue(response)
1920
- ? 0
1921
- : (values[String(response)] ?? mapping.defaultValue);
1922
- return clampMappedScore(score, mapping.attributes);
1923
- }
1924
-
1925
- function clampMappedScore(score: number, attributes: Record<string, string>): number {
1926
- const lower = numericBound(attributes["lower-bound"]);
1927
- const upper = numericBound(attributes["upper-bound"]);
1928
- let clamped = score;
1929
- if (lower !== undefined) clamped = Math.max(clamped, lower);
1930
- if (upper !== undefined) clamped = Math.min(clamped, upper);
1931
- return clamped;
1932
- }
1933
-
1934
- function numericBound(value: string | undefined): number | undefined {
1935
- if (value === undefined) return undefined;
1936
- const numberValue = Number(value);
1937
- return Number.isFinite(numberValue) ? numberValue : undefined;
1938
- }
1939
-
1940
- function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boolean {
1941
- if (isRecordValue(actual) || isRecordValue(expected)) {
1942
- if (!isRecordValue(actual) || !isRecordValue(expected)) return false;
1943
- const actualKeys = Object.keys(actual).sort();
1944
- const expectedKeys = Object.keys(expected).sort();
1945
- return (
1946
- valuesEqual(actualKeys, expectedKeys, true) &&
1947
- actualKeys.every((key) => valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered))
1948
- );
1949
- }
1950
- if (Array.isArray(actual) || Array.isArray(expected)) {
1951
- const actualValues = valueContainer(actual);
1952
- const expectedValues = Array.isArray(expected) ? expected : expected === null ? [] : [expected];
1953
- if (actualValues.length !== expectedValues.length) return false;
1954
- if (ordered)
1955
- return actualValues.every((value, index) => scalarValuesEqual(value, expectedValues[index]!));
1956
- const sortedExpected = [...expectedValues].sort(compareScalarValues);
1957
- return [...actualValues]
1958
- .sort(compareScalarValues)
1959
- .every((value, index) => scalarValuesEqual(value, sortedExpected[index]!));
1960
- }
1961
- return scalarValuesEqual(actual, expected);
1962
- }
1963
-
1964
- function qtiMatchValues(actual: QtiValue, expected: QtiValue, ordered = false): boolean | null {
1965
- if (actual === null || expected === null) return null;
1966
- return valuesEqual(actual, expected, ordered);
1967
- }
1968
-
1969
- function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
1970
- if (typeof actual === "boolean" && typeof expected === "string") {
1971
- return String(actual) === expected;
1972
- }
1973
- if (typeof actual === "string" && typeof expected === "boolean") {
1974
- return actual === String(expected);
1975
- }
1976
- return actual === expected;
1977
- }
1978
-
1979
- function normalizeValueForCardinality(
1980
- value: QtiValue,
1981
- cardinality: QtiResponseDeclaration["cardinality"],
1982
- ): QtiValue {
1983
- if (
1984
- (cardinality === "multiple" || cardinality === "ordered") &&
1985
- value !== null &&
1986
- !Array.isArray(value) &&
1987
- !isRecordValue(value)
1988
- ) {
1989
- return [value];
1990
- }
1991
- return value;
1992
- }
1993
-
1994
- function valueContainer(value: QtiValue): QtiScalarValue[] {
1995
- if (value === null) return [];
1996
- if (isRecordValue(value)) return [];
1997
- return Array.isArray(value) ? value.filter((item) => item !== null) : [value];
1998
- }
1999
-
2000
- function isRecordValue(value: QtiValue): value is QtiRecordValue {
2001
- return typeof value === "object" && value !== null && !Array.isArray(value);
2002
- }
2003
-
2004
- function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
2005
- if (!isRecord(value)) return false;
2006
- return Object.values(value).every(isQtiValue);
2007
- }
2008
-
2009
- function isPortableCustomState(value: unknown): value is QtiPortableCustomStateValue {
2010
- return isQtiPortableCustomStateValue(value);
2011
- }
2012
-
2013
- function isPortableCustomStateObject(
2014
- value: QtiPortableCustomStateValue,
2015
- ): value is { [key: string]: QtiPortableCustomStateValue } {
2016
- return typeof value === "object" && value !== null && !Array.isArray(value);
2017
- }
2018
-
2019
- function isPortableCustomStateRecord(
2020
- value: unknown,
2021
- ): value is Record<string, QtiPortableCustomStateValue> {
2022
- if (!isRecord(value)) return false;
2023
- return Object.values(value).every(isPortableCustomState);
2024
- }
2025
-
2026
- function isDiagnosticArray(value: unknown): value is QtiDiagnostic[] {
2027
- if (!Array.isArray(value)) return false;
2028
- return value.every((item) => {
2029
- if (!isRecord(item)) return false;
2030
- if (typeof item.code !== "string" || item.code.length === 0) return false;
2031
- if (item.severity !== "info" && item.severity !== "warning" && item.severity !== "error") {
2032
- return false;
2033
- }
2034
- if (typeof item.message !== "string" || item.message.length === 0) return false;
2035
- return item.path === undefined || typeof item.path === "string";
2036
- });
2037
- }
2038
-
2039
- function isRecord(value: unknown): value is Record<string, unknown> {
2040
- return typeof value === "object" && value !== null && !Array.isArray(value);
2041
- }
2042
-
2043
- function indexValue(
2044
- n: string,
2045
- outcomes: Record<string, QtiValue>,
2046
- templateValues: Record<string, QtiValue>,
2047
- ): number | undefined {
2048
- const parsed = Number(n);
2049
- if (Number.isInteger(parsed)) return parsed;
2050
- const value = outcomes[n] ?? templateValues[n] ?? null;
2051
- const numeric = numericValueOrNull(value);
2052
- return numeric !== null && Number.isInteger(numeric) ? numeric : undefined;
2053
- }
2054
-
2055
- function evaluateNumericOperands(
2056
- expressions: QtiProcessingExpression[],
2057
- document: QtiDocument,
2058
- responses: Record<string, QtiValue>,
2059
- outcomes: Record<string, QtiValue>,
2060
- templateValues: Record<string, QtiValue>,
2061
- correctResponses: Record<string, QtiValue>,
2062
- random: () => number,
2063
- customOperators: QtiCustomOperatorRegistry,
2064
- ): number[] | null {
2065
- const numericValues: number[] = [];
2066
- for (const expression of expressions) {
2067
- const value = evaluateValue(
2068
- expression,
2069
- document,
2070
- responses,
2071
- outcomes,
2072
- templateValues,
2073
- correctResponses,
2074
- random,
2075
- customOperators,
2076
- );
2077
- if (value === null || isRecordValue(value)) return null;
2078
- const values = Array.isArray(value) ? value : [value];
2079
- for (const item of values) {
2080
- const numeric = numericValueOrNull(item);
2081
- if (numeric === null) return null;
2082
- numericValues.push(numeric);
2083
- }
2084
- }
2085
- return numericValues;
2086
- }
2087
-
2088
- function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
2089
- const remaining = [...collection];
2090
- for (const value of values) {
2091
- const index = remaining.findIndex((candidate) => valuesEqual(candidate, value));
2092
- if (index === -1) return false;
2093
- remaining.splice(index, 1);
2094
- }
2095
- return true;
2096
- }
2097
-
2098
- function compareScalarValues(left: QtiScalarValue, right: QtiScalarValue): number {
2099
- return String(left).localeCompare(String(right));
2100
- }
2101
-
2102
- function numericValue(value: QtiValue): number {
2103
- if (typeof value === "number") return value;
2104
- if (typeof value === "boolean") return value ? 1 : 0;
2105
- if (typeof value === "string") return Number(value);
2106
- return 0;
2107
- }
2108
-
2109
- function numericValueOrNull(value: QtiValue): number | null {
2110
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
2111
- if (typeof value === "boolean") return value ? 1 : 0;
2112
- if (typeof value === "string") {
2113
- const parsed = Number(value);
2114
- return Number.isFinite(parsed) ? parsed : null;
2115
- }
2116
- return null;
2117
- }
2118
-
2119
- function durationSeconds(value: QtiValue): number | null {
2120
- if (value === null || Array.isArray(value) || isRecordValue(value)) return null;
2121
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
2122
- const raw = String(value).trim();
2123
- if (raw.length === 0) return null;
2124
- const numeric = Number(raw);
2125
- if (Number.isFinite(numeric)) return numeric;
2126
- const match =
2127
- /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(
2128
- raw,
2129
- );
2130
- if (!match) return null;
2131
- const [, days, hours, minutes, seconds] = match;
2132
- if (!days && !hours && !minutes && !seconds) return null;
2133
- const total =
2134
- Number(days ?? 0) * 86_400 +
2135
- Number(hours ?? 0) * 3_600 +
2136
- Number(minutes ?? 0) * 60 +
2137
- Number(seconds ?? 0);
2138
- return Number.isFinite(total) ? total : null;
2139
- }
2140
-
2141
- function booleanValue(value: QtiValue): boolean {
2142
- if (typeof value === "boolean") return value;
2143
- if (typeof value === "number") return value !== 0;
2144
- if (typeof value === "string") return value.length > 0 && value !== "false";
2145
- if (Array.isArray(value)) return value.length > 0;
2146
- return false;
2147
- }
2148
-
2149
- function booleanValueOrNull(value: QtiValue): boolean | null {
2150
- if (value === null) return null;
2151
- return booleanValue(value);
2152
- }
2153
-
2154
- function roundToDecimalPlaces(value: number, figures: number): number {
2155
- const factor = 10 ** figures;
2156
- return Math.round(value * factor) / factor;
2157
- }
2158
-
2159
- function roundToSignificantFigures(value: number, figures: number): number {
2160
- if (value === 0 || figures <= 0) return 0;
2161
- const factor = 10 ** (figures - 1 - Math.floor(Math.log10(Math.abs(value))));
2162
- return Math.round(value * factor) / factor;
2163
- }
2164
-
2165
- function roundWithMode(value: number, roundingMode: string, figures: number): number | null {
2166
- if (!Number.isFinite(value)) return null;
2167
- if (roundingMode === "decimalPlaces") return roundToDecimalPlaces(value, figures);
2168
- if (roundingMode === "significantFigures") return roundToSignificantFigures(value, figures);
2169
- return null;
2170
- }
2171
-
2172
- function generalizedGcd(values: number[]): number {
2173
- let result = 0;
2174
- for (const value of values) {
2175
- result = gcd(result, Math.abs(value));
2176
- }
2177
- return result;
2178
- }
2179
-
2180
- function generalizedLcm(values: number[]): number {
2181
- if (values.some((value) => value === 0)) return 0;
2182
- return values.reduce((result, value) => lcm(result, Math.abs(value)), 1);
2183
- }
2184
-
2185
- function gcd(left: number, right: number): number {
2186
- let a = Math.abs(left);
2187
- let b = Math.abs(right);
2188
- while (b !== 0) {
2189
- const next = a % b;
2190
- a = b;
2191
- b = next;
2192
- }
2193
- return a;
2194
- }
2195
-
2196
- function lcm(left: number, right: number): number {
2197
- return Math.abs(left * right) / gcd(left, right);
2198
- }
2199
-
2200
- function mathOperatorValue(name: string, values: number[]): QtiValue {
2201
- const [first, second] = values;
2202
- if (first === undefined) return null;
2203
- switch (name) {
2204
- case "abs":
2205
- return finiteOrNull(Math.abs(first));
2206
- case "acos":
2207
- return Math.abs(first) > 1 ? null : finiteOrNull(Math.acos(first));
2208
- case "acot":
2209
- return finiteOrNull(Math.PI / 2 - Math.atan(1 / first));
2210
- case "acsc":
2211
- return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.asin(1 / first));
2212
- case "asec":
2213
- return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.acos(1 / first));
2214
- case "asin":
2215
- return Math.abs(first) > 1 ? null : finiteOrNull(Math.asin(first));
2216
- case "atan":
2217
- return finiteOrNull(Math.atan(first));
2218
- case "atan2":
2219
- return second === undefined ? null : finiteOrNull(Math.atan2(first, second));
2220
- case "ceil":
2221
- return finiteOrNull(Math.ceil(first));
2222
- case "cos":
2223
- return finiteOrNull(Math.cos(first));
2224
- case "cosh":
2225
- return finiteOrNull(Math.cosh(first));
2226
- case "cot":
2227
- return finiteOrNull(1 / Math.tan(first));
2228
- case "coth":
2229
- return finiteOrNull(1 / Math.tanh(first));
2230
- case "csc":
2231
- return finiteOrNull(1 / Math.sin(first));
2232
- case "csch":
2233
- return finiteOrNull(1 / Math.sinh(first));
2234
- case "exp":
2235
- return finiteOrNull(Math.exp(first));
2236
- case "floor":
2237
- return finiteOrNull(Math.floor(first));
2238
- case "ln":
2239
- return first < 0 ? null : finiteOrNull(Math.log(first));
2240
- case "log":
2241
- return first < 0 ? null : finiteOrNull(Math.log10(first));
2242
- case "sec":
2243
- return finiteOrNull(1 / Math.cos(first));
2244
- case "sech":
2245
- return finiteOrNull(1 / Math.cosh(first));
2246
- case "signum":
2247
- return finiteOrNull(Math.sign(first));
2248
- case "sin":
2249
- return finiteOrNull(Math.sin(first));
2250
- case "sinh":
2251
- return finiteOrNull(Math.sinh(first));
2252
- case "tan":
2253
- return finiteOrNull(Math.tan(first));
2254
- case "tanh":
2255
- return finiteOrNull(Math.tanh(first));
2256
- case "toDegrees":
2257
- return finiteOrNull((first * 180) / Math.PI);
2258
- case "toRadians":
2259
- return finiteOrNull((first * Math.PI) / 180);
2260
- default:
2261
- return null;
2262
- }
2263
- }
2264
-
2265
- function statsOperatorValue(name: string, values: number[]): QtiValue {
2266
- if (values.length === 0) return 0;
2267
- const meanValue = mean(values);
2268
- const squareDiffs = values.map((value) => (value - meanValue) ** 2);
2269
- switch (name) {
2270
- case "mean":
2271
- return meanValue;
2272
- case "sampleVariance":
2273
- return meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1);
2274
- case "sampleSD":
2275
- return Math.sqrt(meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1));
2276
- case "popVariance":
2277
- return mean(squareDiffs);
2278
- case "popSD":
2279
- return Math.sqrt(mean(squareDiffs));
2280
- default:
2281
- return null;
2282
- }
2283
- }
2284
-
2285
- function mean(values: number[]): number {
2286
- return meanWithDivisor(values, values.length);
2287
- }
2288
-
2289
- function meanWithDivisor(values: number[], divisor: number): number {
2290
- if (values.length === 0) return 0;
2291
- return values.reduce((sum, value) => sum + value, 0) / divisor;
2292
- }
2293
-
2294
- function finiteOrNull(value: number): QtiValue {
2295
- return Number.isFinite(value) ? value : null;
2296
- }
2297
-
2298
- function stringMatch(
2299
- left: QtiValue,
2300
- right: QtiValue,
2301
- caseSensitive: boolean,
2302
- substring: boolean,
2303
- ): boolean | null {
2304
- if (left === null || right === null) return null;
2305
- let actual = qtiValueToString(left);
2306
- let expected = qtiValueToString(right);
2307
- if (!caseSensitive) {
2308
- actual = actual.toLocaleLowerCase();
2309
- expected = expected.toLocaleLowerCase();
2310
- }
2311
- return substring ? actual.includes(expected) : actual === expected;
2312
- }
2313
-
2314
- function seededRandom(seed: string | number): () => number {
2315
- let state = typeof seed === "number" ? seed >>> 0 : hashSeed(seed);
2316
- return () => {
2317
- state = (1664525 * state + 1013904223) >>> 0;
2318
- return state / 0x100000000;
2319
- };
2320
- }
2321
-
2322
- function hashSeed(seed: string): number {
2323
- let hash = 2166136261;
2324
- for (let index = 0; index < seed.length; index += 1) {
2325
- hash ^= seed.charCodeAt(index);
2326
- hash = Math.imul(hash, 16777619);
2327
- }
2328
- return hash >>> 0;
450
+ return applyResponseRules(context, resolveConditionalRules(context, condition));
2329
451
  }