@longsightgroup/qti3-core 0.9.3 → 0.9.4

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 (130) hide show
  1. package/README.md +88 -8
  2. package/dist/adaptive-turn-materializer.d.ts +2 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -1
  4. package/dist/adaptive-turn-materializer.js +25 -19
  5. package/dist/adaptive-turn-materializer.js.map +1 -1
  6. package/dist/adaptive-turn.d.ts +8 -1
  7. package/dist/adaptive-turn.d.ts.map +1 -1
  8. package/dist/adaptive-turn.js +15 -5
  9. package/dist/adaptive-turn.js.map +1 -1
  10. package/dist/delivery-preparation.d.ts +20 -0
  11. package/dist/delivery-preparation.d.ts.map +1 -0
  12. package/dist/delivery-preparation.js +71 -0
  13. package/dist/delivery-preparation.js.map +1 -0
  14. package/dist/delivery-redaction.d.ts +5 -0
  15. package/dist/delivery-redaction.d.ts.map +1 -1
  16. package/dist/delivery-redaction.js +26 -5
  17. package/dist/delivery-redaction.js.map +1 -1
  18. package/dist/delivery-security.js +2 -2
  19. package/dist/delivery-security.js.map +1 -1
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/item-scoring-expectation.d.ts +4 -0
  25. package/dist/item-scoring-expectation.d.ts.map +1 -0
  26. package/dist/item-scoring-expectation.js +6 -0
  27. package/dist/item-scoring-expectation.js.map +1 -0
  28. package/dist/item-submission-materialization.d.ts +25 -0
  29. package/dist/item-submission-materialization.d.ts.map +1 -0
  30. package/dist/item-submission-materialization.js +68 -0
  31. package/dist/item-submission-materialization.js.map +1 -0
  32. package/dist/qti-package-assets.d.ts +8 -0
  33. package/dist/qti-package-assets.d.ts.map +1 -0
  34. package/dist/qti-package-assets.js +173 -0
  35. package/dist/qti-package-assets.js.map +1 -0
  36. package/dist/qti-package-items.d.ts +14 -0
  37. package/dist/qti-package-items.d.ts.map +1 -0
  38. package/dist/qti-package-items.js +138 -0
  39. package/dist/qti-package-items.js.map +1 -0
  40. package/dist/qti-package-manifest.d.ts +13 -0
  41. package/dist/qti-package-manifest.d.ts.map +1 -0
  42. package/dist/qti-package-manifest.js +109 -0
  43. package/dist/qti-package-manifest.js.map +1 -0
  44. package/dist/qti-package-metadata.d.ts +8 -0
  45. package/dist/qti-package-metadata.d.ts.map +1 -0
  46. package/dist/qti-package-metadata.js +115 -0
  47. package/dist/qti-package-metadata.js.map +1 -0
  48. package/dist/qti-package-paths.d.ts +6 -0
  49. package/dist/qti-package-paths.d.ts.map +1 -0
  50. package/dist/qti-package-paths.js +32 -0
  51. package/dist/qti-package-paths.js.map +1 -0
  52. package/dist/qti-package-types.d.ts +98 -0
  53. package/dist/qti-package-types.d.ts.map +1 -0
  54. package/dist/qti-package-types.js +2 -0
  55. package/dist/qti-package-types.js.map +1 -0
  56. package/dist/qti-package-xml.d.ts +21 -0
  57. package/dist/qti-package-xml.d.ts.map +1 -0
  58. package/dist/qti-package-xml.js +68 -0
  59. package/dist/qti-package-xml.js.map +1 -0
  60. package/dist/qti-package-zip.d.ts +24 -0
  61. package/dist/qti-package-zip.d.ts.map +1 -0
  62. package/dist/qti-package-zip.js +185 -0
  63. package/dist/qti-package-zip.js.map +1 -0
  64. package/dist/qti-package.d.ts +7 -0
  65. package/dist/qti-package.d.ts.map +1 -0
  66. package/dist/qti-package.js +76 -0
  67. package/dist/qti-package.js.map +1 -0
  68. package/dist/response-input.d.ts +9 -0
  69. package/dist/response-input.d.ts.map +1 -0
  70. package/dist/response-input.js +9 -0
  71. package/dist/response-input.js.map +1 -0
  72. package/dist/response-validation-policy.d.ts +31 -0
  73. package/dist/response-validation-policy.d.ts.map +1 -0
  74. package/dist/response-validation-policy.js +158 -0
  75. package/dist/response-validation-policy.js.map +1 -0
  76. package/dist/response-validation.d.ts +23 -0
  77. package/dist/response-validation.d.ts.map +1 -0
  78. package/dist/response-validation.js +157 -0
  79. package/dist/response-validation.js.map +1 -0
  80. package/dist/scoring-disposition-policy.d.ts +8 -0
  81. package/dist/scoring-disposition-policy.d.ts.map +1 -0
  82. package/dist/scoring-disposition-policy.js +24 -0
  83. package/dist/scoring-disposition-policy.js.map +1 -0
  84. package/dist/template-presentation-materializer.d.ts +14 -0
  85. package/dist/template-presentation-materializer.d.ts.map +1 -0
  86. package/dist/template-presentation-materializer.js +143 -0
  87. package/dist/template-presentation-materializer.js.map +1 -0
  88. package/dist/trusted-item-session.d.ts +25 -8
  89. package/dist/trusted-item-session.d.ts.map +1 -1
  90. package/dist/trusted-item-session.js +51 -46
  91. package/dist/trusted-item-session.js.map +1 -1
  92. package/dist/trusted-session-materialization.d.ts +33 -0
  93. package/dist/trusted-session-materialization.d.ts.map +1 -0
  94. package/dist/trusted-session-materialization.js +26 -0
  95. package/dist/trusted-session-materialization.js.map +1 -0
  96. package/dist/validation-primitives.d.ts +1 -0
  97. package/dist/validation-primitives.d.ts.map +1 -1
  98. package/dist/validation-primitives.js +5 -0
  99. package/dist/validation-primitives.js.map +1 -1
  100. package/dist/variable-snapshot.d.ts +9 -0
  101. package/dist/variable-snapshot.d.ts.map +1 -0
  102. package/dist/variable-snapshot.js +9 -0
  103. package/dist/variable-snapshot.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/adaptive-turn-materializer.ts +29 -16
  106. package/src/adaptive-turn.ts +29 -6
  107. package/src/delivery-preparation.ts +121 -0
  108. package/src/delivery-redaction.ts +36 -5
  109. package/src/delivery-security.ts +2 -2
  110. package/src/index.ts +65 -0
  111. package/src/item-scoring-expectation.ts +9 -0
  112. package/src/item-submission-materialization.ts +120 -0
  113. package/src/qti-package-assets.ts +256 -0
  114. package/src/qti-package-items.ts +240 -0
  115. package/src/qti-package-manifest.ts +210 -0
  116. package/src/qti-package-metadata.ts +143 -0
  117. package/src/qti-package-paths.ts +56 -0
  118. package/src/qti-package-types.ts +117 -0
  119. package/src/qti-package-xml.ts +97 -0
  120. package/src/qti-package-zip.ts +325 -0
  121. package/src/qti-package.ts +176 -0
  122. package/src/response-input.ts +19 -0
  123. package/src/response-validation-policy.ts +218 -0
  124. package/src/response-validation.ts +282 -0
  125. package/src/scoring-disposition-policy.ts +37 -0
  126. package/src/template-presentation-materializer.ts +219 -0
  127. package/src/trusted-item-session.ts +80 -74
  128. package/src/trusted-session-materialization.ts +67 -0
  129. package/src/validation-primitives.ts +5 -0
  130. package/src/variable-snapshot.ts +20 -0
