@longsightgroup/qti3-core 0.10.6 → 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 (204) hide show
  1. package/README.md +126 -0
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/diagnostics.d.ts +13 -0
  11. package/dist/diagnostics.d.ts.map +1 -0
  12. package/dist/diagnostics.js +28 -0
  13. package/dist/diagnostics.js.map +1 -0
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/item-submission-materialization.d.ts.map +1 -1
  19. package/dist/item-submission-materialization.js +2 -0
  20. package/dist/item-submission-materialization.js.map +1 -1
  21. package/dist/parser-processing.d.ts.map +1 -1
  22. package/dist/parser-processing.js +8 -18
  23. package/dist/parser-processing.js.map +1 -1
  24. package/dist/parser-values.d.ts.map +1 -1
  25. package/dist/parser-values.js +1 -3
  26. package/dist/parser-values.js.map +1 -1
  27. package/dist/parser.js +32 -0
  28. package/dist/parser.js.map +1 -1
  29. package/dist/processing-equality.d.ts +10 -0
  30. package/dist/processing-equality.d.ts.map +1 -0
  31. package/dist/processing-equality.js +78 -0
  32. package/dist/processing-equality.js.map +1 -0
  33. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  34. package/dist/processing-evaluator-comparison.js +14 -9
  35. package/dist/processing-evaluator-comparison.js.map +1 -1
  36. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  37. package/dist/processing-evaluator-numeric.js +2 -1
  38. package/dist/processing-evaluator-numeric.js.map +1 -1
  39. package/dist/processing-evaluator-string.d.ts.map +1 -1
  40. package/dist/processing-evaluator-string.js +26 -10
  41. package/dist/processing-evaluator-string.js.map +1 -1
  42. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  43. package/dist/processing-evaluator-variable.js +9 -3
  44. package/dist/processing-evaluator-variable.js.map +1 -1
  45. package/dist/processing-evaluator.d.ts +5 -1
  46. package/dist/processing-evaluator.d.ts.map +1 -1
  47. package/dist/processing-evaluator.js +13 -4
  48. package/dist/processing-evaluator.js.map +1 -1
  49. package/dist/processing-expression-children.d.ts.map +1 -1
  50. package/dist/processing-expression-children.js +2 -3
  51. package/dist/processing-expression-children.js.map +1 -1
  52. package/dist/processing-expression-tags.d.ts +2 -1
  53. package/dist/processing-expression-tags.d.ts.map +1 -1
  54. package/dist/processing-expression-tags.js +2 -1
  55. package/dist/processing-expression-tags.js.map +1 -1
  56. package/dist/processing-mapping.d.ts.map +1 -1
  57. package/dist/processing-mapping.js +26 -16
  58. package/dist/processing-mapping.js.map +1 -1
  59. package/dist/processing-state.d.ts +2 -2
  60. package/dist/processing-state.d.ts.map +1 -1
  61. package/dist/processing-state.js +5 -2
  62. package/dist/processing-state.js.map +1 -1
  63. package/dist/processing-values.d.ts +1 -2
  64. package/dist/processing-values.d.ts.map +1 -1
  65. package/dist/processing-values.js +1 -3
  66. package/dist/processing-values.js.map +1 -1
  67. package/dist/processing-variables.d.ts.map +1 -1
  68. package/dist/processing-variables.js +10 -5
  69. package/dist/processing-variables.js.map +1 -1
  70. package/dist/qti-package-assets.d.ts.map +1 -1
  71. package/dist/qti-package-assets.js +16 -10
  72. package/dist/qti-package-assets.js.map +1 -1
  73. package/dist/qti-package-manifest.d.ts +1 -0
  74. package/dist/qti-package-manifest.d.ts.map +1 -1
  75. package/dist/qti-package-manifest.js +1 -0
  76. package/dist/qti-package-manifest.js.map +1 -1
  77. package/dist/qti-package-metadata.d.ts.map +1 -1
  78. package/dist/qti-package-metadata.js +11 -0
  79. package/dist/qti-package-metadata.js.map +1 -1
  80. package/dist/qti-package-plan.d.ts +1 -1
  81. package/dist/qti-package-plan.d.ts.map +1 -1
  82. package/dist/qti-package-types.d.ts +9 -0
  83. package/dist/qti-package-types.d.ts.map +1 -1
  84. package/dist/qti-package-xml.d.ts +6 -1
  85. package/dist/qti-package-xml.d.ts.map +1 -1
  86. package/dist/qti-package-xml.js +11 -6
  87. package/dist/qti-package-xml.js.map +1 -1
  88. package/dist/qti-package-zip.d.ts +13 -1
  89. package/dist/qti-package-zip.d.ts.map +1 -1
  90. package/dist/qti-package-zip.js +185 -54
  91. package/dist/qti-package-zip.js.map +1 -1
  92. package/dist/qti-package.d.ts +12 -5
  93. package/dist/qti-package.d.ts.map +1 -1
  94. package/dist/qti-package.js +63 -8
  95. package/dist/qti-package.js.map +1 -1
  96. package/dist/response-validation-policy.d.ts.map +1 -1
  97. package/dist/response-validation-policy.js +3 -1
  98. package/dist/response-validation-policy.js.map +1 -1
  99. package/dist/response-validation.d.ts.map +1 -1
  100. package/dist/response-validation.js +2 -1
  101. package/dist/response-validation.js.map +1 -1
  102. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  103. package/dist/serializer-processing-expressions.js +2 -10
  104. package/dist/serializer-processing-expressions.js.map +1 -1
  105. package/dist/server-scoring.d.ts.map +1 -1
  106. package/dist/server-scoring.js +1 -0
  107. package/dist/server-scoring.js.map +1 -1
  108. package/dist/session-builtins.d.ts +16 -0
  109. package/dist/session-builtins.d.ts.map +1 -0
  110. package/dist/session-builtins.js +58 -0
  111. package/dist/session-builtins.js.map +1 -0
  112. package/dist/session.d.ts +5 -1
  113. package/dist/session.d.ts.map +1 -1
  114. package/dist/session.js +63 -11
  115. package/dist/session.js.map +1 -1
  116. package/dist/support-evidence.d.ts.map +1 -1
  117. package/dist/support-evidence.js +2 -0
  118. package/dist/support-evidence.js.map +1 -1
  119. package/dist/support.d.ts.map +1 -1
  120. package/dist/support.js +64 -23
  121. package/dist/support.js.map +1 -1
  122. package/dist/trusted-item-session.d.ts +4 -1
  123. package/dist/trusted-item-session.d.ts.map +1 -1
  124. package/dist/trusted-item-session.js +8 -3
  125. package/dist/trusted-item-session.js.map +1 -1
  126. package/dist/types.d.ts +21 -3
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/validation-declaration-entries.d.ts.map +1 -1
  129. package/dist/validation-declaration-entries.js +11 -1
  130. package/dist/validation-declaration-entries.js.map +1 -1
  131. package/dist/validation-item-children.d.ts +4 -0
  132. package/dist/validation-item-children.d.ts.map +1 -0
  133. package/dist/validation-item-children.js +45 -0
  134. package/dist/validation-item-children.js.map +1 -0
  135. package/dist/validation-processing.d.ts.map +1 -1
  136. package/dist/validation-processing.js +45 -12
  137. package/dist/validation-processing.js.map +1 -1
  138. package/dist/validation.d.ts.map +1 -1
  139. package/dist/validation.js +12 -0
  140. package/dist/validation.js.map +1 -1
  141. package/dist/value-guards.d.ts +4 -0
  142. package/dist/value-guards.d.ts.map +1 -0
  143. package/dist/value-guards.js +5 -0
  144. package/dist/value-guards.js.map +1 -0
  145. package/dist/xml-schema-regex-data.d.ts +48 -0
  146. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  147. package/dist/xml-schema-regex-data.js +403 -0
  148. package/dist/xml-schema-regex-data.js.map +1 -0
  149. package/dist/xml-schema-regex-parser.d.ts +31 -0
  150. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  151. package/dist/xml-schema-regex-parser.js +245 -0
  152. package/dist/xml-schema-regex-parser.js.map +1 -0
  153. package/dist/xml-schema-regex.d.ts +14 -0
  154. package/dist/xml-schema-regex.d.ts.map +1 -0
  155. package/dist/xml-schema-regex.js +114 -0
  156. package/dist/xml-schema-regex.js.map +1 -0
  157. package/package.json +1 -1
  158. package/src/adaptive-turn-materializer.ts +4 -3
  159. package/src/adaptive-turn.ts +1 -0
  160. package/src/attempt-state.ts +54 -1
  161. package/src/diagnostics.ts +29 -0
  162. package/src/index.ts +9 -0
  163. package/src/item-submission-materialization.ts +2 -0
  164. package/src/parser-processing.ts +15 -18
  165. package/src/parser-values.ts +1 -5
  166. package/src/parser.ts +34 -0
  167. package/src/processing-equality.ts +104 -0
  168. package/src/processing-evaluator-comparison.ts +19 -14
  169. package/src/processing-evaluator-numeric.ts +2 -6
  170. package/src/processing-evaluator-string.ts +25 -18
  171. package/src/processing-evaluator-variable.ts +8 -3
  172. package/src/processing-evaluator.ts +20 -7
  173. package/src/processing-expression-children.ts +2 -3
  174. package/src/processing-expression-tags.ts +2 -1
  175. package/src/processing-mapping.ts +31 -25
  176. package/src/processing-state.ts +6 -1
  177. package/src/processing-values.ts +2 -11
  178. package/src/processing-variables.ts +9 -7
  179. package/src/qti-package-assets.ts +20 -13
  180. package/src/qti-package-manifest.ts +1 -0
  181. package/src/qti-package-metadata.ts +11 -0
  182. package/src/qti-package-plan.ts +1 -1
  183. package/src/qti-package-types.ts +10 -0
  184. package/src/qti-package-xml.ts +11 -6
  185. package/src/qti-package-zip.ts +330 -97
  186. package/src/qti-package.ts +106 -7
  187. package/src/response-validation-policy.ts +5 -1
  188. package/src/response-validation.ts +2 -1
  189. package/src/serializer-processing-expressions.ts +8 -25
  190. package/src/server-scoring.ts +1 -0
  191. package/src/session-builtins.ts +75 -0
  192. package/src/session.ts +64 -8
  193. package/src/support-evidence.ts +2 -0
  194. package/src/support.ts +84 -23
  195. package/src/trusted-item-session.ts +13 -2
  196. package/src/types.ts +30 -4
  197. package/src/validation-declaration-entries.ts +11 -1
  198. package/src/validation-item-children.ts +48 -0
  199. package/src/validation-processing.ts +47 -10
  200. package/src/validation.ts +14 -0
  201. package/src/value-guards.ts +6 -0
  202. package/src/xml-schema-regex-data.ts +404 -0
  203. package/src/xml-schema-regex-parser.ts +234 -0
  204. package/src/xml-schema-regex.ts +115 -0
