@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.
- package/README.md +126 -0
- package/dist/adaptive-turn-materializer.js +4 -5
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +1 -0
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +41 -1
- package/dist/attempt-state.js.map +1 -1
- package/dist/diagnostics.d.ts +13 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +28 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/item-submission-materialization.d.ts.map +1 -1
- package/dist/item-submission-materialization.js +2 -0
- package/dist/item-submission-materialization.js.map +1 -1
- package/dist/parser-processing.d.ts.map +1 -1
- package/dist/parser-processing.js +8 -18
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +1 -3
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.js +32 -0
- package/dist/parser.js.map +1 -1
- package/dist/processing-equality.d.ts +10 -0
- package/dist/processing-equality.d.ts.map +1 -0
- package/dist/processing-equality.js +78 -0
- package/dist/processing-equality.js.map +1 -0
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +14 -9
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-numeric.d.ts.map +1 -1
- package/dist/processing-evaluator-numeric.js +2 -1
- package/dist/processing-evaluator-numeric.js.map +1 -1
- package/dist/processing-evaluator-string.d.ts.map +1 -1
- package/dist/processing-evaluator-string.js +26 -10
- package/dist/processing-evaluator-string.js.map +1 -1
- package/dist/processing-evaluator-variable.d.ts.map +1 -1
- package/dist/processing-evaluator-variable.js +9 -3
- package/dist/processing-evaluator-variable.js.map +1 -1
- package/dist/processing-evaluator.d.ts +5 -1
- package/dist/processing-evaluator.d.ts.map +1 -1
- package/dist/processing-evaluator.js +13 -4
- package/dist/processing-evaluator.js.map +1 -1
- package/dist/processing-expression-children.d.ts.map +1 -1
- package/dist/processing-expression-children.js +2 -3
- package/dist/processing-expression-children.js.map +1 -1
- package/dist/processing-expression-tags.d.ts +2 -1
- package/dist/processing-expression-tags.d.ts.map +1 -1
- package/dist/processing-expression-tags.js +2 -1
- package/dist/processing-expression-tags.js.map +1 -1
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +26 -16
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-state.d.ts +2 -2
- package/dist/processing-state.d.ts.map +1 -1
- package/dist/processing-state.js +5 -2
- package/dist/processing-state.js.map +1 -1
- package/dist/processing-values.d.ts +1 -2
- package/dist/processing-values.d.ts.map +1 -1
- package/dist/processing-values.js +1 -3
- package/dist/processing-values.js.map +1 -1
- package/dist/processing-variables.d.ts.map +1 -1
- package/dist/processing-variables.js +10 -5
- package/dist/processing-variables.js.map +1 -1
- package/dist/qti-package-assets.d.ts.map +1 -1
- package/dist/qti-package-assets.js +16 -10
- package/dist/qti-package-assets.js.map +1 -1
- package/dist/qti-package-manifest.d.ts +1 -0
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js +1 -0
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +11 -0
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-plan.d.ts +1 -1
- package/dist/qti-package-plan.d.ts.map +1 -1
- package/dist/qti-package-types.d.ts +9 -0
- package/dist/qti-package-types.d.ts.map +1 -1
- package/dist/qti-package-xml.d.ts +6 -1
- package/dist/qti-package-xml.d.ts.map +1 -1
- package/dist/qti-package-xml.js +11 -6
- package/dist/qti-package-xml.js.map +1 -1
- package/dist/qti-package-zip.d.ts +13 -1
- package/dist/qti-package-zip.d.ts.map +1 -1
- package/dist/qti-package-zip.js +185 -54
- package/dist/qti-package-zip.js.map +1 -1
- package/dist/qti-package.d.ts +12 -5
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +63 -8
- package/dist/qti-package.js.map +1 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +3 -1
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +2 -1
- package/dist/response-validation.js.map +1 -1
- package/dist/serializer-processing-expressions.d.ts.map +1 -1
- package/dist/serializer-processing-expressions.js +2 -10
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/server-scoring.d.ts.map +1 -1
- package/dist/server-scoring.js +1 -0
- package/dist/server-scoring.js.map +1 -1
- package/dist/session-builtins.d.ts +16 -0
- package/dist/session-builtins.d.ts.map +1 -0
- package/dist/session-builtins.js +58 -0
- package/dist/session-builtins.js.map +1 -0
- package/dist/session.d.ts +5 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -11
- package/dist/session.js.map +1 -1
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +2 -0
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +64 -23
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts +4 -1
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +8 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +21 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-declaration-entries.d.ts.map +1 -1
- package/dist/validation-declaration-entries.js +11 -1
- package/dist/validation-declaration-entries.js.map +1 -1
- package/dist/validation-item-children.d.ts +4 -0
- package/dist/validation-item-children.d.ts.map +1 -0
- package/dist/validation-item-children.js +45 -0
- package/dist/validation-item-children.js.map +1 -0
- package/dist/validation-processing.d.ts.map +1 -1
- package/dist/validation-processing.js +45 -12
- package/dist/validation-processing.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +12 -0
- package/dist/validation.js.map +1 -1
- package/dist/value-guards.d.ts +4 -0
- package/dist/value-guards.d.ts.map +1 -0
- package/dist/value-guards.js +5 -0
- package/dist/value-guards.js.map +1 -0
- package/dist/xml-schema-regex-data.d.ts +48 -0
- package/dist/xml-schema-regex-data.d.ts.map +1 -0
- package/dist/xml-schema-regex-data.js +403 -0
- package/dist/xml-schema-regex-data.js.map +1 -0
- package/dist/xml-schema-regex-parser.d.ts +31 -0
- package/dist/xml-schema-regex-parser.d.ts.map +1 -0
- package/dist/xml-schema-regex-parser.js +245 -0
- package/dist/xml-schema-regex-parser.js.map +1 -0
- package/dist/xml-schema-regex.d.ts +14 -0
- package/dist/xml-schema-regex.d.ts.map +1 -0
- package/dist/xml-schema-regex.js +114 -0
- package/dist/xml-schema-regex.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +4 -3
- package/src/adaptive-turn.ts +1 -0
- package/src/attempt-state.ts +54 -1
- package/src/diagnostics.ts +29 -0
- package/src/index.ts +9 -0
- package/src/item-submission-materialization.ts +2 -0
- package/src/parser-processing.ts +15 -18
- package/src/parser-values.ts +1 -5
- package/src/parser.ts +34 -0
- package/src/processing-equality.ts +104 -0
- package/src/processing-evaluator-comparison.ts +19 -14
- package/src/processing-evaluator-numeric.ts +2 -6
- package/src/processing-evaluator-string.ts +25 -18
- package/src/processing-evaluator-variable.ts +8 -3
- package/src/processing-evaluator.ts +20 -7
- package/src/processing-expression-children.ts +2 -3
- package/src/processing-expression-tags.ts +2 -1
- package/src/processing-mapping.ts +31 -25
- package/src/processing-state.ts +6 -1
- package/src/processing-values.ts +2 -11
- package/src/processing-variables.ts +9 -7
- package/src/qti-package-assets.ts +20 -13
- package/src/qti-package-manifest.ts +1 -0
- package/src/qti-package-metadata.ts +11 -0
- package/src/qti-package-plan.ts +1 -1
- package/src/qti-package-types.ts +10 -0
- package/src/qti-package-xml.ts +11 -6
- package/src/qti-package-zip.ts +330 -97
- package/src/qti-package.ts +106 -7
- package/src/response-validation-policy.ts +5 -1
- package/src/response-validation.ts +2 -1
- package/src/serializer-processing-expressions.ts +8 -25
- package/src/server-scoring.ts +1 -0
- package/src/session-builtins.ts +75 -0
- package/src/session.ts +64 -8
- package/src/support-evidence.ts +2 -0
- package/src/support.ts +84 -23
- package/src/trusted-item-session.ts +13 -2
- package/src/types.ts +30 -4
- package/src/validation-declaration-entries.ts +11 -1
- package/src/validation-item-children.ts +48 -0
- package/src/validation-processing.ts +47 -10
- package/src/validation.ts +14 -0
- package/src/value-guards.ts +6 -0
- package/src/xml-schema-regex-data.ts +404 -0
- package/src/xml-schema-regex-parser.ts +234 -0
- package/src/xml-schema-regex.ts +115 -0
package/src/qti-package.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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,
|
|
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,
|
|
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,
|
package/src/server-scoring.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/support-evidence.ts
CHANGED
|
@@ -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",
|