@@ -0,0 +1,219 @@
1
+ import {
2
+ applySourceRangeEdits,
3
+ parseDeliveryXml,
4
+ sourceRangeFor,
5
+ type SourceRangeEdit,
6
+ } from "./delivery-redaction.js";
7
+ import type { QtiDiagnostic, QtiValue } from "./types.js";
8
+ import { qtiValueToString } from "./value-format.js";
9
+ import { descendants, escapeXmlText, type XmlNode } from "./xml.js";
10
+
11
+ interface TemplateBlockBinding {
12
+ readonly templateIdentifier: string;
13
+ readonly identifier: string;
14
+ readonly value: QtiValue;
15
+ }
16
+
17
+ export interface QtiTemplatePresentationVariables {
18
+ readonly outcomes: Record<string, QtiValue>;
19
+ readonly templateValues: Record<string, QtiValue>;
20
+ readonly responses?: Record<string, QtiValue> | undefined;
21
+ }
22
+
23
+ export function materializeTemplatePresentation(
24
+ xml: string,
25
+ variables: QtiTemplatePresentationVariables,
26
+ ): { ok: true; xml: string } | { ok: false; diagnostics: QtiDiagnostic[] } {
27
+ const parsed = parseDeliveryXml(xml);
28
+ if (!parsed.root) return { ok: true, xml };
29
+
30
+ const diagnostics: QtiDiagnostic[] = [];
31
+ const edits: SourceRangeEdit[] = [];
32
+ const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
33
+
34
+ for (const node of nodes) {
35
+ if (isInsideTemplateContentNode(node)) continue;
36
+
37
+ if (node.localName === "qti-printed-variable") {
38
+ const edit = printedVariableEdit(node, variables, diagnostics);
39
+ if (edit) edits.push(edit);
40
+ continue;
41
+ }
42
+
43
+ if (node.localName === "qti-template-block" || node.localName === "qti-template-inline") {
44
+ const edit = templateContentEdit(xml, node, variables, diagnostics);
45
+ if (edit) edits.push(edit);
46
+ }
47
+ }
48
+
49
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
50
+ return { ok: false, diagnostics };
51
+ }
52
+
53
+ return { ok: true, xml: applySourceRangeEdits(xml, edits) };
54
+ }
55
+
56
+ function printedVariableEdit(
57
+ node: XmlNode,
58
+ variables: QtiTemplatePresentationVariables,
59
+ diagnostics: QtiDiagnostic[],
60
+ ): SourceRangeEdit | undefined {
61
+ const text = resolvePrintedVariableText(node, variables, diagnostics);
62
+ if (text === undefined) return undefined;
63
+
64
+ const range = sourceRangeFor(node)[0];
65
+ if (!range) {
66
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
67
+ return undefined;
68
+ }
69
+
70
+ return { ...range, text };
71
+ }
72
+
73
+ function templateContentEdit(
74
+ xml: string,
75
+ node: XmlNode,
76
+ variables: QtiTemplatePresentationVariables,
77
+ diagnostics: QtiDiagnostic[],
78
+ ): SourceRangeEdit | undefined {
79
+ const binding = resolveTemplateBlockBinding(node, variables.templateValues, diagnostics);
80
+ if (!binding) return undefined;
81
+
82
+ const range = sourceRangeFor(node)[0];
83
+ if (!range) {
84
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
85
+ return undefined;
86
+ }
87
+
88
+ return {
89
+ ...range,
90
+ text: isTemplateContentVisible(node, binding.value)
91
+ ? materializedContentXml(xml, node, variables, diagnostics)
92
+ : "",
93
+ };
94
+ }
95
+
96
+ function resolvePrintedVariableText(
97
+ node: XmlNode,
98
+ variables: QtiTemplatePresentationVariables,
99
+ diagnostics: QtiDiagnostic[],
100
+ ): string | undefined {
101
+ const identifier = node.attributes.identifier;
102
+ if (!identifier) {
103
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
104
+ return undefined;
105
+ }
106
+
107
+ const value = resolvePresentationVariable(identifier, variables);
108
+ if (value === undefined) {
109
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
110
+ return undefined;
111
+ }
112
+
113
+ return escapeXmlText(qtiValueToString(value));
114
+ }
115
+
116
+ function resolvePresentationVariable(
117
+ identifier: string,
118
+ variables: QtiTemplatePresentationVariables,
119
+ ): QtiValue | undefined {
120
+ return (
121
+ variables.outcomes[identifier] ??
122
+ variables.templateValues[identifier] ??
123
+ variables.responses?.[identifier]
124
+ );
125
+ }
126
+
127
+ function resolveTemplateBlockBinding(
128
+ node: XmlNode,
129
+ templateValues: Record<string, QtiValue>,
130
+ diagnostics: QtiDiagnostic[],
131
+ ): TemplateBlockBinding | undefined {
132
+ const templateIdentifier = node.attributes["template-identifier"];
133
+ const identifier = node.attributes.identifier;
134
+ if (!templateIdentifier || !identifier || templateValues[templateIdentifier] === undefined) {
135
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
136
+ return undefined;
137
+ }
138
+
139
+ return { templateIdentifier, identifier, value: templateValues[templateIdentifier] };
140
+ }
141
+
142
+ function materializedContentXml(
143
+ xml: string,
144
+ node: XmlNode,
145
+ variables: QtiTemplatePresentationVariables,
146
+ diagnostics: QtiDiagnostic[],
147
+ ): string {
148
+ return node.content
149
+ .map((child) => {
150
+ if (typeof child === "string") return escapeXmlText(child);
151
+ if (child.localName === "qti-printed-variable") {
152
+ return resolvePrintedVariableText(child, variables, diagnostics) ?? "";
153
+ }
154
+ if (child.localName === "qti-template-block" || child.localName === "qti-template-inline") {
155
+ return materializedNestedTemplateContentXml(xml, child, variables, diagnostics);
156
+ }
157
+ return materializedElementXml(xml, child, variables, diagnostics);
158
+ })
159
+ .join("");
160
+ }
161
+
162
+ function materializedNestedTemplateContentXml(
163
+ xml: string,
164
+ node: XmlNode,
165
+ variables: QtiTemplatePresentationVariables,
166
+ diagnostics: QtiDiagnostic[],
167
+ ): string {
168
+ const binding = resolveTemplateBlockBinding(node, variables.templateValues, diagnostics);
169
+ if (!binding) return "";
170
+
171
+ return isTemplateContentVisible(node, binding.value)
172
+ ? materializedContentXml(xml, node, variables, diagnostics)
173
+ : "";
174
+ }
175
+
176
+ function isTemplateContentVisible(node: XmlNode, value: QtiValue): boolean {
177
+ const identifier = node.attributes.identifier;
178
+ const hasIdentifier = Array.isArray(value)
179
+ ? value.map(String).includes(identifier ?? "")
180
+ : qtiValueToString(value) === identifier;
181
+ return node.attributes["show-hide"] === "hide" ? !hasIdentifier : hasIdentifier;
182
+ }
183
+
184
+ function materializedElementXml(
185
+ xml: string,
186
+ node: XmlNode,
187
+ variables: QtiTemplatePresentationVariables,
188
+ diagnostics: QtiDiagnostic[],
189
+ ): string {
190
+ if (node.sourceRange.endOffset === undefined || node.endSource === undefined) {
191
+ diagnostics.push(unsupportedTemplateMaterializationDiagnostic(node));
192
+ return "";
193
+ }
194
+
195
+ const startTag = xml.slice(node.sourceRange.startOffset, node.sourceRange.startTagEndOffset + 1);
196
+ const endTag = xml.slice(node.endSource.offset, node.sourceRange.endOffset);
197
+ return `${startTag}${materializedContentXml(xml, node, variables, diagnostics)}${endTag}`;
198
+ }
199
+
200
+ function isInsideTemplateContentNode(node: XmlNode): boolean {
201
+ let parent = node.parent;
202
+ while (parent) {
203
+ if (parent.localName === "qti-template-block" || parent.localName === "qti-template-inline") {
204
+ return true;
205
+ }
206
+ parent = parent.parent;
207
+ }
208
+ return false;
209
+ }
210
+
211
+ function unsupportedTemplateMaterializationDiagnostic(node: XmlNode): QtiDiagnostic {
212
+ return {
213
+ code: "adaptiveTurn.materialization.unsupported",
214
+ severity: "error",
215
+ message: `${node.name} could not be materialized from authoritative template state.`,
216
+ path: node.source.path,
217
+ source: node.source,
218
+ };
219
+ }
@@ -1,7 +1,8 @@
1
1
  import { parseQtiXml } from "./parser.js";