@@ -1,26 +1,42 @@
1
- import { pushPackageDiagnostic } from "./qti-package-paths.js";
1
+ import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
2
2
  import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
3
- import { buildPackageSummary, inspectPackageManifest } from "./qti-package-plan.js";
3
+ import {
4
+ buildPackageSummary,
5
+ failedPackageSummary,
6
+ inspectPackageManifest,
7
+ } from "./qti-package-plan.js";
4
8
  import {
5
9
  itemReferencesForPackageShape,
6
10
  parseAssessmentTestPackageModel,
7
11
  parsePackageItems,
8
12
  } from "./qti-package-items.js";
9
13
  import type { QtiPackageParseResult } from "./qti-package-types.js";
10
- import { parseXmlFiles } from "./qti-package-xml.js";
14
+ import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
11
15
  import {
12
16
  DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
13
17
  decodeUtf8,
14
18
  readQtiPackageZipEntries,
19
+ readQtiPackageZipEntriesAsync,
20
+ resolveResourceLimits,
15
21
  type QtiPackageEntry,
16
22
  type QtiPackageParseOptions,
17
23
  } from "./qti-package-zip.js";
18
24
  import type { QtiDiagnostic } from "./types.js";
19
25
 
20
- export { DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS, decodeUtf8, readQtiPackageZipEntries };
26
+ export {
27
+ DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
28
+ decodeUtf8,
29
+ readQtiPackageZipEntries,
30
+ readQtiPackageZipEntriesAsync,
31
+ };
21
32
  export { discoverQtiPackageContentAssets } from "./qti-package-assets.js";
