@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,429 @@
1
+ import { parseQtiXml } from "./parser.js";
2
+ import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
3
+ import type {
4
+ QtiAssessmentItem,
5
+ QtiAttemptStateV1,
6
+ QtiAttemptStatus,
7
+ QtiDiagnostic,
8
+ QtiDocument,
9
+ QtiPortableCustomStateValue,
10
+ QtiValue,
11
+ } from "./types.js";
12
+ import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
13
+
14
+ export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn";
15
+
16
+ export interface QtiTrustedResponseInput {
17
+ identifier: string;
18
+ value: unknown;
19
+ }
20
+
21
+ export type QtiTrustedResponsesInput =
22
+ | Record<string, unknown>
23
+ | readonly QtiTrustedResponseInput[]
24
+ | undefined;
25
+
26
+ /** Trusted candidate responses and portable custom interaction state for one application. */
27
+ export interface QtiTrustedResponseApplication {
28
+ trustedResponses?: QtiTrustedResponsesInput;
29
+ trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
30
+ }
31
+
32
+ export interface QtiTrustedItemParseOptions {
33
+ allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
34
+ }
35
+
36
+ interface QtiParsedTrustedItem {
37
+ document: QtiDocument;
38
+ diagnostics: QtiDiagnostic[];
39
+ responseIdentifiers: Set<string>;
40
+ }
41
+
42
+ export type QtiTrustedItemScoringPolicy = "always" | "onSubmission";
43
+
44
+ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
45
+ itemXml: string;
46
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
47
+ submission: QtiTrustedResponseApplication;
48
+ priorState?: QtiAttemptStateV1 | null | undefined;
49
+ /** Applied before submission. Intended for server-side scoring, not adaptive turns. */
50
+ attemptStatus?: QtiAttemptStatus | undefined;
51
+ scoring: QtiTrustedItemScoringPolicy;
52
+ requireNumericScore: boolean;
53
+ }
54
+
55
+ export interface RunTrustedItemSessionSuccess {
56
+ ok: true;
57
+ diagnostics: QtiDiagnostic[];
58
+ state: QtiAttemptStateV1;
59
+ responses: Record<string, QtiValue>;
60
+ outcomes: Record<string, QtiValue>;
61
+ score: number | null;
62
+ }
63
+
64
+ export interface RunTrustedItemSessionFailure {
65
+ ok: false;
66
+ diagnostics: QtiDiagnostic[];
67
+ state: QtiAttemptStateV1 | null;
68
+ responses: Record<string, QtiValue>;
69
+ outcomes: Record<string, QtiValue>;
70
+ score: number | null;
71
+ }
72
+
73
+ export type RunTrustedItemSessionResult =
74
+ | RunTrustedItemSessionSuccess
75
+ | RunTrustedItemSessionFailure;
76
+
77
+ export function readPriorAttemptState(
78
+ priorState: unknown,
79
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
80
+ ): { state: QtiAttemptStateV1 | null; diagnostics: QtiDiagnostic[] } {
81
+ if (priorState === undefined || priorState === null) return { state: null, diagnostics: [] };
82
+ if (isQtiAttemptStateV1(priorState)) return { state: priorState, diagnostics: [] };
83
+ return {
84
+ state: null,
85
+ diagnostics: [
86
+ {
87
+ code: `${diagnosticPrefix}.state.value`,
88
+ severity: "error",
89
+ message: "Prior adaptive turn state is not a valid qti3.attempt-state.v1 value.",
90
+ },
91
+ ],
92
+ };
93
+ }
94
+
95
+ export function runTrustedItemSession(
96
+ input: RunTrustedItemSessionInput,
97
+ ): RunTrustedItemSessionResult {
98
+ const parsedResult = parseTrustedItemXml(
99
+ input.itemXml,
100
+ input.allowedUndeclaredResponseIdentifiers,
101
+ );
102
+ if (!parsedResult.ok) {
103
+ return emptyTrustedItemSessionFailure(parsedResult.diagnostics);
104
+ }
105
+
106
+ const sessionResult = createTrustedItemSession(
107
+ parsedResult.parsed,
108
+ input.priorState,
109
+ input.diagnosticPrefix,
110
+ input.allowedUndeclaredResponseIdentifiers,
111
+ parsedResult.parsed.diagnostics,
112
+ );
113
+ if (!sessionResult.ok) {
114
+ return emptyTrustedItemSessionFailure(sessionResult.diagnostics);
115
+ }
116
+
117
+ if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
118
+
119
+ const applicationResult = applyTrustedResponseApplication(
120
+ sessionResult.session,
121
+ parsedResult.parsed,
122
+ input.submission,
123
+ input.allowedUndeclaredResponseIdentifiers,
124
+ input.diagnosticPrefix,
125
+ );
126
+ const diagnostics = applicationResult.diagnostics;
127
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
128
+ return emptyTrustedItemSessionFailure(diagnostics);
129
+ }
130
+
131
+ const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
132
+
133
+ let outcomes = sessionResult.session.serialize().outcomes;
134
+ let state = sessionResult.session.serialize();
135
+ let score: number | null = null;
136
+ let scoredDiagnostics = diagnostics;
137
+
138
+ if (shouldScore) {
139
+ const scoredResult = scoreTrustedItemSession(
140
+ sessionResult.session,
141
+ diagnostics,
142
+ input.diagnosticPrefix,
143
+ );
144
+ if (!scoredResult.ok) {
145
+ return emptyTrustedItemSessionFailure(scoredResult.diagnostics);
146
+ }
147
+
148
+ scoredDiagnostics = [...diagnostics, ...scoredResult.scored.diagnostics];
149
+ outcomes = scoredResult.scored.outcomes;
150
+ state = stripUndeclaredResponses(
151
+ scoredResult.scored.state,
152
+ parsedResult.parsed.responseIdentifiers,
153
+ );
154
+ score = readNumericScore(outcomes.SCORE);
155
+
156
+ if (scoredResult.scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
157
+ return emptyTrustedItemSessionFailure(scoredDiagnostics, {
158
+ state,
159
+ responses: state.responses,
160
+ outcomes,
161
+ score,
162
+ });
163
+ }
164
+
165
+ if (
166
+ input.requireNumericScore &&
167
+ shouldRequireNumericScore(parsedResult.parsed.document.item) &&
168
+ score === null
169
+ ) {
170
+ return emptyTrustedItemSessionFailure(
171
+ [...scoredDiagnostics, missingNumericScoreDiagnostic(input.diagnosticPrefix)],
172
+ {
173
+ state,
174
+ responses: state.responses,
175
+ outcomes,
176
+ score,
177
+ },
178
+ );
179
+ }
180
+ } else {
181
+ state = stripUndeclaredResponses(state, parsedResult.parsed.responseIdentifiers);
182
+ outcomes = state.outcomes;
183
+ score = readNumericScore(outcomes.SCORE);
184
+ }
185
+
186
+ return {
187
+ ok: true,
188
+ diagnostics: scoredDiagnostics,
189
+ state,
190
+ responses: state.responses,
191
+ outcomes,
192
+ score,
193
+ };
194
+ }
195
+
196
+ function emptyTrustedItemSessionFailure(
197
+ diagnostics: QtiDiagnostic[],
198
+ partial: Partial<
199
+ Pick<RunTrustedItemSessionFailure, "state" | "responses" | "outcomes" | "score">
200
+ > = {},
201
+ ): RunTrustedItemSessionFailure {
202
+ return {
203
+ ok: false,
204
+ diagnostics,
205
+ state: partial.state ?? null,
206
+ responses: partial.responses ?? partial.state?.responses ?? {},
207
+ outcomes: partial.outcomes ?? partial.state?.outcomes ?? {},
208
+ score: partial.score ?? null,
209
+ };
210
+ }
211
+
212
+ function parseTrustedItemXml(
213
+ itemXml: string,
214
+ allowedUndeclaredResponseIdentifiers: readonly string[] = [],
215
+ ): { ok: true; parsed: QtiParsedTrustedItem } | { ok: false; diagnostics: QtiDiagnostic[] } {
216
+ let parsed: ReturnType<typeof parseQtiXml>;
217
+ try {
218
+ parsed = parseQtiXml(itemXml);
219
+ } catch (error) {
220
+ return {
221
+ ok: false,
222
+ diagnostics: [
223
+ {
224
+ code: "xml.parse",
225
+ severity: "error",
226
+ message: error instanceof Error ? error.message : String(error),
227
+ },
228
+ ],
229
+ };
230
+ }
231
+
232
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
233
+ const diagnostics = parsed.diagnostics.filter(
234
+ (diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
235
+ );
236
+ if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
237
+ return { ok: false, diagnostics };
238
+ }
239
+
240
+ return {
241
+ ok: true,
242
+ parsed: {
243
+ document: parsed.document,
244
+ diagnostics,
245
+ responseIdentifiers: new Set(
246
+ parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
247
+ ),
248
+ },
249
+ };
250
+ }
251
+
252
+ function createTrustedItemSession(
253
+ parsed: QtiParsedTrustedItem,
254
+ priorState: QtiAttemptStateV1 | null | undefined,
255
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
256
+ allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
257
+ existingDiagnostics: QtiDiagnostic[] = [],
258
+ ): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
259
+ try {
260
+ return {
261
+ ok: true,
262
+ session: createItemSession(parsed.document, priorState ?? undefined, {
263
+ allowedUndeclaredResponseIdentifiers,
264
+ }),
265
+ };
266
+ } catch (error) {
267
+ return {
268
+ ok: false,
269
+ diagnostics: [
270
+ ...existingDiagnostics,
271
+ {
272
+ code: `${diagnosticPrefix}.state.restore`,
273
+ severity: "error",
274
+ message: error instanceof Error ? error.message : String(error),
275
+ },
276
+ ],
277
+ };
278
+ }
279
+ }
280
+
281
+ function applyTrustedResponseApplication(
282
+ session: QtiItemSession,
283
+ parsed: QtiParsedTrustedItem,
284
+ submission: QtiTrustedResponseApplication,
285
+ allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
286
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
287
+ ): { diagnostics: QtiDiagnostic[]; appliedSubmission: boolean } {
288
+ const diagnostics = [...parsed.diagnostics];
289
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers ?? []);
290
+ let appliedSubmission = false;
291
+
292
+ for (const response of normalizeResponseInputs(submission.trustedResponses)) {
293
+ const identifier = response.identifier.trim();
294
+ if (!identifier) {
295
+ diagnostics.push({
296
+ code: `${diagnosticPrefix}.response.identifier`,
297
+ severity: "error",
298
+ message: "Trusted response identifiers must be non-empty strings.",
299
+ });
300
+ continue;
301
+ }
302
+
303
+ if (!parsed.responseIdentifiers.has(identifier) && !allowedUndeclared.has(identifier)) {
304
+ diagnostics.push({
305
+ code: `${diagnosticPrefix}.response.ignored`,
306
+ severity: "warning",
307
+ message: `Trusted response ${identifier} was ignored because it is not declared by the item.`,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ const value = readQtiJsonValue(response.value);
313
+ if (value === undefined) {
314
+ diagnostics.push({
315
+ code: `${diagnosticPrefix}.response.value`,
316
+ severity: "error",
317
+ message: `Trusted response ${identifier} is not a supported QTI value.`,
318
+ });
319
+ continue;
320
+ }
321
+ session.respond(identifier, value);
322
+ appliedSubmission = true;
323
+ }
324
+
325
+ for (const [identifier, state] of Object.entries(submission.trustedInteractionStates ?? {})) {
326
+ if (!isQtiPortableCustomStateValue(state)) {
327
+ diagnostics.push({
328
+ code: `${diagnosticPrefix}.interactionState.value`,
329
+ severity: "error",
330
+ message: `Trusted interaction state ${identifier} is not a supported portable custom state value.`,
331
+ });
332
+ continue;
333
+ }
334
+
335
+ try {
336
+ session.setInteractionState(identifier, state);
337
+ appliedSubmission = true;
338
+ } catch (error) {
339
+ diagnostics.push({
340
+ code: `${diagnosticPrefix}.interactionState.identifier`,
341
+ severity: "error",
342
+ message: error instanceof Error ? error.message : String(error),
343
+ });
344
+ }
345
+ }
346
+
347
+ return { diagnostics, appliedSubmission };
348
+ }
349
+
350
+ function scoreTrustedItemSession(
351
+ session: QtiItemSession,
352
+ diagnostics: QtiDiagnostic[],
353
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
354
+ ):
355
+ | { ok: true; scored: ReturnType<QtiItemSession["score"]> }
356
+ | { ok: false; diagnostics: QtiDiagnostic[] } {
357
+ try {
358
+ return { ok: true, scored: session.score() };
359
+ } catch (error) {
360
+ return {
361
+ ok: false,
362
+ diagnostics: [
363
+ ...diagnostics,
364
+ {
365
+ code: `${diagnosticPrefix}.score.exception`,
366
+ severity: "error",
367
+ message: error instanceof Error ? error.message : String(error),
368
+ },
369
+ ],
370
+ };
371
+ }
372
+ }
373
+
374
+ function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
375
+ return (
376
+ Boolean(item.responseProcessing) ||
377
+ item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
378
+ );
379
+ }
380
+
381
+ function readNumericScore(value: QtiValue | undefined): number | null {
382
+ if (typeof value === "number" && Number.isFinite(value)) return value;
383
+ if (typeof value === "string") {
384
+ const score = Number(value.trim());
385
+ return Number.isFinite(score) ? score : null;
386
+ }
387
+ return null;
388
+ }
389
+
390
+ function missingNumericScoreDiagnostic(
391
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
392
+ ): QtiDiagnostic {
393
+ return {
394
+ code: `${diagnosticPrefix}.score.missing`,
395
+ severity: "error",
396
+ message: "Server-side scoring did not produce a numeric SCORE outcome.",
397
+ };
398
+ }
399
+
400
+ function stripUndeclaredResponses(
401
+ state: QtiAttemptStateV1,
402
+ declaredResponseIdentifiers: Set<string>,
403
+ ): QtiAttemptStateV1 {
404
+ return {
405
+ ...state,
406
+ responses: Object.fromEntries(
407
+ Object.entries(state.responses).filter(([identifier]) =>
408
+ declaredResponseIdentifiers.has(identifier),
409
+ ),
410
+ ),
411
+ };
412
+ }
413
+
414
+ function normalizeResponseInputs(responses: QtiTrustedResponsesInput): QtiTrustedResponseInput[] {
415
+ if (!responses) return [];
416
+ if (Array.isArray(responses)) return [...responses];
417
+ return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
418
+ }
419
+
420
+ function isAllowedUndeclaredVariableReference(
421
+ diagnostic: QtiDiagnostic,
422
+ allowedUndeclaredResponseIdentifiers: Set<string>,
423
+ ): boolean {
424
+ if (diagnostic.code !== "processing.variable.reference") return false;
425
+ const identifier = diagnostic.message.match(
426
+ /^Processing expression references missing variable (.+)\.$/,
427
+ )?.[1];
428
+ return Boolean(identifier && allowedUndeclaredResponseIdentifiers.has(identifier));
429
+ }
package/src/tts.ts CHANGED
@@ -103,10 +103,17 @@ interface SegmentInput {
103
103
  }
104
104
 
105
105
  const dataSsmlFunctionNames = new Set(["break", "phoneme", "prosody", "say-as", "sub"]);
106
- const breakStrengths = new Set(["medium", "none", "strong", "weak", "x-strong", "x-weak"]);
107
- const phonemeAlphabets = new Set(["ipa", "x-sampa"]);
108
- const phonemeTypes = new Set(["default", "ruby"]);
109
- const sayAsInterpretations = new Set([
106
+ const breakStrengths = new Set<QtiDataSsmlBreakStrength>([
107
+ "medium",
108
+ "none",
109
+ "strong",
110
+ "weak",
111
+ "x-strong",
112
+ "x-weak",
113
+ ]);
114
+ const phonemeAlphabets = new Set<NonNullable<QtiDataSsmlPhoneme["alphabet"]>>(["ipa", "x-sampa"]);
115
+ const phonemeTypes = new Set<NonNullable<QtiDataSsmlPhoneme["type"]>>(["default", "ruby"]);
116
+ const sayAsInterpretations = new Set<QtiDataSsmlSayAs["interpret-as"]>([
110
117
  "cardinal",
111
118
  "characters",
112
119
  "date",
@@ -200,7 +207,7 @@ function validateBreak(value: Record<string, unknown>, errors: string[]): QtiDat
200
207
  const result: QtiDataSsmlBreak = {};
201
208
  const strength = optionalEnum(value, "strength", "break.strength", breakStrengths, errors);
202
209
  const time = optionalString(value, "time", "break.time", errors);
203
- if (strength) result.strength = strength as QtiDataSsmlBreakStrength;
210
+ if (strength) result.strength = strength;
204
211
  if (time !== undefined) result.time = time;
205
212
  return result;
206
213
  }
@@ -215,8 +222,8 @@ function validatePhoneme(
215
222
  const type = optionalEnum(value, "type", "phoneme.type", phonemeTypes, errors);
216
223
  if (!ph) return undefined;
217
224
  const result: QtiDataSsmlPhoneme = { ph };
218
- if (alphabet) result.alphabet = alphabet as QtiDataSsmlPhoneme["alphabet"];
219
- if (type) result.type = type as QtiDataSsmlPhoneme["type"];
225
+ if (alphabet) result.alphabet = alphabet;
226
+ if (type) result.type = type;
220
227
  return result;
221
228
  }
222
229
 
@@ -241,7 +248,7 @@ function validateSayAs(
241
248
  errors,
242
249
  );
243
250
  if (!interpretation) return undefined;
244
- return { "interpret-as": interpretation as QtiDataSsmlSayAs["interpret-as"] };
251
+ return { "interpret-as": interpretation };
245
252
  }
246
253
 
247
254
  function validateSub(value: Record<string, unknown>, errors: string[]): QtiDataSsmlSub | undefined {
@@ -520,29 +527,36 @@ function requiredString(
520
527
  return optionalString(value, name, path, errors);
521
528
  }
522
529
 
523
- function optionalEnum(
530
+ function isAllowedEnum<T extends string>(value: string, allowed: ReadonlySet<T>): value is T {
531
+ for (const entry of allowed) {
532
+ if (entry === value) return true;
533
+ }
534
+ return false;
535
+ }
536
+
537
+ function optionalEnum<T extends string>(
524
538
  value: Record<string, unknown>,
525
539
  name: string,
526
540
  path: string,
527
- allowed: Set<string>,
541
+ allowed: ReadonlySet<T>,
528
542
  errors: string[],
529
- ): string | undefined {
543
+ ): T | undefined {
530
544
  const property = optionalString(value, name, path, errors);
531
545
  if (property === undefined) return undefined;
532
- if (!allowed.has(property)) {
546
+ if (!isAllowedEnum(property, allowed)) {
533
547
  errors.push(`${path} has unsupported value "${property}".`);
534
548
  return undefined;
535
549
  }
536
550
  return property;
537
551
  }
538
552
 
539
- function requiredEnum(
553
+ function requiredEnum<T extends string>(
540
554
  value: Record<string, unknown>,
541
555
  name: string,
542
556
  path: string,
543
- allowed: Set<string>,
557
+ allowed: ReadonlySet<T>,
544
558
  errors: string[],
545
- ): string | undefined {
559
+ ): T | undefined {
546
560
  if (!Object.hasOwn(value, name)) {
547
561
  errors.push(`${path} is required.`);
548
562
  return undefined;