@longsightgroup/qti3-core 0.10.5 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -4
- 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/catalog.d.ts +8 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +10 -0
- package/dist/catalog.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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +2 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -1
- package/dist/item-scoring-expectation.js +8 -0
- package/dist/item-scoring-expectation.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 +36 -6
- 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 +3 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +66 -6
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts +3 -1
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +32 -10
- package/dist/response-validation.js.map +1 -1
- package/dist/scoring-disposition-policy.d.ts.map +1 -1
- package/dist/scoring-disposition-policy.js +6 -2
- package/dist/scoring-disposition-policy.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 +16 -3
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +72 -24
- package/dist/support.js.map +1 -1
- package/dist/text-response.d.ts +9 -0
- package/dist/text-response.d.ts.map +1 -0
- package/dist/text-response.js +102 -0
- package/dist/text-response.js.map +1 -0
- 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 +12 -6
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +23 -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-interactions.d.ts.map +1 -1
- package/dist/validation-interactions.js +102 -12
- package/dist/validation-interactions.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/catalog.ts +29 -0
- package/src/diagnostics.ts +29 -0
- package/src/index.ts +16 -0
- package/src/item-scoring-expectation.ts +8 -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 +38 -6
- 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 +76 -4
- package/src/response-validation.ts +51 -5
- package/src/scoring-disposition-policy.ts +7 -2
- 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 +16 -3
- package/src/support.ts +94 -24
- package/src/text-response.ts +106 -0
- package/src/trusted-item-session.ts +17 -5
- package/src/types.ts +32 -4
- package/src/validation-declaration-entries.ts +11 -1
- package/src/validation-interactions.ts +139 -14
- 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
|
|
|
@@ -31,6 +31,8 @@ export function responseLimitAttribute(
|
|
|
31
31
|
choiceKey: "min-choices" | "max-choices",
|
|
32
32
|
associationKey: "min-associations" | "max-associations",
|
|
33
33
|
): string | undefined {
|
|
34
|
+
if (interaction.type === "extendedText")
|
|
35
|
+
return interaction.attributes[choiceKey === "min-choices" ? "min-strings" : "max-strings"];
|
|
34
36
|
if (interaction.type === "order" || interaction.type === "graphicOrder") {
|
|
35
37
|
return interaction.attributes[choiceKey];
|
|
36
38
|
}
|
|
@@ -42,7 +44,7 @@ function interactionRequiresResponse(interaction: QtiInteraction): boolean {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function minimumRequiredResponses(interaction: QtiInteraction | undefined): number {
|
|
45
|
-
if (!interaction) return
|
|
47
|
+
if (!interaction) return 0;
|
|
46
48
|
if (interaction.type === "media") return minimumMediaPlays(interaction);
|
|
47
49
|
const explicit = responseLimitAttribute(interaction, "min-choices", "min-associations");
|
|
48
50
|
if (explicit === undefined) return interactionRequiresResponse(interaction) ? 1 : 0;
|
|
@@ -54,6 +56,10 @@ export function maximumAllowedResponses(
|
|
|
54
56
|
): number | undefined {
|
|
55
57
|
if (!interaction) return undefined;
|
|
56
58
|
if (interaction.type === "media") return maximumMediaPlays(interaction);
|
|
59
|
+
if (interaction.type === "extendedText") {
|
|
60
|
+
const maximum = interaction.attributes["max-strings"];
|
|
61
|
+
return maximum === undefined ? undefined : parseNonNegativeInteger(maximum);
|
|
62
|
+
}
|
|
57
63
|
if (
|
|
58
64
|
(interaction.type === "order" || interaction.type === "graphicOrder") &&
|
|
59
65
|
!orderSubsetLimitsActive(interaction)
|
|
@@ -61,7 +67,32 @@ export function maximumAllowedResponses(
|
|
|
61
67
|
return undefined;
|
|
62
68
|
}
|
|
63
69
|
const explicit = responseLimitAttribute(interaction, "max-choices", "max-associations");
|
|
64
|
-
if (explicit === undefined)
|
|
70
|
+
if (explicit === undefined) {
|
|
71
|
+
switch (interaction.type) {
|
|
72
|
+
case "associate":
|
|
73
|
+
case "match":
|
|
74
|
+
case "gapMatch":
|
|
75
|
+
case "graphicAssociate":
|
|
76
|
+
case "graphicGapMatch":
|
|
77
|
+
case "choice":
|
|
78
|
+
case "hottext":
|
|
79
|
+
case "hotspot":
|
|
80
|
+
case "positionObject":
|
|
81
|
+
return 1;
|
|
82
|
+
case "custom":
|
|
83
|
+
case "drawing":
|
|
84
|
+
case "endAttempt":
|
|
85
|
+
case "graphicOrder":
|
|
86
|
+
case "inlineChoice":
|
|
87
|
+
case "order":
|
|
88
|
+
case "portableCustom":
|
|
89
|
+
case "selectPoint":
|
|
90
|
+
case "slider":
|
|
91
|
+
case "textEntry":
|
|
92
|
+
case "upload":
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
65
96
|
const parsed = parseNonNegativeInteger(explicit);
|
|
66
97
|
return parsed === undefined || parsed <= 0 ? undefined : parsed;
|
|
67
98
|
}
|
|
@@ -90,6 +121,7 @@ export { maximumMediaPlays, minimumMediaPlays };
|
|
|
90
121
|
export function responseValidationPolicy(
|
|
91
122
|
declaration: { readonly correctResponse: QtiValue | null },
|
|
92
123
|
interaction: QtiInteraction | undefined,
|
|
124
|
+
requireScoredResponses = false,
|
|
93
125
|
): QtiResponseValidationPolicy {
|
|
94
126
|
const authoredMinimum =
|
|
95
127
|
interaction === undefined
|
|
@@ -97,16 +129,20 @@ export function responseValidationPolicy(
|
|
|
97
129
|
: responseLimitAttribute(interaction, "min-choices", "min-associations");
|
|
98
130
|
const minimum = interaction === undefined ? undefined : minimumRequiredResponses(interaction);
|
|
99
131
|
const validatesMinimum =
|
|
100
|
-
declaration.correctResponse !== null ||
|
|
132
|
+
(requireScoredResponses && declaration.correctResponse !== null) ||
|
|
101
133
|
interaction?.type === "media" ||
|
|
102
134
|
authoredMinimum !== undefined ||
|
|
103
135
|
(minimum !== undefined && minimum > 0);
|
|
104
136
|
const maximum = maximumAllowedResponses(interaction);
|
|
137
|
+
const hasMatchMaximum = interaction?.choices.some(
|
|
138
|
+
(choice) => choiceMatchMaximum(choice) !== undefined,
|
|
139
|
+
);
|
|
105
140
|
if (
|
|
106
141
|
declaration.correctResponse === null &&
|
|
107
142
|
interaction?.type !== "media" &&
|
|
108
143
|
!validatesMinimum &&
|
|
109
|
-
maximum === undefined
|
|
144
|
+
maximum === undefined &&
|
|
145
|
+
!hasMatchMaximum
|
|
110
146
|
) {
|
|
111
147
|
return { checkMinimum: false, checkMaximum: false, checkMatchMax: false };
|
|
112
148
|
}
|
|
@@ -225,3 +261,39 @@ export function matchMaxDiagnostics(
|
|
|
225
261
|
}
|
|
226
262
|
return diagnostics;
|
|
227
263
|
}
|
|
264
|
+
|
|
265
|
+
/** Enforce authored per-choice minimum uses when a response is finalized. */
|
|
266
|
+
export function matchMinDiagnostics(
|
|
267
|
+
responseIdentifier: string,
|
|
268
|
+
interaction: QtiInteraction,
|
|
269
|
+
response: QtiValue,
|
|
270
|
+
): QtiDiagnostic[] {
|
|
271
|
+
if (
|
|
272
|
+
!["associate", "graphicAssociate", "match", "gapMatch", "graphicGapMatch"].includes(
|
|
273
|
+
interaction.type,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return [];
|
|
277
|
+
const pairs = qtiValueToIdentifierList(response);
|
|
278
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
279
|
+
for (const choice of interaction.choices) {
|
|
280
|
+
const minimum = parseNonNegativeInteger(choice.attributes["match-min"] ?? "0") ?? 0;
|
|
281
|
+
if (minimum === 0) continue;
|
|
282
|
+
const side =
|
|
283
|
+
choice.role === "matchSource" || choice.role === "gapChoice"
|
|
284
|
+
? "source"
|
|
285
|
+
: choice.role === "matchTarget" ||
|
|
286
|
+
(interaction.type === "graphicGapMatch" && choice.role === "hotspot")
|
|
287
|
+
? "target"
|
|
288
|
+
: "either";
|
|
289
|
+
if (directedPairChoiceUseCount(choice, pairs, side) >= minimum) continue;
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
code: "response.matchMin",
|
|
292
|
+
severity: "error",
|
|
293
|
+
message: `${choice.text || choice.identifier} must be used at least ${minimum} time${minimum === 1 ? "" : "s"}.`,
|
|
294
|
+
path: responseIdentifier,
|
|
295
|
+
source: choice.source,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return diagnostics;
|
|
299
|
+
}
|
|
@@ -8,11 +8,14 @@ import type {
|
|
|
8
8
|
QtiScalarValue,
|
|
9
9
|
QtiValue,
|
|
10
10
|
} from "./types.js";
|
|
11
|
+
import { isQtiTextResponseRecord, qtiTextResponseString } from "./text-response.js";
|
|
11
12
|
import { assertNever } from "./assert-never.js";
|
|
12
|
-
import { isNullResponse,
|
|
13
|
+
import { isNullResponse, valueContainer } from "./processing-values.js";
|
|
14
|
+
import { isRecordValue } from "./value-guards.js";
|
|
13
15
|
import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
|
|
14
16
|
import {
|
|
15
17
|
matchMaxDiagnostics,
|
|
18
|
+
matchMinDiagnostics,
|
|
16
19
|
maximumAllowedResponses,
|
|
17
20
|
maximumResponseDiagnostic,
|
|
18
21
|
mediaPlayCount,
|
|
@@ -33,6 +36,7 @@ export type QtiResponseValidationDiagnosticCode =
|
|
|
33
36
|
| "response.required"
|
|
34
37
|
| "response.maximum"
|
|
35
38
|
| "response.matchMax"
|
|
39
|
+
| "response.matchMin"
|
|
36
40
|
| "response.cardinality"
|
|
37
41
|
| "response.baseType"
|
|
38
42
|
| "response.domain"
|
|
@@ -65,6 +69,8 @@ export interface QtiResponseValidationInput {
|
|
|
65
69
|
item: QtiAssessmentItem;
|
|
66
70
|
responses: QtiResponseVariablesInput;
|
|
67
71
|
allowIncompleteResponses?: boolean | undefined;
|
|
72
|
+
/** Host policy: require a scored response when no minimum is authored; defaults to false. */
|
|
73
|
+
requireScoredResponses?: boolean | undefined;
|
|
68
74
|
allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
|
|
69
75
|
responseIdentifiers?: Iterable<string> | undefined;
|
|
70
76
|
}
|
|
@@ -113,6 +119,17 @@ export function parseQtiResponseVariables(
|
|
|
113
119
|
if (cardinalityMatches && parsedValue !== undefined) {
|
|
114
120
|
responses.set(declaration.identifier, parsedValue);
|
|
115
121
|
validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
|
|
122
|
+
if (
|
|
123
|
+
declaration.cardinality === "record" &&
|
|
124
|
+
parsedValue !== null &&
|
|
125
|
+
interactions?.some(
|
|
126
|
+
(interaction) =>
|
|
127
|
+
interaction.type === "textEntry" || interaction.type === "extendedText",
|
|
128
|
+
) &&
|
|
129
|
+
!isQtiTextResponseRecord(parsedValue)
|
|
130
|
+
) {
|
|
131
|
+
pushResponseDomainDiagnostic(declaration, parsedValue, diagnostics);
|
|
132
|
+
}
|
|
116
133
|
}
|
|
117
134
|
}
|
|
118
135
|
|
|
@@ -122,6 +139,7 @@ export function parseQtiResponseVariables(
|
|
|
122
139
|
undefined,
|
|
123
140
|
value,
|
|
124
141
|
input.allowIncompleteResponses,
|
|
142
|
+
input.requireScoredResponses,
|
|
125
143
|
diagnostics,
|
|
126
144
|
);
|
|
127
145
|
continue;
|
|
@@ -133,6 +151,7 @@ export function parseQtiResponseVariables(
|
|
|
133
151
|
interaction,
|
|
134
152
|
value,
|
|
135
153
|
input.allowIncompleteResponses,
|
|
154
|
+
input.requireScoredResponses,
|
|
136
155
|
diagnostics,
|
|
137
156
|
);
|
|
138
157
|
}
|
|
@@ -379,17 +398,37 @@ function validateDeclarationResponse(
|
|
|
379
398
|
interaction: QtiInteraction | undefined,
|
|
380
399
|
value: QtiValue | undefined,
|
|
381
400
|
allowIncompleteResponses: boolean | undefined,
|
|
401
|
+
requireScoredResponses: boolean | undefined,
|
|
382
402
|
diagnostics: QtiResponseValidationDiagnostic[],
|
|
383
403
|
): void {
|
|
384
|
-
|
|
404
|
+
if (interaction && !allowIncompleteResponses) {
|
|
405
|
+
diagnostics.push(
|
|
406
|
+
...matchMinDiagnostics(declaration.identifier, interaction, value ?? null).map((diagnostic) =>
|
|
407
|
+
attachResponseIdentifier(declaration.identifier, diagnostic),
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const policy = responseValidationPolicy(declaration, interaction, requireScoredResponses);
|
|
385
412
|
if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
|
|
386
413
|
|
|
387
414
|
const effectiveValue = value ?? null;
|
|
388
415
|
const count =
|
|
389
|
-
interaction?.type === "
|
|
416
|
+
interaction?.type === "extendedText" || interaction?.type === "textEntry"
|
|
417
|
+
? Array.isArray(effectiveValue)
|
|
418
|
+
? effectiveValue.filter((entry) => entry !== "").length
|
|
419
|
+
: qtiTextResponseString(effectiveValue) === ""
|
|
420
|
+
? 0
|
|
421
|
+
: 1
|
|
422
|
+
: interaction?.type === "media"
|
|
423
|
+
? mediaPlayCount(effectiveValue)
|
|
424
|
+
: responseCount(effectiveValue);
|
|
390
425
|
|
|
391
426
|
if (policy.checkMinimum && !allowIncompleteResponses) {
|
|
392
|
-
const minimum = effectiveMinimumRequiredResponses(
|
|
427
|
+
const minimum = effectiveMinimumRequiredResponses(
|
|
428
|
+
declaration,
|
|
429
|
+
interaction,
|
|
430
|
+
requireScoredResponses,
|
|
431
|
+
);
|
|
393
432
|
if (count < minimum) {
|
|
394
433
|
diagnostics.push(
|
|
395
434
|
attachResponseIdentifier(
|
|
@@ -424,9 +463,15 @@ function validateDeclarationResponse(
|
|
|
424
463
|
function effectiveMinimumRequiredResponses(
|
|
425
464
|
declaration: { readonly correctResponse: QtiValue | null },
|
|
426
465
|
interaction: QtiInteraction | undefined,
|
|
466
|
+
requireScoredResponses: boolean | undefined,
|
|
427
467
|
): number {
|
|
428
468
|
const minimum = minimumRequiredResponses(interaction);
|
|
429
|
-
if (
|
|
469
|
+
if (
|
|
470
|
+
!requireScoredResponses ||
|
|
471
|
+
declaration.correctResponse === null ||
|
|
472
|
+
hasAuthoredMinimum(interaction)
|
|
473
|
+
)
|
|
474
|
+
return minimum;
|
|
430
475
|
return Math.max(minimum, 1);
|
|
431
476
|
}
|
|
432
477
|
|
|
@@ -453,6 +498,7 @@ function isResponseValidationDiagnosticCode(
|
|
|
453
498
|
code === "response.required" ||
|
|
454
499
|
code === "response.maximum" ||
|
|
455
500
|
code === "response.matchMax" ||
|
|
501
|
+
code === "response.matchMin" ||
|
|
456
502
|
code === "response.cardinality" ||
|
|
457
503
|
code === "response.baseType" ||
|
|
458
504
|
code === "response.domain" ||
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QtiAssessmentItem } from "./types.js";
|
|
2
|
-
import { itemExpectsAutomatedScore } from "./item-scoring-expectation.js";
|
|
2
|
+
import { itemExpectsAutomatedScore, itemHasExternalScore } from "./item-scoring-expectation.js";
|
|
3
3
|
|
|
4
4
|
export type QtiItemSubmissionScoringDisposition =
|
|
5
5
|
| "scored"
|
|
@@ -23,7 +23,11 @@ export function itemHasManuallyScoredInteractions(item: QtiAssessmentItem): bool
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function itemNeedsScoringFollowUpWhenUnscored(item: QtiAssessmentItem): boolean {
|
|
26
|
-
return
|
|
26
|
+
return (
|
|
27
|
+
itemHasExternalScore(item) ||
|
|
28
|
+
itemExpectsAutomatedScore(item) ||
|
|
29
|
+
itemHasManuallyScoredInteractions(item)
|
|
30
|
+
);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Default generic disposition taxonomy shipped by qti3-core for submission materialization. */
|
|
@@ -31,6 +35,7 @@ export function classifyQtiItemScoringDisposition(
|
|
|
31
35
|
item: QtiAssessmentItem,
|
|
32
36
|
score: number | null,
|
|
33
37
|
): Exclude<QtiItemSubmissionScoringDisposition, "invalid"> {
|
|
38
|
+
if (itemHasExternalScore(item)) return "manual-scoring-required";
|
|
34
39
|
if (score !== null) return "scored";
|
|
35
40
|
if (itemNeedsScoringFollowUpWhenUnscored(item)) return "manual-scoring-required";
|
|
36
41
|
return "unscored-reference";
|
|
@@ -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
|
+
}
|