@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.
- package/README.md +88 -8
- package/dist/adaptive-turn-materializer.d.ts +2 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -1
- package/dist/adaptive-turn-materializer.js +25 -19
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts +8 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +15 -5
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/delivery-preparation.d.ts +20 -0
- package/dist/delivery-preparation.d.ts.map +1 -0
- package/dist/delivery-preparation.js +71 -0
- package/dist/delivery-preparation.js.map +1 -0
- package/dist/delivery-redaction.d.ts +5 -0
- package/dist/delivery-redaction.d.ts.map +1 -1
- package/dist/delivery-redaction.js +26 -5
- package/dist/delivery-redaction.js.map +1 -1
- package/dist/delivery-security.js +2 -2
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +4 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -0
- package/dist/item-scoring-expectation.js +6 -0
- package/dist/item-scoring-expectation.js.map +1 -0
- package/dist/item-submission-materialization.d.ts +25 -0
- package/dist/item-submission-materialization.d.ts.map +1 -0
- package/dist/item-submission-materialization.js +68 -0
- package/dist/item-submission-materialization.js.map +1 -0
- package/dist/qti-package-assets.d.ts +8 -0
- package/dist/qti-package-assets.d.ts.map +1 -0
- package/dist/qti-package-assets.js +173 -0
- package/dist/qti-package-assets.js.map +1 -0
- package/dist/qti-package-items.d.ts +14 -0
- package/dist/qti-package-items.d.ts.map +1 -0
- package/dist/qti-package-items.js +138 -0
- package/dist/qti-package-items.js.map +1 -0
- package/dist/qti-package-manifest.d.ts +13 -0
- package/dist/qti-package-manifest.d.ts.map +1 -0
- package/dist/qti-package-manifest.js +109 -0
- package/dist/qti-package-manifest.js.map +1 -0
- package/dist/qti-package-metadata.d.ts +8 -0
- package/dist/qti-package-metadata.d.ts.map +1 -0
- package/dist/qti-package-metadata.js +115 -0
- package/dist/qti-package-metadata.js.map +1 -0
- package/dist/qti-package-paths.d.ts +6 -0
- package/dist/qti-package-paths.d.ts.map +1 -0
- package/dist/qti-package-paths.js +32 -0
- package/dist/qti-package-paths.js.map +1 -0
- package/dist/qti-package-types.d.ts +98 -0
- package/dist/qti-package-types.d.ts.map +1 -0
- package/dist/qti-package-types.js +2 -0
- package/dist/qti-package-types.js.map +1 -0
- package/dist/qti-package-xml.d.ts +21 -0
- package/dist/qti-package-xml.d.ts.map +1 -0
- package/dist/qti-package-xml.js +68 -0
- package/dist/qti-package-xml.js.map +1 -0
- package/dist/qti-package-zip.d.ts +24 -0
- package/dist/qti-package-zip.d.ts.map +1 -0
- package/dist/qti-package-zip.js +185 -0
- package/dist/qti-package-zip.js.map +1 -0
- package/dist/qti-package.d.ts +7 -0
- package/dist/qti-package.d.ts.map +1 -0
- package/dist/qti-package.js +76 -0
- package/dist/qti-package.js.map +1 -0
- package/dist/response-input.d.ts +9 -0
- package/dist/response-input.d.ts.map +1 -0
- package/dist/response-input.js +9 -0
- package/dist/response-input.js.map +1 -0
- package/dist/response-validation-policy.d.ts +31 -0
- package/dist/response-validation-policy.d.ts.map +1 -0
- package/dist/response-validation-policy.js +158 -0
- package/dist/response-validation-policy.js.map +1 -0
- package/dist/response-validation.d.ts +23 -0
- package/dist/response-validation.d.ts.map +1 -0
- package/dist/response-validation.js +157 -0
- package/dist/response-validation.js.map +1 -0
- package/dist/scoring-disposition-policy.d.ts +8 -0
- package/dist/scoring-disposition-policy.d.ts.map +1 -0
- package/dist/scoring-disposition-policy.js +24 -0
- package/dist/scoring-disposition-policy.js.map +1 -0
- package/dist/template-presentation-materializer.d.ts +14 -0
- package/dist/template-presentation-materializer.d.ts.map +1 -0
- package/dist/template-presentation-materializer.js +143 -0
- package/dist/template-presentation-materializer.js.map +1 -0
- package/dist/trusted-item-session.d.ts +25 -8
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +51 -46
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/trusted-session-materialization.d.ts +33 -0
- package/dist/trusted-session-materialization.d.ts.map +1 -0
- package/dist/trusted-session-materialization.js +26 -0
- package/dist/trusted-session-materialization.js.map +1 -0
- package/dist/validation-primitives.d.ts +1 -0
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +5 -0
- package/dist/validation-primitives.js.map +1 -1
- package/dist/variable-snapshot.d.ts +9 -0
- package/dist/variable-snapshot.d.ts.map +1 -0
- package/dist/variable-snapshot.js +9 -0
- package/dist/variable-snapshot.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +29 -16
- package/src/adaptive-turn.ts +29 -6
- package/src/delivery-preparation.ts +121 -0
- package/src/delivery-redaction.ts +36 -5
- package/src/delivery-security.ts +2 -2
- package/src/index.ts +65 -0
- package/src/item-scoring-expectation.ts +9 -0
- package/src/item-submission-materialization.ts +120 -0
- package/src/qti-package-assets.ts +256 -0
- package/src/qti-package-items.ts +240 -0
- package/src/qti-package-manifest.ts +210 -0
- package/src/qti-package-metadata.ts +143 -0
- package/src/qti-package-paths.ts +56 -0
- package/src/qti-package-types.ts +117 -0
- package/src/qti-package-xml.ts +97 -0
- package/src/qti-package-zip.ts +325 -0
- package/src/qti-package.ts +176 -0
- package/src/response-input.ts +19 -0
- package/src/response-validation-policy.ts +218 -0
- package/src/response-validation.ts +282 -0
- package/src/scoring-disposition-policy.ts +37 -0
- package/src/template-presentation-materializer.ts +219 -0
- package/src/trusted-item-session.ts +80 -74
- package/src/trusted-session-materialization.ts +67 -0
- package/src/validation-primitives.ts +5 -0
- 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
|
|
20
|
+
export type QtiTrustedResponseInput = QtiNamedResponseInput;
|
|
15
21
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
22
|
+
export type QtiTrustedResponsesInput = QtiNamedResponsesInput;
|
|
23
|
+
|
|
24
|
+
export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn" | "itemSubmission";
|
|
20
25
|
|
|
21
|
-
export type
|
|
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
|
|
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
|
|
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 =
|
|
99
|
-
input.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
}
|