2
+ import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
3
+ import { validateQtiResponseVariables } from "./response-validation.js";
2
4
  import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
3
5
  import type {
4
- QtiAssessmentItem,
5
6
  QtiAttemptStateV1,
6
7
  QtiAttemptStatus,
7
8
  QtiDiagnostic,
@@ -10,20 +11,20 @@ import type {
10
11
  QtiValue,
11
12
  } from "./types.js";
12
13
  import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
14
+ import {
15
+ listNamedResponseInputs,
16
+ type QtiNamedResponseInput,
17
+ type QtiNamedResponsesInput,
18
+ } from "./response-input.js";
13
19
 
14
- export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn";
20
+ export type QtiTrustedResponseInput = QtiNamedResponseInput;
15
21
 
16
- export interface QtiTrustedResponseInput {
17
- identifier: string;
18
- value: unknown;
19
- }
22
+ export type QtiTrustedResponsesInput = QtiNamedResponsesInput;
23
+
24
+ export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn" | "itemSubmission";
20
25
 
21
- export type QtiTrustedResponsesInput =
22
- | Record<string, unknown>
23
- | readonly QtiTrustedResponseInput[]
24
- | undefined;
26
+ export type QtiTrustedSubmissionValidation = "trusted" | "strict";
25
27
 
26
- /** Trusted candidate responses and portable custom interaction state for one application. */
27
28
  export interface QtiTrustedResponseApplication {
28
29
  trustedResponses?: QtiTrustedResponsesInput;
29
30
  trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
@@ -33,7 +34,7 @@ export interface QtiTrustedItemParseOptions {
33
34
  allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
34
35
  }
35
36
 
36
- interface QtiParsedTrustedItem {
37
+ export interface QtiTrustedItemDocument {
37
38
  document: QtiDocument;
38
39
  diagnostics: QtiDiagnostic[];
39
40
  responseIdentifiers: Set<string>;
@@ -43,6 +44,7 @@ export type QtiTrustedItemScoringPolicy = "always" | "onSubmission";
43
44
 
44
45
  export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
45
46
  itemXml: string;
47
+ parsedItem?: QtiTrustedItemDocument | undefined;
46
48
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
47
49
  submission: QtiTrustedResponseApplication;
48
50
  priorState?: QtiAttemptStateV1 | null | undefined;
@@ -50,6 +52,12 @@ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
50
52
  attemptStatus?: QtiAttemptStatus | undefined;
51
53
  scoring: QtiTrustedItemScoringPolicy;
52
54
  requireNumericScore: boolean;
55
+ /**
56
+ * `strict` rejects undeclared and malformed responses before trusted application.
57
+ * `trusted` keeps the legacy warning path for undeclared identifiers.
58
+ */
59
+ submissionValidation?: QtiTrustedSubmissionValidation | undefined;
60
+ allowIncompleteResponses?: boolean | undefined;
53
61
  }
54
62
 
55
63
  export interface RunTrustedItemSessionSuccess {
@@ -86,19 +94,58 @@ export function readPriorAttemptState(
86
94
  {
87
95
  code: `${diagnosticPrefix}.state.value`,
88
96
  severity: "error",
89
- message: "Prior adaptive turn state is not a valid qti3.attempt-state.v1 value.",
97
+ message: "Prior attempt state is not a valid qti3.attempt-state.v1 value.",
90
98
  },
91
99
  ],
92
100
  };
93
101
  }
94
102
 
103
+ export function parseTrustedItemDocument(
104
+ itemXml: string,
105
+ allowedUndeclaredResponseIdentifiers: readonly string[] = [],
106
+ ): { ok: true; parsed: QtiTrustedItemDocument } | { ok: false; diagnostics: QtiDiagnostic[] } {
107
+ let parsed: ReturnType<typeof parseQtiXml>;
108
+ try {
109
+ parsed = parseQtiXml(itemXml);
110
+ } catch (error) {
111
+ return {
112
+ ok: false,
113
+ diagnostics: [
114
+ {
115
+ code: "xml.parse",
116
+ severity: "error",
117
+ message: error instanceof Error ? error.message : String(error),
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
124
+ const diagnostics = parsed.diagnostics.filter(
125
+ (diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
126
+ );
127
+ if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
128
+ return { ok: false, diagnostics };
129
+ }
130
+
131
+ return {
132
+ ok: true,
133
+ parsed: {
134
+ document: parsed.document,
135
+ diagnostics,
136
+ responseIdentifiers: new Set(
137
+ parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
138
+ ),
139
+ },
140
+ };
141
+ }
142
+
95
143
  export function runTrustedItemSession(
96
144
  input: RunTrustedItemSessionInput,
97
145
  ): RunTrustedItemSessionResult {
98
- const parsedResult = parseTrustedItemXml(
99
- input.itemXml,
100
- input.allowedUndeclaredResponseIdentifiers,
101
- );
146
+ const parsedResult = input.parsedItem
147
+ ? { ok: true as const, parsed: input.parsedItem }
148
+ : parseTrustedItemDocument(input.itemXml, input.allowedUndeclaredResponseIdentifiers);
102
149
  if (!parsedResult.ok) {
103
150
  return emptyTrustedItemSessionFailure(parsedResult.diagnostics);
104
151
  }
@@ -116,6 +163,18 @@ export function runTrustedItemSession(
116
163
 
117
164
  if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
118
165
 
166
+ if (input.submissionValidation === "strict") {
167
+ const validation = validateQtiResponseVariables({
168
+ item: parsedResult.parsed.document.item,
169
+ responses: input.submission.trustedResponses ?? {},
170
+ allowIncompleteResponses: input.allowIncompleteResponses,
171
+ allowedUndeclaredResponseIdentifiers: input.allowedUndeclaredResponseIdentifiers,
172
+ });
173
+ if (!validation.ok) {
174
+ return emptyTrustedItemSessionFailure(validation.diagnostics);
175
+ }
176
+ }
177
+
119
178
  const applicationResult = applyTrustedResponseApplication(
120
179
  sessionResult.session,
121
180
  parsedResult.parsed,
@@ -164,7 +223,7 @@ export function runTrustedItemSession(
164
223
 
165
224
  if (
166
225
  input.requireNumericScore &&
167
- shouldRequireNumericScore(parsedResult.parsed.document.item) &&
226
+ itemExpectsAutomatedScore(parsedResult.parsed.document.item) &&
168
227
  score === null
169
228
  ) {
170
229
  return emptyTrustedItemSessionFailure(
@@ -209,48 +268,8 @@ function emptyTrustedItemSessionFailure(
209
268
  };
210
269
  }
211
270
 
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
271
  function createTrustedItemSession(
253
- parsed: QtiParsedTrustedItem,
272
+ parsed: QtiTrustedItemDocument,
254
273
  priorState: QtiAttemptStateV1 | null | undefined,
255
274
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
256
275
  allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
@@ -280,7 +299,7 @@ function createTrustedItemSession(
280
299
 
281
300
  function applyTrustedResponseApplication(
282
301
  session: QtiItemSession,
283
- parsed: QtiParsedTrustedItem,
302
+ parsed: QtiTrustedItemDocument,
284
303
  submission: QtiTrustedResponseApplication,
285
304
  allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
286
305
  diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
@@ -289,7 +308,7 @@ function applyTrustedResponseApplication(
289
308
  const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers ?? []);
290
309
  let appliedSubmission = false;
291
310
 
292
- for (const response of normalizeResponseInputs(submission.trustedResponses)) {
311
+ for (const response of listNamedResponseInputs(submission.trustedResponses)) {
293
312
  const identifier = response.identifier.trim();
294
313
  if (!identifier) {
295
314
  diagnostics.push({
@@ -371,13 +390,6 @@ function scoreTrustedItemSession(
371
390
  }
372
391
  }
373
392
 
374
- function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
375
- return (
376
- Boolean(item.responseProcessing) ||
377
- item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
378
- );
379
- }
380
-
381
393
  function readNumericScore(value: QtiValue | undefined): number | null {
382
394
  if (typeof value === "number" && Number.isFinite(value)) return value;
383
395
  if (typeof value === "string") {
@@ -411,12 +423,6 @@ function stripUndeclaredResponses(
411
423
  };
412
424
  }
413
425
 
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
426
  function isAllowedUndeclaredVariableReference(
421
427
  diagnostic: QtiDiagnostic,
422
428
  allowedUndeclaredResponseIdentifiers: Set<string>,
@@ -0,0 +1,67 @@
1
+ import {
2
+ classifyQtiItemScoringDisposition,
3
+ type QtiItemSubmissionScoringDisposition,
4
+ } from "./scoring-disposition-policy.js";
5
+ import type { QtiAssessmentItem, QtiAttemptStateV1, QtiDiagnostic, QtiValue } from "./types.js";
6
+ import { snapshotQtiVariableDeclarations, type QtiVariableSnapshot } from "./variable-snapshot.js";
7
+
8
+ export interface QtiTrustedSessionMaterialization {
9
+ diagnostics: QtiDiagnostic[];
10
+ state: QtiAttemptStateV1;
11
+ responses: Record<string, QtiValue>;
12
+ outcomes: Record<string, QtiValue>;
13
+ score: number | null;
14
+ candidateSafeXml?: string | undefined;
15
+ }
16
+
17
+ export interface QtiItemSubmissionMaterializationSuccess {
18
+ ok: true;
19
+ diagnostics: QtiDiagnostic[];
20
+ state: QtiAttemptStateV1;
21
+ responseVariables: QtiVariableSnapshot[];
22
+ outcomeVariables: QtiVariableSnapshot[];
23
+ score: number | null;
24
+ scoringDisposition: Exclude<QtiItemSubmissionScoringDisposition, "invalid">;
25
+ candidateSafeXml?: string | undefined;
26
+ }
27
+
28
+ export interface QtiItemSubmissionMaterializationFailure {
29
+ ok: false;
30
+ diagnostics: QtiDiagnostic[];
31
+ state?: QtiAttemptStateV1 | undefined;
32
+ responseVariables: QtiVariableSnapshot[];
33
+ outcomeVariables: QtiVariableSnapshot[];
34
+ score: null;
35
+ scoringDisposition: "invalid";
36
+ }
37
+
38
+ export function buildItemSubmissionMaterializationSuccess(
39
+ item: QtiAssessmentItem,
40
+ result: QtiTrustedSessionMaterialization,
41
+ ): QtiItemSubmissionMaterializationSuccess {
42
+ return {
43
+ ok: true,
44
+ diagnostics: result.diagnostics,
45
+ state: result.state,
46
+ responseVariables: snapshotQtiVariableDeclarations(item.responseDeclarations, result.responses),
47
+ outcomeVariables: snapshotQtiVariableDeclarations(item.outcomeDeclarations, result.outcomes),
48
+ score: result.score,
49
+ scoringDisposition: classifyQtiItemScoringDisposition(item, result.score),
50
+ candidateSafeXml: result.candidateSafeXml,
51
+ };
52
+ }
53
+
54
+ export function buildItemSubmissionMaterializationFailure(
55
+ diagnostics: QtiDiagnostic[],
56
+ state?: QtiAttemptStateV1,
57
+ ): QtiItemSubmissionMaterializationFailure {
58
+ return {
59
+ ok: false,
60
+ diagnostics,
61
+ state,
62
+ responseVariables: [],
63
+ outcomeVariables: [],
64
+ score: null,
65
+ scoringDisposition: "invalid",
66
+ };
67
+ }
@@ -48,6 +48,11 @@ export function isNonNegativeInteger(value: string): boolean {
48
48
  return /^\d+$/.test(value);
49
49
  }
50
50
 
51
+ export function parseNonNegativeInteger(value: string | undefined): number | undefined {
52
+ if (value === undefined || !isNonNegativeInteger(value)) return undefined;
53
+ return Number(value);
54
+ }
55
+
51
56
  export function isBooleanAttribute(value: string): boolean {
52
57
  return value === "true" || value === "false" || value === "1" || value === "0";
53
58
  }
@@ -0,0 +1,20 @@
1
+ import type { QtiBaseType, QtiCardinality, QtiValue, QtiVariableDeclaration } from "./types.js";
2
+
3
+ export interface QtiVariableSnapshot {
4
+ identifier: string;
5
+ value: QtiValue;
6
+ cardinality: QtiCardinality;
7
+ baseType?: QtiBaseType | undefined;
8
+ }
9
+
10
+ export function snapshotQtiVariableDeclarations(
11
+ declarations: readonly QtiVariableDeclaration[],
12
+ values: Record<string, QtiValue>,
13
+ ): QtiVariableSnapshot[] {
14
+ return declarations.map((declaration) => ({
15
+ identifier: declaration.identifier,
16
+ value: values[declaration.identifier] ?? null,
17
+ cardinality: declaration.cardinality,
18
+ baseType: declaration.baseType,
19
+ }));
20
+ }