@longsightgroup/qti3-core 0.10.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +153 -4
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/catalog.d.ts +8 -0
  11. package/dist/catalog.d.ts.map +1 -1
  12. package/dist/catalog.js +10 -0
  13. package/dist/catalog.js.map +1 -1
  14. package/dist/diagnostics.d.ts +13 -0
  15. package/dist/diagnostics.d.ts.map +1 -0
  16. package/dist/diagnostics.js +28 -0
  17. package/dist/diagnostics.js.map +1 -0
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/item-scoring-expectation.d.ts +2 -0
  23. package/dist/item-scoring-expectation.d.ts.map +1 -1
  24. package/dist/item-scoring-expectation.js +8 -0
  25. package/dist/item-scoring-expectation.js.map +1 -1
  26. package/dist/item-submission-materialization.d.ts.map +1 -1
  27. package/dist/item-submission-materialization.js +2 -0
  28. package/dist/item-submission-materialization.js.map +1 -1
  29. package/dist/parser-processing.d.ts.map +1 -1
  30. package/dist/parser-processing.js +8 -18
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts.map +1 -1
  33. package/dist/parser-values.js +1 -3
  34. package/dist/parser-values.js.map +1 -1
  35. package/dist/parser.js +36 -6
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-equality.d.ts +10 -0
  38. package/dist/processing-equality.d.ts.map +1 -0
  39. package/dist/processing-equality.js +78 -0
  40. package/dist/processing-equality.js.map +1 -0
  41. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  42. package/dist/processing-evaluator-comparison.js +14 -9
  43. package/dist/processing-evaluator-comparison.js.map +1 -1
  44. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  45. package/dist/processing-evaluator-numeric.js +2 -1
  46. package/dist/processing-evaluator-numeric.js.map +1 -1
  47. package/dist/processing-evaluator-string.d.ts.map +1 -1
  48. package/dist/processing-evaluator-string.js +26 -10
  49. package/dist/processing-evaluator-string.js.map +1 -1
  50. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  51. package/dist/processing-evaluator-variable.js +9 -3
  52. package/dist/processing-evaluator-variable.js.map +1 -1
  53. package/dist/processing-evaluator.d.ts +5 -1
  54. package/dist/processing-evaluator.d.ts.map +1 -1
  55. package/dist/processing-evaluator.js +13 -4
  56. package/dist/processing-evaluator.js.map +1 -1
  57. package/dist/processing-expression-children.d.ts.map +1 -1
  58. package/dist/processing-expression-children.js +2 -3
  59. package/dist/processing-expression-children.js.map +1 -1
  60. package/dist/processing-expression-tags.d.ts +2 -1
  61. package/dist/processing-expression-tags.d.ts.map +1 -1
  62. package/dist/processing-expression-tags.js +2 -1
  63. package/dist/processing-expression-tags.js.map +1 -1
  64. package/dist/processing-mapping.d.ts.map +1 -1
  65. package/dist/processing-mapping.js +26 -16
  66. package/dist/processing-mapping.js.map +1 -1
  67. package/dist/processing-state.d.ts +2 -2
  68. package/dist/processing-state.d.ts.map +1 -1
  69. package/dist/processing-state.js +5 -2
  70. package/dist/processing-state.js.map +1 -1
  71. package/dist/processing-values.d.ts +1 -2
  72. package/dist/processing-values.d.ts.map +1 -1
  73. package/dist/processing-values.js +1 -3
  74. package/dist/processing-values.js.map +1 -1
  75. package/dist/processing-variables.d.ts.map +1 -1
  76. package/dist/processing-variables.js +10 -5
  77. package/dist/processing-variables.js.map +1 -1
  78. package/dist/qti-package-assets.d.ts.map +1 -1
  79. package/dist/qti-package-assets.js +16 -10
  80. package/dist/qti-package-assets.js.map +1 -1
  81. package/dist/qti-package-manifest.d.ts +1 -0
  82. package/dist/qti-package-manifest.d.ts.map +1 -1
  83. package/dist/qti-package-manifest.js +1 -0
  84. package/dist/qti-package-manifest.js.map +1 -1
  85. package/dist/qti-package-metadata.d.ts.map +1 -1
  86. package/dist/qti-package-metadata.js +11 -0
  87. package/dist/qti-package-metadata.js.map +1 -1
  88. package/dist/qti-package-plan.d.ts +1 -1
  89. package/dist/qti-package-plan.d.ts.map +1 -1
  90. package/dist/qti-package-types.d.ts +9 -0
  91. package/dist/qti-package-types.d.ts.map +1 -1
  92. package/dist/qti-package-xml.d.ts +6 -1
  93. package/dist/qti-package-xml.d.ts.map +1 -1
  94. package/dist/qti-package-xml.js +11 -6
  95. package/dist/qti-package-xml.js.map +1 -1
  96. package/dist/qti-package-zip.d.ts +13 -1
  97. package/dist/qti-package-zip.d.ts.map +1 -1
  98. package/dist/qti-package-zip.js +185 -54
  99. package/dist/qti-package-zip.js.map +1 -1
  100. package/dist/qti-package.d.ts +12 -5
  101. package/dist/qti-package.d.ts.map +1 -1
  102. package/dist/qti-package.js +63 -8
  103. package/dist/qti-package.js.map +1 -1
  104. package/dist/response-validation-policy.d.ts +3 -1
  105. package/dist/response-validation-policy.d.ts.map +1 -1
  106. package/dist/response-validation-policy.js +66 -6
  107. package/dist/response-validation-policy.js.map +1 -1
  108. package/dist/response-validation.d.ts +3 -1
  109. package/dist/response-validation.d.ts.map +1 -1
  110. package/dist/response-validation.js +32 -10
  111. package/dist/response-validation.js.map +1 -1
  112. package/dist/scoring-disposition-policy.d.ts.map +1 -1
  113. package/dist/scoring-disposition-policy.js +6 -2
  114. package/dist/scoring-disposition-policy.js.map +1 -1
  115. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  116. package/dist/serializer-processing-expressions.js +2 -10
  117. package/dist/serializer-processing-expressions.js.map +1 -1
  118. package/dist/server-scoring.d.ts.map +1 -1
  119. package/dist/server-scoring.js +1 -0
  120. package/dist/server-scoring.js.map +1 -1
  121. package/dist/session-builtins.d.ts +16 -0
  122. package/dist/session-builtins.d.ts.map +1 -0
  123. package/dist/session-builtins.js +58 -0
  124. package/dist/session-builtins.js.map +1 -0
  125. package/dist/session.d.ts +5 -1
  126. package/dist/session.d.ts.map +1 -1
  127. package/dist/session.js +63 -11
  128. package/dist/session.js.map +1 -1
  129. package/dist/support-evidence.d.ts.map +1 -1
  130. package/dist/support-evidence.js +16 -3
  131. package/dist/support-evidence.js.map +1 -1
  132. package/dist/support.d.ts.map +1 -1
  133. package/dist/support.js +72 -24
  134. package/dist/support.js.map +1 -1
  135. package/dist/text-response.d.ts +9 -0
  136. package/dist/text-response.d.ts.map +1 -0
  137. package/dist/text-response.js +102 -0
  138. package/dist/text-response.js.map +1 -0
  139. package/dist/trusted-item-session.d.ts +4 -1
  140. package/dist/trusted-item-session.d.ts.map +1 -1
  141. package/dist/trusted-item-session.js +12 -6
  142. package/dist/trusted-item-session.js.map +1 -1
  143. package/dist/types.d.ts +23 -3
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/validation-declaration-entries.d.ts.map +1 -1
  146. package/dist/validation-declaration-entries.js +11 -1
  147. package/dist/validation-declaration-entries.js.map +1 -1
  148. package/dist/validation-interactions.d.ts.map +1 -1
  149. package/dist/validation-interactions.js +102 -12
  150. package/dist/validation-interactions.js.map +1 -1
  151. package/dist/validation-item-children.d.ts +4 -0
  152. package/dist/validation-item-children.d.ts.map +1 -0
  153. package/dist/validation-item-children.js +45 -0
  154. package/dist/validation-item-children.js.map +1 -0
  155. package/dist/validation-processing.d.ts.map +1 -1
  156. package/dist/validation-processing.js +45 -12
  157. package/dist/validation-processing.js.map +1 -1
  158. package/dist/validation.d.ts.map +1 -1
  159. package/dist/validation.js +12 -0
  160. package/dist/validation.js.map +1 -1
  161. package/dist/value-guards.d.ts +4 -0
  162. package/dist/value-guards.d.ts.map +1 -0
  163. package/dist/value-guards.js +5 -0
  164. package/dist/value-guards.js.map +1 -0
  165. package/dist/xml-schema-regex-data.d.ts +48 -0
  166. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  167. package/dist/xml-schema-regex-data.js +403 -0
  168. package/dist/xml-schema-regex-data.js.map +1 -0
  169. package/dist/xml-schema-regex-parser.d.ts +31 -0
  170. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  171. package/dist/xml-schema-regex-parser.js +245 -0
  172. package/dist/xml-schema-regex-parser.js.map +1 -0
  173. package/dist/xml-schema-regex.d.ts +14 -0
  174. package/dist/xml-schema-regex.d.ts.map +1 -0
  175. package/dist/xml-schema-regex.js +114 -0
  176. package/dist/xml-schema-regex.js.map +1 -0
  177. package/package.json +1 -1
  178. package/src/adaptive-turn-materializer.ts +4 -3
  179. package/src/adaptive-turn.ts +1 -0
  180. package/src/attempt-state.ts +54 -1
  181. package/src/catalog.ts +29 -0
  182. package/src/diagnostics.ts +29 -0
  183. package/src/index.ts +16 -0
  184. package/src/item-scoring-expectation.ts +8 -0
  185. package/src/item-submission-materialization.ts +2 -0
  186. package/src/parser-processing.ts +15 -18
  187. package/src/parser-values.ts +1 -5
  188. package/src/parser.ts +38 -6
  189. package/src/processing-equality.ts +104 -0
  190. package/src/processing-evaluator-comparison.ts +19 -14
  191. package/src/processing-evaluator-numeric.ts +2 -6
  192. package/src/processing-evaluator-string.ts +25 -18
  193. package/src/processing-evaluator-variable.ts +8 -3
  194. package/src/processing-evaluator.ts +20 -7
  195. package/src/processing-expression-children.ts +2 -3
  196. package/src/processing-expression-tags.ts +2 -1
  197. package/src/processing-mapping.ts +31 -25
  198. package/src/processing-state.ts +6 -1
  199. package/src/processing-values.ts +2 -11
  200. package/src/processing-variables.ts +9 -7
  201. package/src/qti-package-assets.ts +20 -13
  202. package/src/qti-package-manifest.ts +1 -0
  203. package/src/qti-package-metadata.ts +11 -0
  204. package/src/qti-package-plan.ts +1 -1
  205. package/src/qti-package-types.ts +10 -0
  206. package/src/qti-package-xml.ts +11 -6
  207. package/src/qti-package-zip.ts +330 -97
  208. package/src/qti-package.ts +106 -7
  209. package/src/response-validation-policy.ts +76 -4
  210. package/src/response-validation.ts +51 -5
  211. package/src/scoring-disposition-policy.ts +7 -2
  212. package/src/serializer-processing-expressions.ts +8 -25
  213. package/src/server-scoring.ts +1 -0
  214. package/src/session-builtins.ts +75 -0
  215. package/src/session.ts +64 -8
  216. package/src/support-evidence.ts +16 -3
  217. package/src/support.ts +94 -24
  218. package/src/text-response.ts +106 -0
  219. package/src/trusted-item-session.ts +17 -5
  220. package/src/types.ts +32 -4
  221. package/src/validation-declaration-entries.ts +11 -1
  222. package/src/validation-interactions.ts +139 -14
  223. package/src/validation-item-children.ts +48 -0
  224. package/src/validation-processing.ts +47 -10
  225. package/src/validation.ts +14 -0
  226. package/src/value-guards.ts +6 -0
  227. package/src/xml-schema-regex-data.ts +404 -0
  228. package/src/xml-schema-regex-parser.ts +234 -0
  229. package/src/xml-schema-regex.ts +115 -0