22
33
  export { normalizePackagePath } from "./qti-package-paths.js";
23
- export { QTI_ITEM_RESOURCE_TYPE, QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
34
+ export { scopeDiagnosticToPackagePath } from "./qti-package-xml.js";
35
+ export {
36
+ QTI_ITEM_RESOURCE_TYPE,
37
+ QTI_PACKAGE_MANIFEST_PATH,
38
+ isQtiItemResource,
39
+ } from "./qti-package-manifest.js";
24
40
  export type { QtiPackageEntry };
25
41
 
26
42
  export type {
@@ -35,6 +51,7 @@ export type {
35
51
  QtiPackageItem,
36
52
  QtiPackageItemSource,
37
53
  QtiPackageParseResult,
54
+ QtiPackageXmlFileSummary,
38
55
  QtiPackageShape,
39
56
  QtiItemSessionControl,
40
57
  QtiStandardAlignment,
@@ -48,6 +65,8 @@ export type {
48
65
  export type {
49
66
  QtiPackageInflateContext,
50
67
  QtiPackageInflateRaw,
68
+ QtiPackageInflateRawAsync,
69
+ QtiPackageZipAsyncOptions,
51
70
  QtiPackageParseOptions,
52
71
  QtiPackageResourceLimits,
53
72
  } from "./qti-package-zip.js";
@@ -59,12 +78,82 @@ export function parseQtiPackage(
59
78
  ): QtiPackageParseResult {
60
79
  const diagnostics: QtiDiagnostic[] = [];
61
80
  const entries = readQtiPackageZipEntries(bytes, options, diagnostics);
62
- return parseQtiPackageEntries(entries, options, diagnostics);
81
+ return parseQtiPackageEntries(entries, diagnostics);
82
+ }
83
+
84
+ /**
85
+ * Parse already extracted or restored entries with the same package semantics as ZIP input.
86
+ * Paths must be unique and canonical. Entry count and expanded byte limits apply;
87
+ * compression-ratio limits are irrelevant after extraction. Inspect ok before use.
88
+ */
89
+ export function parseQtiPackageFromEntries(
90
+ entries: readonly QtiPackageEntry[],
91
+ options: Pick<QtiPackageParseOptions, "limits"> = {},
92
+ ): QtiPackageParseResult {
93
+ const diagnostics: QtiDiagnostic[] = [];
94
+ const limits = resolveResourceLimits(options.limits, diagnostics);
95
+ if (limits) {
96
+ if (entries.length > limits.maxEntries) {
97
+ pushPackageDiagnostic(
98
+ diagnostics,
99
+ "package.entries.limit",
100
+ "error",
101
+ "Package contains more entries than allowed.",
102
+ );
103
+ } else {
104
+ let total = 0;
105
+ const paths = new Set<string>();
106
+ for (const entry of entries) {
107
+ const path = normalizePackagePath(entry.path, "entry", diagnostics);
108
+ if (path === undefined) break;
109
+ if (!path || path !== entry.path || path.includes("\\") || path.includes("\0")) {
110
+ pushPackageDiagnostic(
111
+ diagnostics,
112
+ "package.entry.path",
113
+ "error",
114
+ "Package entry paths must be canonical and package-relative.",
115
+ entry.path,
116
+ );
117
+ break;
118
+ }
119
+ if (paths.has(path)) {
120
+ pushPackageDiagnostic(
121
+ diagnostics,
122
+ "package.entry.duplicate",
123
+ "error",
124
+ `QTI package contains duplicate entry ${path}.`,
125
+ path,
126
+ );
127
+ break;
128
+ }
129
+ paths.add(path);
130
+ total += entry.bytes.byteLength;
131
+ if (
132
+ entry.bytes.byteLength > limits.maxEntryUncompressedBytes ||
133
+ !Number.isSafeInteger(total) ||
134
+ total > limits.maxTotalUncompressedBytes
135
+ ) {
136
+ pushPackageDiagnostic(
137
+ diagnostics,
138
+ "package.bytes.limit",
139
+ "error",
140
+ "Package entry or total size exceeds its configured budget.",
141
+ path,
142
+ );
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ if (diagnostics.length) {
149
+ const { itemCount: _itemCount, ...summary } = failedPackageSummary(diagnostics);
150
+ return { ...summary, entries: [], items: [], xmlFiles: [] };
151
+ }
152
+ return parseQtiPackageEntries(entries, diagnostics);
63
153
  }
64
154
 
65
155
  function parseQtiPackageEntries(
66
156
  entries: readonly QtiPackageEntry[],
67
- options: QtiPackageParseOptions,
68
157
  diagnostics: QtiDiagnostic[],
69
158
  ): QtiPackageParseResult {
70
159
  const entriesByPath = indexEntries(entries, diagnostics);
@@ -116,6 +205,16 @@ function parseQtiPackageEntries(
116
205
  ...summary,
117
206
  entries,
118
207
  items,
208
+ xmlFiles: [...xmlFilesByPath.values()].map((file) => {
209
+ const xmlDiagnostics: QtiDiagnostic[] = [];
210
+ pushXmlDiagnostics(file, xmlDiagnostics);
211
+ return {
212
+ path: file.path,
213
+ rootLocalName: file.root?.localName,
214
+ rootNamespaceUri: file.root?.uri,
215
+ diagnostics: xmlDiagnostics,
216
+ };
217
+ }),
119
218
  };
120
219
  }
121
220
 
@@ -134,11 +134,15 @@ export function responseValidationPolicy(
134
134
  authoredMinimum !== undefined ||
135
135
  (minimum !== undefined && minimum > 0);
136
136
  const maximum = maximumAllowedResponses(interaction);
137
+ const hasMatchMaximum = interaction?.choices.some(
138
+ (choice) => choiceMatchMaximum(choice) !== undefined,
139
+ );
137
140
  if (
138
141
  declaration.correctResponse === null &&
139
142
  interaction?.type !== "media" &&
140
143
  !validatesMinimum &&
141
- maximum === undefined
144
+ maximum === undefined &&
145
+ !hasMatchMaximum
142
146
  ) {
143
147
  return { checkMinimum: false, checkMaximum: false, checkMatchMax: false };
144
148
  }
@@ -10,7 +10,8 @@ import type {
10
10
  } from "./types.js";
11
11
  import { isQtiTextResponseRecord, qtiTextResponseString } from "./text-response.js";
12
12
  import { assertNever } from "./assert-never.js";
13
- import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
13
+ import { isNullResponse, valueContainer } from "./processing-values.js";
14
+ import { isRecordValue } from "./value-guards.js";
14
15
  import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
15
16
  import {
16
17
  matchMaxDiagnostics,
@@ -79,8 +79,6 @@ export function serializeExpression(
79
79
  return serializeBaseValue(expression, context, indent);
80
80
  case "null":
81
81
  return renderElement("qti-null", [], [], indent);
82
- case "isNull":
83
- return serializeIsNull(expression, context, indent);
84
82
  case "matchCorrect":
85
83
  return serializeMatchCorrect(expression, context, indent);
86
84
  case "randomInteger":
@@ -104,13 +102,6 @@ export function serializeExpression(
104
102
  [],
105
103
  indent,
106
104
  );
107
- case "random":
108
- return renderElement(
109
- "qti-random",
110
- [],
111
- expression.values.flatMap((value) => serializeExpression(value, context, indent + 1)),
112
- indent,
113
- );
114
105
  case "index":
115
106
  return renderExpressionContainer(
116
107
  "qti-index",
@@ -143,6 +134,14 @@ export function serializeExpression(
143
134
  );
144
135
  case "not":
145
136
  return renderExpressionContainer("qti-not", [], [expression.expression], context, indent);
137
+ case "equal":
138
+ return renderExpressionContainer(
139
+ "qti-equal",
140
+ sortedBagAttributes(expression.attributes),
141
+ [expression.left, expression.right],
142
+ context,
143
+ indent,
144
+ );
146
145
  case "equalRounded":
147
146
  return renderExpressionContainer(
148
147
  "qti-equal-rounded",
@@ -337,22 +336,6 @@ function baseValueText(
337
336
  return qtiValueToString(expression.value);
338
337
  }
339
338
 
340
- function serializeIsNull(
341
- expression: Extract<QtiProcessingExpression, { type: "isNull" }>,
342
- context: SerializationContext,
343
- indent: number,
344
- ): string[] {
345
- if (!requireIdentifier(expression.identifier, "qti-is-null", expression.source, context)) {
346
- return [];
347
- }
348
- return renderElement(
349
- "qti-is-null",
350
- [],
351
- renderElement("qti-variable", [["identifier", expression.identifier]], [], indent + 1),
352
- indent,
353
- );
354
- }
355
-
356
339
  function serializeMatchCorrect(
357
340
  expression: Extract<QtiProcessingExpression, { type: "matchCorrect" }>,
358
341
  context: SerializationContext,
@@ -28,6 +28,7 @@ export function scoreQtiItemServerSide(input: QtiServerScoringInput): QtiServerS
28
28
  itemXml: input.itemXml,
29
29
  diagnosticPrefix: "serverScoring",
30
30
  allowedUndeclaredResponseIdentifiers: input.allowedUndeclaredResponseIdentifiers,
31
+ sessionEnvironment: input.sessionEnvironment,
31
32
  submission: {
32
33
  trustedResponses: input.trustedResponses,
33
34
  trustedInteractionStates: input.trustedInteractionStates,
@@ -0,0 +1,75 @@
1
+ import type { QtiBuiltInVariables, QtiDiagnostic, QtiSessionContext, QtiValue } from "./types.js";
2
+
3
+ export interface QtiSessionEnvironment {
4
+ /** Host context, resolved before template processing. Unspecified fields are empty strings. */
5
+ context?: Partial<QtiSessionContext> | undefined;
6
+ /** Accumulated item-session seconds supplied by a scoring host. */
7
+ duration?: number | undefined;
8
+ /** Monotonic milliseconds supplied by a delivery host; suspension time is excluded. */
9
+ now?: (() => number) | undefined;
10
+ }
11
+
12
+ export function createSessionBuiltIns(
13
+ prior: QtiBuiltInVariables | undefined,
14
+ options: QtiSessionEnvironment,
15
+ report: (diagnostic: QtiDiagnostic) => void,
16
+ ) {
17
+ const state: QtiBuiltInVariables = {
18
+ numAttempts: prior?.numAttempts ?? 0,
19
+ attemptInProgress: prior?.attemptInProgress ?? false,
20
+ duration: options.duration ?? prior?.duration ?? (options.now ? 0 : null),
21
+ context: {
22
+ candidateIdentifier:
23
+ options.context?.candidateIdentifier ?? prior?.context.candidateIdentifier ?? "",
24
+ testIdentifier: options.context?.testIdentifier ?? prior?.context.testIdentifier ?? "",
25
+ environmentIdentifier:
26
+ options.context?.environmentIdentifier ?? prior?.context.environmentIdentifier ?? "",
27
+ },
28
+ };
29
+ let previousTime = options.now?.();
30
+ let elapsed = state.duration;
31
+ let timingValid = elapsed === null || (Number.isFinite(elapsed) && elapsed >= 0);
32
+ if (!timingValid) state.duration = elapsed = null;
33
+
34
+ function captureTime(active: boolean): void {
35
+ if (!options.now) return;
36
+ const current = options.now();
37
+ if (
38
+ !Number.isFinite(current) ||
39
+ previousTime === undefined ||
40
+ !Number.isFinite(previousTime) ||
41
+ current < previousTime
42
+ ) {
43
+ timingValid = false;
44
+ elapsed = state.duration = null;
45
+ } else if (active && elapsed !== null) {
46
+ elapsed += (current - previousTime) / 1000;
47
+ // Preserve clock precision across suspension and restoration.
48
+ state.duration = elapsed;
49
+ }
50
+ previousTime = current;
51
+ }
52
+
53
+ function duration(): QtiValue {
54
+ if (state.duration !== null && timingValid) return Math.floor(state.duration * 1000) / 1000;
55
+ report({
56
+ code: timingValid ? "session.duration.unavailable" : "session.duration.invalid",
57
+ severity: "error",
58
+ message: timingValid
59
+ ? "Reading duration requires host-supplied accumulated seconds or a monotonic clock."
60
+ : "Duration requires finite non-negative seconds and a monotonic finite clock.",
61
+ });
62
+ return null;
63
+ }
64
+
65
+ return { state, captureTime, duration };
66
+ }
67
+
68
+ export function builtInVariableBaseType(
69
+ identifier: string,
70
+ ): "integer" | "float" | "identifier" | undefined {
71
+ if (identifier === "numAttempts") return "integer";
72
+ if (identifier === "duration") return "float";
73
+ if (identifier === "completionStatus") return "identifier";
74
+ return undefined;
75
+ }
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
  }
@@ -67,8 +67,10 @@ export function browserTestsFor(interactionType: QtiInteractionType): string[] {
67
67
  gapMatch: ["tests/browser/player-gap-match.spec.ts"],
68
68
  graphicAssociate: ["tests/browser/player-graphic.spec.ts"],
69
69
  graphicGapMatch: [
70
+ "packages/core/src/core-session-graphic-gap-state.test.ts",
70
71
  "tests/browser/player-graphic-gap-match.spec.ts",
71
72
  "tests/browser/player-graphic.spec.ts",
73
+ ...browserLifecycleTests,
72
74
  ],
73
75
  graphicOrder: [
74
76
  "packages/core/src/graphic-image-contracts.test.ts",