package/src/session.ts CHANGED
@@ -43,6 +43,9 @@ import {
43
43
  } from "./processing-state.js";
44
44
  import { booleanValue, normalizeValueForCardinality, qtiMatchValues } from "./processing-values.js";
45
45
 
46
+ import { createSessionBuiltIns, type QtiSessionEnvironment } from "./session-builtins.js";
47
+ export type { QtiSessionEnvironment } from "./session-builtins.js";
48
+
46
49
  interface SessionProcessingContext {
47
50
  responseDefaults: Record<string, QtiValue>;
48
51
  evaluation: EvaluationContext;
@@ -58,7 +61,7 @@ interface ConditionalRules<Rule> {
58
61
  elseRules: Rule[];
59
62
  }
60
63
 
61
- export interface QtiItemSessionOptions {
64
+ export interface QtiItemSessionOptions extends QtiSessionEnvironment {
62
65
  randomSeed?: string | number | undefined;
63
66
  customOperators?: QtiCustomOperatorRegistry | undefined;
64
67
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
@@ -67,6 +70,8 @@ export interface QtiItemSessionOptions {
67
70
  export interface QtiItemSession {
68
71
  readonly item: QtiAssessmentItem;
69
72
  correctResponses(): Record<string, QtiValue>;
73
+ /** Starts an attempt without requiring a response; repeated calls and resume are idempotent. */
74
+ beginAttempt(): void;
70
75
  respond(identifier: string, value: QtiValue): void;
71
76
  setInteractionState(identifier: string, state: QtiPortableCustomStateValue): void;
72
77
  interactionState(identifier: string): QtiPortableCustomStateValue | undefined;
@@ -80,11 +85,12 @@ export function visibleModalFeedback(
80
85
  outcomes: Record<string, QtiValue>,
81
86
  ): QtiModalFeedback[] {
82
87
  return item.modalFeedback.filter((feedback) => {
83
- if (feedback.showHide === "hide") return false;
84
88
  const outcome = outcomes[feedback.outcomeIdentifier];
85
89
  const outcomeValue: QtiValue = outcome === undefined ? null : outcome;
86
- if (Array.isArray(outcomeValue)) return outcomeValue.includes(feedback.identifier);
87
- return qtiValueToString(outcomeValue) === feedback.identifier;
90
+ const matches = Array.isArray(outcomeValue)
91
+ ? outcomeValue.includes(feedback.identifier)
92
+ : qtiValueToString(outcomeValue) === feedback.identifier;
93
+ return feedback.showHide === "hide" ? !matches : matches;
88
94
  });
89
95
  }
90
96
 
@@ -109,6 +115,12 @@ export function createItemSession(
109
115
  const interactionStates: Record<string, QtiPortableCustomStateValue> = {};
110
116
  const correctResponses: Record<string, QtiValue> = {};
111
117
  let status: QtiAttemptStatus = priorState?.status ?? "initialized";
118
+ const builtInDiagnostics: QtiDiagnostic[] = [];
119
+ const builtIns = createSessionBuiltIns(priorState?.builtInVariables, options, (diagnostic) => {
120
+ if (!builtInDiagnostics.some((existing) => existing.code === diagnostic.code))
121
+ builtInDiagnostics.push(diagnostic);
122
+ });
123
+ const captureTime = () => builtIns.captureTime(status !== "suspended" && status !== "completed");
112
124
  const random = seededRandom(options.randomSeed ?? document.item.identifier);
113
125
  const customOperators = options.customOperators ?? {};
114
126
  const allowedUndeclaredResponseIdentifiers = new Set(
@@ -131,7 +143,10 @@ export function createItemSession(
131
143
  templateValues[declaration.identifier] = cloneValue(declaration.defaultValue);
132
144
  }
133
145
  for (const outcome of document.item.outcomeDeclarations) {
134
- outcomes[outcome.identifier] = cloneValue(outcome.defaultValue);
146
+ const numericSingle =
147
+ outcome.cardinality === "single" &&
148
+ (outcome.baseType === "integer" || outcome.baseType === "float");
149
+ outcomes[outcome.identifier] = cloneValue(outcome.defaultValue ?? (numericSingle ? 0 : null));
135
150
  }
136
151
  outcomes[COMPLETION_STATUS] = COMPLETION_NOT_ATTEMPTED;
137
152
  const baseResponses = cloneValueRecord(responses);
@@ -145,7 +160,20 @@ export function createItemSession(
145
160
  correctResponses,
146
161
  random,
147
162
  customOperators,
148
- { allowedUndeclaredResponseIdentifiers },
163
+ {
164
+ allowedUndeclaredResponseIdentifiers,
165
+ builtInVariable(identifier) {
166
+ if (identifier === "completionStatus")
167
+ return outcomes[COMPLETION_STATUS] ?? COMPLETION_NOT_ATTEMPTED;
168
+ if (identifier === "numAttempts") return builtIns.state.numAttempts;
169
+ if (identifier === "QTI_CONTEXT") return { ...builtIns.state.context };
170
+ if (identifier === "duration" && document.item.timeDependent) {
171
+ captureTime();
172
+ return builtIns.duration();
173
+ }
174
+ return undefined;
175
+ },
176
+ },
149
177
  );
150
178
  const processingContext: SessionProcessingContext = {
151
179
  responseDefaults,
@@ -170,6 +198,7 @@ export function createItemSession(
170
198
  baseOutcomes,
171
199
  );
172
200
  }
201
+ const templateDiagnostics = [...evaluation.diagnostics, ...builtInDiagnostics];
173
202
  const defaultOutcomes = cloneValueRecord(outcomes);
174
203
  Object.assign(responses, priorResponses);
175
204
  Object.assign(outcomes, priorOutcomes);
@@ -177,6 +206,7 @@ export function createItemSession(
177
206
 
178
207
  return {
179
208
  item: document.item,
209
+ beginAttempt: startAttempt,
180
210
  correctResponses() {
181
211
  return cloneValueRecord(correctResponses);
182
212
  },
@@ -198,10 +228,15 @@ export function createItemSession(
198
228
  return state === undefined ? undefined : clonePortableCustomState(state);
199
229
  },
200
230
  setStatus(nextStatus: QtiAttemptStatus) {
231
+ captureTime();
232
+ if (nextStatus === "interacting") startAttempt();
233
+ if (nextStatus === "completed") builtIns.state.attemptInProgress = false;
201
234
  status = nextStatus;
202
235
  },
203
236
  score() {
204
- const diagnostics: QtiDiagnostic[] = [];
237
+ evaluation.diagnostics.length = 0;
238
+ builtInDiagnostics.length = 0;
239
+ captureTime();
205
240
  if (document.item.adaptive || status !== "initialized") {
206
241
  startAttempt();
207
242
  }
@@ -211,6 +246,12 @@ export function createItemSession(
211
246
  outcomes[COMPLETION_STATUS] = completionStatus;
212
247
  }
213
248
  applyResponseProcessing(processingContext);
249
+ builtIns.state.attemptInProgress = false;
250
+ const diagnostics = [
251
+ ...templateDiagnostics,
252
+ ...evaluation.diagnostics,
253
+ ...builtInDiagnostics,
254
+ ];
214
255
  if (outcomes[COMPLETION_STATUS] === COMPLETION_COMPLETED) status = "completed";
215
256
  validationMessages = diagnostics;
216
257
  const state = serialize(
@@ -221,10 +262,12 @@ export function createItemSession(
221
262
  templateValues,
222
263
  interactionStates,
223
264
  diagnostics,
265
+ builtIns.state,
224
266
  );
225
267
  return { outcomes: cloneValueRecord(outcomes), diagnostics, state };
226
268
  },
227
269
  serialize() {
270
+ captureTime();
228
271
  return serialize(
229
272
  document.item.identifier,
230
273
  status,
@@ -233,11 +276,18 @@ export function createItemSession(
233
276
  templateValues,
234
277
  interactionStates,
235
278
  validationMessages,
279
+ builtIns.state,
236
280
  );
237
281
  },
238
282
  };
239
283
 
240
284
  function startAttempt(): void {
285
+ captureTime();
286
+ if (status === "completed") return;
287
+ if (!builtIns.state.attemptInProgress) {
288
+ builtIns.state.numAttempts += 1;
289
+ builtIns.state.attemptInProgress = true;
290
+ }
241
291
  for (const [identifier, value] of Object.entries(responseDefaults)) {
242
292
  if (responses[identifier] === undefined) responses[identifier] = cloneValue(value);
243
293
  }
@@ -264,6 +314,8 @@ function applyTemplateProcessing(
264
314
  ): void {
265
315
  const { evaluation } = context;
266
316
  const rules = evaluation.document.item.templateProcessing?.rules ?? [];
317
+ // A retry or restored clone must derive defaults afresh from the authored declarations.
318
+ resetRecord(evaluation.defaultValues, {});
267
319
  let restarts = 0;
268
320
  for (let index = 0; index < rules.length; index += 1) {
269
321
  const rule = rules[index]!;
@@ -276,6 +328,7 @@ function applyTemplateProcessing(
276
328
  resetRecord(evaluation.responses, cloneValueRecord(baseResponses));
277
329
  resetRecord(context.responseDefaults, cloneValueRecord(baseResponseDefaults));
278
330
  resetRecord(evaluation.outcomes, cloneValueRecord(baseOutcomes));
331
+ resetRecord(evaluation.defaultValues, {});
279
332
  resetCorrectResponses(evaluation.document, evaluation.correctResponses);
280
333
  restarts += 1;
281
334
  if (restarts <= 100) index = -1;
@@ -352,6 +405,7 @@ function applyTemplateRule(
352
405
  const responseDeclaration = getResponseDeclaration(evaluation.document, rule.identifier);
353
406
  if (responseDeclaration) {
354
407
  const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
408
+ evaluation.defaultValues[rule.identifier] = cloneValue(normalized);
355
409
  if (normalized === null) {
356
410
  delete context.responseDefaults[rule.identifier];
357
411
  } else {
@@ -363,7 +417,9 @@ function applyTemplateRule(
363
417
  (declaration) => declaration.identifier === rule.identifier,
364
418
  );
365
419
  if (outcomeDeclaration) {
366
- evaluation.outcomes[rule.identifier] = value;
420
+ const normalized = normalizeValueForCardinality(value, outcomeDeclaration.cardinality);
421
+ evaluation.defaultValues[rule.identifier] = cloneValue(normalized);
422
+ evaluation.outcomes[rule.identifier] = cloneValue(normalized);
367
423
  }
368
424
  return false;
369
425
  }
@@ -51,21 +51,31 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
51
51
  "tests/browser/player-interaction-sweep.spec.ts",
52
52
  ];
53
53
  const extras: Partial<Record<QtiInteractionType, string[]>> = {
54
- associate: browserKeyboardA11yTests,
54
+ associate: [
55
+ ...browserKeyboardA11yTests,
56
+ "packages/core/src/association-contracts.test.ts",
57
+ "tests/browser/player-dom-behavior.spec.ts",
58
+ ],
55
59
  choice: ["tests/browser/player-choice.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
56
60
  drawing: ["tests/browser/player-graphic.spec.ts"],
57
61
  endAttempt: ["tests/browser/player-dom-behavior.spec.ts", ...browserKeyboardA11yTests],
58
62
  extendedText: [
63
+ "packages/core/src/text-response.test.ts",
59
64
  "tests/browser/player-dom-behavior.spec.ts",
60
65
  "tests/browser/player-extended-text-xhtml.spec.ts",
61
66
  ],
62
67
  gapMatch: ["tests/browser/player-gap-match.spec.ts"],
63
68
  graphicAssociate: ["tests/browser/player-graphic.spec.ts"],
64
69
  graphicGapMatch: [
70
+ "packages/core/src/core-session-graphic-gap-state.test.ts",
65
71
  "tests/browser/player-graphic-gap-match.spec.ts",
66
72
  "tests/browser/player-graphic.spec.ts",
73
+ ...browserLifecycleTests,
74
+ ],
75
+ graphicOrder: [
76
+ "packages/core/src/graphic-image-contracts.test.ts",
77
+ "tests/browser/player-graphic.spec.ts",
67
78
  ],
68
- graphicOrder: ["tests/browser/player-graphic.spec.ts"],
69
79
  hotspot: ["tests/browser/player-graphic.spec.ts"],
70
80
  hottext: ["tests/browser/player-hottext.spec.ts", "tests/browser/player-dom-behavior.spec.ts"],
71
81
  inlineChoice: ["tests/browser/player-inline-choice.spec.ts"],
@@ -81,7 +91,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
81
91
  "tests/browser/player-slider-cross-browser.spec.ts",
82
92
  ...browserKeyboardA11yTests,
83
93
  ],
84
- textEntry: ["tests/browser/player-dom-behavior.spec.ts"],
94
+ textEntry: [
95
+ "packages/core/src/text-response.test.ts",
96
+ "tests/browser/player-dom-behavior.spec.ts",
97
+ ],
85
98
  upload: ["tests/browser/player-dom-behavior.spec.ts"],
86
99
  };
87
100
  return [...base, ...(extras[interactionType] ?? [])];
package/src/support.ts CHANGED
@@ -92,7 +92,11 @@ export const processingSupport: QtiProcessingElementSupport[] = [
92
92
  "packages/fixtures/xml/random-integer-template-reference.xml",
93
93
  "packages/fixtures/xml/template-content-reference.xml",
94
94
  ]),
95
- templateProcessingEntry("qti-set-default-value"),
95
+ {
96
+ ...templateProcessingEntry("qti-set-default-value"),
97
+ notes:
98
+ "Updates effective response/outcome defaults for the clone, including explicit NULL overrides; rejected template passes discard their overrides.",
99
+ },
96
100
  templateProcessingEntry("qti-set-correct-response", [
97
101
  "packages/fixtures/xml/template-processing-reference.xml",
98
102
  "packages/fixtures/xml/random-integer-template-reference.xml",
@@ -133,32 +137,65 @@ export const processingSupport: QtiProcessingElementSupport[] = [
133
137
  operatorProcessingEntry("qti-null"),
134
138
  mappingProcessingEntry("qti-match-table"),
135
139
  mappingProcessingEntry("qti-match-table-entry"),
136
- mappingProcessingEntry("qti-interpolation-table"),
140
+ {
141
+ ...mappingProcessingEntry("qti-interpolation-table"),
142
+ notes:
143
+ "Selects the first authored entry whose source-value is below the input, including equality only when include-boundary permits it.",
144
+ },
137
145
  mappingProcessingEntry("qti-interpolation-table-entry"),
138
- operatorProcessingEntry("qti-is-null"),
146
+ {
147
+ ...operatorProcessingEntry("qti-is-null"),
148
+ notes:
149
+ "Accepts one arbitrary expression, evaluates it in the full variable context, and tests the result for NULL.",
150
+ },
139
151
  mappingProcessingEntry("qti-match", [
140
152
  "packages/fixtures/xml/generic-match-processing-reference.xml",
141
153
  "packages/fixtures/xml/advanced-processing-reference.xml",
142
154
  ]),
143
155
  mappingProcessingEntry("qti-correct"),
144
- mappingProcessingEntry("qti-default"),
145
- mappingProcessingEntry("qti-map-response"),
146
- mappingProcessingEntry("qti-map-response-point"),
147
- responseProcessingEntry("qti-variable", [
148
- "packages/fixtures/xml/generic-match-processing-reference.xml",
149
- "packages/fixtures/xml/template-processing-reference.xml",
150
- "packages/fixtures/xml/random-integer-template-reference.xml",
151
- "packages/fixtures/xml/template-content-reference.xml",
152
- "packages/fixtures/xml/advanced-processing-reference.xml",
153
- "packages/fixtures/xml/adaptive-feedback-reference.xml",
154
- ]),
156
+ {
157
+ ...mappingProcessingEntry("qti-default", [], [processingTemplateTest]),
158
+ notes:
159
+ "Reads the effective authored or template-overridden default, independently of the variable's current value.",
160
+ },
161
+ {
162
+ ...mappingProcessingEntry("qti-map-response"),
163
+ notes:
164
+ "String map entries honor case-sensitive (default false); mapping counts each distinct response value once using QTI base-type equality, then applies bounds.",
165
+ },
166
+ {
167
+ ...mappingProcessingEntry("qti-map-response-point"),
168
+ notes:
169
+ "Each point selects the first matching authored area; each selected area contributes at most once.",
170
+ },
171
+ {
172
+ ...responseProcessingEntry("qti-variable", [
173
+ "packages/fixtures/xml/generic-match-processing-reference.xml",
174
+ "packages/fixtures/xml/template-processing-reference.xml",
175
+ "packages/fixtures/xml/random-integer-template-reference.xml",
176
+ "packages/fixtures/xml/template-content-reference.xml",
177
+ "packages/fixtures/xml/advanced-processing-reference.xml",
178
+ "packages/fixtures/xml/adaptive-feedback-reference.xml",
179
+ ]),
180
+ notes:
181
+ "Resolves declared variables and implicit completionStatus, numAttempts, timed-item duration (float seconds), and QTI_CONTEXT; session environment is resolved before template processing.",
182
+ tests: [
183
+ processingResponseTest,
184
+ "packages/core/src/session-builtins.test.ts",
185
+ "tests/browser/player-lifecycle.spec.ts",
186
+ ],
187
+ },
155
188
  templateProcessingEntry(
156
189
  "qti-random-integer",
157
190
  ["packages/fixtures/xml/random-integer-template-reference.xml"],
158
191
  [...processingBrowserEvidence["qti-random-integer"]],
159
192
  ),
160
193
  operatorProcessingEntry("qti-random-float"),
161
- operatorProcessingEntry("qti-random"),
194
+ {
195
+ ...operatorProcessingEntry("qti-random"),
196
+ notes:
197
+ "Evaluates one multiple/ordered container expression, including variables, then selects a scalar member; NULL/empty containers return NULL and serialization retains the operand.",
198
+ },
162
199
  operatorProcessingEntry("qti-multiple", [
163
200
  "packages/fixtures/xml/advanced-processing-reference.xml",
164
201
  ]),
@@ -166,7 +203,15 @@ export const processingSupport: QtiProcessingElementSupport[] = [
166
203
  "packages/fixtures/xml/advanced-processing-reference.xml",
167
204
  ]),
168
205
  operatorProcessingEntry("qti-index"),
169
- operatorProcessingEntry("qti-contains"),
206
+ {
207
+ ...operatorProcessingEntry(
208
+ "qti-contains",
209
+ [],
210
+ ["packages/core/src/processing-containment.test.ts"],
211
+ ),
212
+ notes:
213
+ "Multiple containers use multiset containment; ordered containers require a contiguous sequence and preserve duplicate counts, including variable/delete/repeat operands.",
214
+ },
170
215
  operatorProcessingEntry("qti-container-size", [
171
216
  "packages/fixtures/xml/advanced-processing-reference.xml",
172
217
  ]),
@@ -192,10 +237,20 @@ export const processingSupport: QtiProcessingElementSupport[] = [
192
237
  operatorProcessingEntry("qti-any-n"),
193
238
  operatorProcessingEntry("qti-or"),
194
239
  operatorProcessingEntry("qti-not"),
195
- operatorProcessingEntry("qti-equal"),
196
- operatorProcessingEntry("qti-equal-rounded", [
197
- "packages/fixtures/xml/advanced-processing-reference.xml",
198
- ]),
240
+ {
241
+ ...operatorProcessingEntry("qti-equal", [], ["packages/core/src/processing-equality.test.ts"]),
242
+ notes:
243
+ "Preserves and evaluates exact, absolute, and relative tolerance with asymmetric bounds, variable tolerances, and inclusive/exclusive boundaries.",
244
+ },
245
+ {
246
+ ...operatorProcessingEntry(
247
+ "qti-equal-rounded",
248
+ ["packages/fixtures/xml/advanced-processing-reference.xml"],
249
+ ["packages/core/src/processing-equality.test.ts"],
250
+ ),
251
+ notes:
252
+ "Defaults rounding-mode to significantFigures and resolves integer-or-variable-reference figures at evaluation time, preserving both through serialization.",
253
+ },
199
254
  mappingProcessingEntry("qti-field-value"),
200
255
  operatorProcessingEntry("qti-lt"),
201
256
  operatorProcessingEntry("qti-lte"),
@@ -203,7 +258,12 @@ export const processingSupport: QtiProcessingElementSupport[] = [
203
258
  operatorProcessingEntry("qti-gte"),
204
259
  operatorProcessingEntry("qti-string-match"),
205
260
  operatorProcessingEntry("qti-substring"),
206
- operatorProcessingEntry("qti-pattern-match"),
261
+ {
262
+ ...operatorProcessingEntry("qti-pattern-match"),
263
+ tests: [processingOperatorsTest, "packages/core/src/processing-pattern.test.ts"],
264
+ notes:
265
+ "XML Schema Appendix F whole-string matching, XML name escapes, Unicode categories/blocks and class subtraction; brace-enclosed pattern variables, NULL propagation and explicit syntax/resource diagnostics. No JavaScript-pattern fallback.",
266
+ },
207
267
  operatorProcessingEntry("qti-member"),
208
268
  operatorProcessingEntry("qti-delete"),
209
269
  operatorProcessingEntry("qti-duration-gte"),
@@ -272,10 +332,11 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
272
332
  tests: [
273
333
  processingResponseTest,
274
334
  "packages/core/src/parser-item-metadata.test.ts",
335
+ "packages/core/src/adaptive-turn-materializer.test.ts",
275
336
  ...browserFeedbackTests,
276
337
  ],
277
338
  notes:
278
- "Parsed by parser-item-metadata.ts and validated by validateModalFeedback. Response processing supplies the outcome value used for visibility, and the player renders the matching feedback in player-feedback.spec.ts.",
339
+ "Parsed by parser-item-metadata.ts and validated by validateModalFeedback. Response processing supplies the scalar/container outcome used for show/hide visibility, including inverse hide conditions, in both player rendering and adaptive candidate XML materialization.",
279
340
  },
280
341
  {
281
342
  qtiName: "qti-companion-materials-info",
@@ -401,20 +462,29 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
401
462
  process: true,
402
463
  fixtures: interactionSupportFixtures(interactionType),
403
464
  tests: interactionSupportTests(interactionType),
465
+ notes: ["associate", "graphicAssociate", "match", "gapMatch"].includes(interactionType)
466
+ ? interactionType === "associate" || interactionType === "graphicAssociate"
467
+ ? "Supports single and multiple unordered pair responses."
468
+ : "Supports single and multiple directedPair responses."
469
+ : interactionType === "graphicGapMatch"
470
+ ? "Requires multiple directedPair responses; multiple and repeated placements in one hotspot preserve each association through rendering, removal, scoring, and restoration, subject to source/target match-max and max-associations."
471
+ : undefined,
404
472
  };
405
473
  }
406
474
 
407
475
  function extendedTextInteractionEntry(): QtiInteractionElementSupport {
408
476
  return {
409
477
  ...entry("qti-extended-text-interaction", "extendedText"),
410
- notes: "Supports plain and format=xhtml extended text.",
478
+ notes:
479
+ "Supports single, multiple, ordered and numeric record responses; string/integer/float values; min-strings/max-strings; plain, preformatted and string XHTML capture.",
411
480
  };
412
481
  }
413
482
 
414
483
  function textEntryInteractionEntry(): QtiInteractionElementSupport {
415
484
  return {
416
485
  ...entry("qti-text-entry-interaction", "textEntry"),
417
- notes: "Supports placeholder-text and pattern-mask on text-entry controls.",
486
+ notes:
487
+ "Supports single string/integer/float and numeric record responses, base 2–36, string-identifier, placeholder-text and pattern-mask; text-entry format is plain only.",
418
488
  };
419
489
  }
420
490
 
@@ -0,0 +1,106 @@
1
+ import type { QtiInteraction, QtiRecordValue, QtiValue } from "./types.js";
2
+
3
+ /** Capture text using the interaction's declared response type and authored radix. */
4
+ export function captureQtiTextResponse(interaction: QtiInteraction, text: string): QtiValue {
5
+ if (interaction.responseCardinality === "record") return numericRecord(text, radix(interaction));
6
+ if (interaction.responseBaseType === "string") return text;
7
+ if (text.trim() === "") return null;
8
+ const record = numericRecord(text, radix(interaction));
9
+ return interaction.responseBaseType === "integer"
10
+ ? (record.integerValue ?? null)
11
+ : (record.floatValue ?? null);
12
+ }
13
+
14
+ /** Recover editable text, including the original lexical form in a numeric record. */
15
+ export function qtiTextResponseString(value: QtiValue): string {
16
+ if (value !== null && typeof value === "object") {
17
+ return !Array.isArray(value) && typeof value.stringValue === "string" ? value.stringValue : "";
18
+ }
19
+ return value === null ? "" : String(value);
20
+ }
21
+
22
+ /** Format a captured scalar in its authored radix, preserving companion and record text. */
23
+ export function formatQtiTextResponse(interaction: QtiInteraction, value: QtiValue): string {
24
+ const base = radix(interaction);
25
+ if (typeof value === "number" && Number.isInteger(base) && base >= 2 && base <= 36) {
26
+ return value.toString(base);
27
+ }
28
+ return qtiTextResponseString(value);
29
+ }
30
+
31
+ function radix(interaction: QtiInteraction): number {
32
+ return Number(interaction.attributes.base ?? 10);
33
+ }
34
+
35
+ function numericRecord(text: string, base: number): QtiRecordValue {
36
+ const empty: QtiRecordValue = {
37
+ stringValue: text,
38
+ floatValue: null,
39
+ integerValue: null,
40
+ leftDigits: null,
41
+ rightDigits: null,
42
+ ndp: null,
43
+ nsf: null,
44
+ exponent: null,
45
+ };
46
+ if (!Number.isInteger(base) || base < 2 || base > 36) return empty;
47
+ const normalized = text.trim();
48
+ const match = (
49
+ base === 10
50
+ ? /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/
51
+ : /^([+-]?)([0-9a-z]*)(?:\.([0-9a-z]*))?$/i
52
+ ).exec(normalized);
53
+ if (!match) return empty;
54
+ const left = match[2] ?? "";
55
+ const right = match[3] ?? "";
56
+ const digits = left + right;
57
+ if (!digits || digits.split("").some((digit) => Number.parseInt(digit, 36) >= base)) return empty;
58
+ const exponent = match[4] === undefined ? null : Number(match[4]);
59
+ if (exponent !== null && !Number.isSafeInteger(exponent)) return empty;
60
+ const ndp = Math.max(0, right.length - (exponent ?? 0));
61
+ if (!Number.isSafeInteger(ndp)) return empty;
62
+ const magnitude =
63
+ (Number.parseInt(left || "0", base) +
64
+ right
65
+ .split("")
66
+ .reduce(
67
+ (sum, digit, index) => sum + Number.parseInt(digit, base) / base ** (index + 1),
68
+ 0,
69
+ )) *
70
+ base ** (exponent ?? 0);
71
+ const value = base === 10 ? Number(normalized) : match[1] === "-" ? -magnitude : magnitude;
72
+ if (!Number.isFinite(value)) return empty;
73
+ const integer =
74
+ match[3] === undefined && exponent === null && Number.isSafeInteger(value) ? value : null;
75
+ return {
76
+ stringValue: text,
77
+ floatValue: value,
78
+ integerValue: integer,
79
+ leftDigits: left.length,
80
+ rightDigits: right.length,
81
+ ndp,
82
+ nsf: digits.replace(/^0+/, "").length || 1,
83
+ exponent,
84
+ };
85
+ }
86
+
87
+ export function isQtiTextResponseRecord(value: QtiValue): boolean {
88
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
89
+ const integerFields = new Set([
90
+ "integerValue",
91
+ "leftDigits",
92
+ "rightDigits",
93
+ "ndp",
94
+ "nsf",
95
+ "exponent",
96
+ ]);
97
+ return Object.entries(value).every(([field, entry]) => {
98
+ if (field === "stringValue") return typeof entry === "string" || entry === null;
99
+ if (field === "floatValue")
100
+ return entry === null || (typeof entry === "number" && Number.isFinite(entry));
101
+ return (
102
+ integerFields.has(field) &&
103
+ (entry === null || (typeof entry === "number" && Number.isSafeInteger(entry)))
104
+ );
105
+ });
106
+ }
@@ -1,5 +1,6 @@
1
+ import type { QtiSessionEnvironment } from "./session-builtins.js";
1
2
  import { parseQtiXml } from "./parser.js";
2
- import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
3
+ import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
3
4
  import { parseQtiResponseVariables } from "./response-validation.js";
4
5
  import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
5
6
  import type {
@@ -31,6 +32,8 @@ export interface QtiTrustedResponseApplication {
31
32
  }
32
33
 
33
34
  export interface QtiTrustedItemParseOptions {
35
+ /** Trusted host environment; never take duration/context from candidate response fields. */
36
+ sessionEnvironment?: QtiSessionEnvironment | undefined;
34
37
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
35
38
  }
36
39
 
@@ -48,7 +51,7 @@ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
48
51
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
49
52
  submission: QtiTrustedResponseApplication;
50
53
  priorState?: QtiAttemptStateV1 | null | undefined;
51
- /** Applied before submission. Intended for server-side scoring, not adaptive turns. */
54
+ /** Applied before submission, except completed is applied after capture. For server scoring, not adaptive turns. */
52
55
  attemptStatus?: QtiAttemptStatus | undefined;
53
56
  scoring: QtiTrustedItemScoringPolicy;
54
57
  requireNumericScore: boolean;
@@ -156,12 +159,15 @@ export function runTrustedItemSession(
156
159
  input.diagnosticPrefix,
157
160
  input.allowedUndeclaredResponseIdentifiers,
158
161
  parsedResult.parsed.diagnostics,
162
+ input.sessionEnvironment,
159
163
  );
160
164
  if (!sessionResult.ok) {
161
165
  return emptyTrustedItemSessionFailure(sessionResult.diagnostics);
162
166
  }
163
167
 
164
- if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
168
+ if (input.attemptStatus && input.attemptStatus !== "completed") {
169
+ sessionResult.session.setStatus(input.attemptStatus);
170
+ }
165
171
 
166
172
  let submission = input.submission;
167
173
  if (input.submissionValidation === "strict") {
@@ -189,7 +195,11 @@ export function runTrustedItemSession(
189
195
  return emptyTrustedItemSessionFailure(diagnostics);
190
196
  }
191
197
 
198
+ // Capture the submitted attempt before closing it, so built-in counters reflect participation.
199
+ if (input.attemptStatus === "completed") sessionResult.session.setStatus("completed");
200
+
192
201
  const shouldScore = input.scoring === "always" || applicationResult.appliedSubmission;
202
+ const externalScore = itemHasExternalScore(parsedResult.parsed.document.item);
193
203
 
194
204
  let outcomes = sessionResult.session.serialize().outcomes;
195
205
  let state = sessionResult.session.serialize();
@@ -212,7 +222,7 @@ export function runTrustedItemSession(
212
222
  scoredResult.scored.state,
213
223
  parsedResult.parsed.responseIdentifiers,
214
224
  );
215
- score = readNumericScore(outcomes.SCORE);
225
+ score = externalScore ? null : readNumericScore(outcomes.SCORE);
216
226
 
217
227
  if (scoredResult.scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
218
228
  return emptyTrustedItemSessionFailure(scoredDiagnostics, {
@@ -241,7 +251,7 @@ export function runTrustedItemSession(
241
251
  } else {
242
252
  state = stripUndeclaredResponses(state, parsedResult.parsed.responseIdentifiers);
243
253
  outcomes = state.outcomes;
244
- score = readNumericScore(outcomes.SCORE);
254
+ score = externalScore ? null : readNumericScore(outcomes.SCORE);
245
255
  }
246
256
 
247
257
  return {
@@ -276,11 +286,13 @@ function createTrustedItemSession(
276
286
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
277
287
  allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
278
288
  existingDiagnostics: QtiDiagnostic[] = [],
289
+ sessionEnvironment: QtiSessionEnvironment = {},
279
290
  ): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
280
291
  try {
281
292
  return {
282
293
  ok: true,
283
294
  session: createItemSession(parsed.document, priorState ?? undefined, {
295
+ ...sessionEnvironment,
284
296
  allowedUndeclaredResponseIdentifiers,
285
297
  }),
286
298
  };