@longsightgroup/qti3-core 0.10.4 → 0.10.6
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 +62 -4
- 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- 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/parser.js +4 -6
- package/dist/parser.js.map +1 -1
- package/dist/qti-package-assets.d.ts +3 -2
- package/dist/qti-package-assets.d.ts.map +1 -1
- package/dist/qti-package-assets.js.map +1 -1
- package/dist/qti-package-items.d.ts +1 -0
- package/dist/qti-package-items.d.ts.map +1 -1
- package/dist/qti-package-items.js +36 -32
- package/dist/qti-package-items.js.map +1 -1
- package/dist/qti-package-manifest.d.ts +3 -2
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts +3 -2
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +5 -2
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-plan.d.ts +41 -0
- package/dist/qti-package-plan.d.ts.map +1 -0
- package/dist/qti-package-plan.js +85 -0
- package/dist/qti-package-plan.js.map +1 -0
- package/dist/qti-package-stream.d.ts +38 -0
- package/dist/qti-package-stream.d.ts.map +1 -0
- package/dist/qti-package-stream.js +149 -0
- package/dist/qti-package-stream.js.map +1 -0
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +21 -51
- 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 +63 -5
- 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 +30 -9
- 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/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +14 -3
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.js +9 -2
- 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.map +1 -1
- package/dist/trusted-item-session.js +4 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.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/package.json +1 -1
- package/src/catalog.ts +29 -0
- package/src/index.ts +15 -0
- package/src/item-scoring-expectation.ts +8 -0
- package/src/parser.ts +4 -6
- package/src/qti-package-assets.ts +3 -4
- package/src/qti-package-items.ts +50 -40
- package/src/qti-package-manifest.ts +2 -3
- package/src/qti-package-metadata.ts +16 -4
- package/src/qti-package-plan.ts +168 -0
- package/src/qti-package-stream.ts +254 -0
- package/src/qti-package.ts +27 -87
- package/src/response-validation-policy.ts +71 -3
- package/src/response-validation.ts +49 -4
- package/src/scoring-disposition-policy.ts +7 -2
- package/src/support-evidence.ts +14 -3
- package/src/support.ts +11 -2
- package/src/text-response.ts +106 -0
- package/src/trusted-item-session.ts +4 -3
- package/src/types.ts +2 -0
- package/src/validation-interactions.ts +139 -14
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
itemReferencesForPackageShape,
|
|
3
|
+
parseAssessmentTestPackageModel,
|
|
4
|
+
parsePackageItem,
|
|
5
|
+
} from "./qti-package-items.js";
|
|
6
|
+
import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
|
|
7
|
+
import {
|
|
8
|
+
buildPackageSummary,
|
|
9
|
+
enforceDiagnosticLimit,
|
|
10
|
+
failedPackageSummary,
|
|
11
|
+
inspectPackageManifest,
|
|
12
|
+
type QtiPackageSummary,
|
|
13
|
+
} from "./qti-package-plan.js";
|
|
14
|
+
import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
15
|
+
import type { QtiPackageItem } from "./qti-package-types.js";
|
|
16
|
+
import { parsePackageXml } from "./qti-package-xml.js";
|
|
17
|
+
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
18
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** An immutable entry inventory and host-owned, bounded reads of decoded entry bytes. */
|
|
21
|
+
export interface QtiPackageSource {
|
|
22
|
+
readonly entries: readonly { readonly path: string; readonly size: number }[];
|
|
23
|
+
/** Enforce maxBytes during extraction, before allocating an oversized body. */
|
|
24
|
+
readonly readEntry: (path: string, maxBytes: number) => Promise<Uint8Array>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Explicit resource budgets for an incremental package read. */
|
|
28
|
+
export interface QtiPackageStreamLimits {
|
|
29
|
+
readonly maxEntries: number;
|
|
30
|
+
readonly maxEntryBytes: number;
|
|
31
|
+
readonly maxTotalBytes: number;
|
|
32
|
+
readonly maxDiagnostics: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Package metadata without extracted bodies or retained item models. */
|
|
36
|
+
export type QtiPackageStreamSummary = QtiPackageSummary;
|
|
37
|
+
|
|
38
|
+
/** Items are provisional until the terminal summary reports ok; hosts must stage them. */
|
|
39
|
+
export type QtiPackageStreamEvent =
|
|
40
|
+
| { readonly kind: "item"; readonly index: number; readonly item: QtiPackageItem }
|
|
41
|
+
| { readonly kind: "summary"; readonly summary: QtiPackageStreamSummary };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse immutable package entries sequentially with canonical package semantics.
|
|
45
|
+
*
|
|
46
|
+
* The caller owns ZIP/object storage and must not collect item events when bounded
|
|
47
|
+
* memory is required. Assets are described in the terminal summary, never loaded.
|
|
48
|
+
* Breaking iteration stops subsequent reads. Read failures become safe diagnostics.
|
|
49
|
+
*/
|
|
50
|
+
export async function* parseQtiPackageStream(
|
|
51
|
+
source: QtiPackageSource,
|
|
52
|
+
limits: QtiPackageStreamLimits,
|
|
53
|
+
): AsyncGenerator<QtiPackageStreamEvent> {
|
|
54
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
55
|
+
const inventory = inspectInventory(source, limits, diagnostics);
|
|
56
|
+
if (!inventory) {
|
|
57
|
+
yield { kind: "summary", summary: failedPackageSummary(diagnostics) };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const read = createEntryReader(source, inventory, limits, diagnostics);
|
|
61
|
+
const manifestEntry = await read(QTI_PACKAGE_MANIFEST_PATH);
|
|
62
|
+
const manifest = inspectPackageManifest(
|
|
63
|
+
inventory,
|
|
64
|
+
manifestEntry ? parsePackageXml(manifestEntry) : undefined,
|
|
65
|
+
diagnostics,
|
|
66
|
+
);
|
|
67
|
+
// Only the primary assessment test supplies the package structure. The shared
|
|
68
|
+
// parser diagnoses additional test resources without loading their bodies.
|
|
69
|
+
const testEntries = new Map<string, QtiPackageEntry>();
|
|
70
|
+
const testHref = manifest.assessmentTestResources[0]?.href;
|
|
71
|
+
if (testHref !== undefined) {
|
|
72
|
+
const entry = await read(testHref);
|
|
73
|
+
if (entry) testEntries.set(testHref, entry);
|
|
74
|
+
}
|
|
75
|
+
const assessmentTest = parseAssessmentTestPackageModel(
|
|
76
|
+
manifest.packageShape,
|
|
77
|
+
manifest.assessmentTestResources,
|
|
78
|
+
testEntries,
|
|
79
|
+
new Map(),
|
|
80
|
+
diagnostics,
|
|
81
|
+
);
|
|
82
|
+
const references = itemReferencesForPackageShape(
|
|
83
|
+
manifest.packageShape,
|
|
84
|
+
manifest.itemResources,
|
|
85
|
+
assessmentTest,
|
|
86
|
+
diagnostics,
|
|
87
|
+
);
|
|
88
|
+
const assetItems: Pick<QtiPackageItem, "href" | "manifestResourceIdentifier" | "assetHrefs">[] =
|
|
89
|
+
[];
|
|
90
|
+
const itemStandards: QtiPackageItem["standards"][number][] = [];
|
|
91
|
+
const titleItems: Pick<QtiPackageItem, "title">[] = [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
let itemCount = 0;
|
|
94
|
+
let timedItemCount = 0;
|
|
95
|
+
let soleItemTiming: QtiPackageItem["timing"] | undefined;
|
|
96
|
+
for (const reference of references) {
|
|
97
|
+
if (diagnostics.length >= limits.maxDiagnostics) break;
|
|
98
|
+
if (seen.has(reference.href)) {
|
|
99
|
+
pushPackageDiagnostic(
|
|
100
|
+
diagnostics,
|
|
101
|
+
"package.item.reference.duplicate",
|
|
102
|
+
"warning",
|
|
103
|
+
`Package item ${reference.href} is referenced more than once.`,
|
|
104
|
+
reference.href,
|
|
105
|
+
);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
seen.add(reference.href);
|
|
109
|
+
const entry = await read(reference.href);
|
|
110
|
+
if (!entry) continue;
|
|
111
|
+
const item = parsePackageItem(reference, entry, undefined, diagnostics);
|
|
112
|
+
if (item.title !== undefined && titleItems.length === 0) {
|
|
113
|
+
titleItems.push({ title: item.title });
|
|
114
|
+
}
|
|
115
|
+
if (item.timing !== undefined) {
|
|
116
|
+
timedItemCount += 1;
|
|
117
|
+
soleItemTiming = item.timing;
|
|
118
|
+
}
|
|
119
|
+
itemStandards.push(...item.standards);
|
|
120
|
+
assetItems.push({
|
|
121
|
+
href: item.href,
|
|
122
|
+
manifestResourceIdentifier: item.manifestResourceIdentifier,
|
|
123
|
+
assetHrefs: item.assetHrefs,
|
|
124
|
+
});
|
|
125
|
+
yield { kind: "item", index: itemCount++, item };
|
|
126
|
+
}
|
|
127
|
+
enforceDiagnosticLimit(diagnostics, limits.maxDiagnostics);
|
|
128
|
+
yield {
|
|
129
|
+
kind: "summary",
|
|
130
|
+
summary: buildPackageSummary({
|
|
131
|
+
manifestRoot: manifest.manifestRoot,
|
|
132
|
+
manifestResources: manifest.manifestResources,
|
|
133
|
+
resourcesByIdentifier: manifest.resourcesByIdentifier,
|
|
134
|
+
packageShape: manifest.packageShape,
|
|
135
|
+
assessmentTest,
|
|
136
|
+
assetItems,
|
|
137
|
+
titleItems,
|
|
138
|
+
timedItemCount,
|
|
139
|
+
soleItemTiming,
|
|
140
|
+
itemStandards,
|
|
141
|
+
entriesByPath: inventory,
|
|
142
|
+
diagnostics,
|
|
143
|
+
itemCount,
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createEntryReader(
|
|
149
|
+
source: QtiPackageSource,
|
|
150
|
+
inventory: ReadonlyMap<string, number>,
|
|
151
|
+
limits: QtiPackageStreamLimits,
|
|
152
|
+
diagnostics: QtiDiagnostic[],
|
|
153
|
+
): (path: string) => Promise<QtiPackageEntry | undefined> {
|
|
154
|
+
return async (path) => {
|
|
155
|
+
const size = inventory.get(path);
|
|
156
|
+
if (size === undefined) return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const bytes = await source.readEntry(path, limits.maxEntryBytes);
|
|
159
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== size) {
|
|
160
|
+
pushPackageDiagnostic(
|
|
161
|
+
diagnostics,
|
|
162
|
+
"package.entry.size",
|
|
163
|
+
"error",
|
|
164
|
+
"Package entry size does not match its immutable inventory.",
|
|
165
|
+
path,
|
|
166
|
+
);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return { path, bytes };
|
|
170
|
+
} catch {
|
|
171
|
+
pushPackageDiagnostic(
|
|
172
|
+
diagnostics,
|
|
173
|
+
"package.entry.read",
|
|
174
|
+
"error",
|
|
175
|
+
"Package entry could not be read.",
|
|
176
|
+
path,
|
|
177
|
+
);
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inspectInventory(
|
|
184
|
+
source: QtiPackageSource,
|
|
185
|
+
limits: QtiPackageStreamLimits,
|
|
186
|
+
diagnostics: QtiDiagnostic[],
|
|
187
|
+
): Map<string, number> | undefined {
|
|
188
|
+
if (!validStreamLimits(limits)) {
|
|
189
|
+
pushPackageDiagnostic(
|
|
190
|
+
diagnostics,
|
|
191
|
+
"package.limits.invalid",
|
|
192
|
+
"error",
|
|
193
|
+
"Package limits must be positive safe integers.",
|
|
194
|
+
);
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (source.entries.length > limits.maxEntries) {
|
|
198
|
+
pushPackageDiagnostic(
|
|
199
|
+
diagnostics,
|
|
200
|
+
"package.entries.limit",
|
|
201
|
+
"error",
|
|
202
|
+
"Package contains more entries than allowed.",
|
|
203
|
+
);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const inventory = new Map<string, number>();
|
|
207
|
+
let total = 0;
|
|
208
|
+
for (const entry of source.entries) {
|
|
209
|
+
const path = normalizePackagePath(entry.path, "entry", diagnostics);
|
|
210
|
+
if (!path) return undefined;
|
|
211
|
+
if (path !== entry.path || inventory.has(path)) {
|
|
212
|
+
pushPackageDiagnostic(
|
|
213
|
+
diagnostics,
|
|
214
|
+
"package.entry.duplicate",
|
|
215
|
+
"error",
|
|
216
|
+
"Package inventory paths must be unique and canonical.",
|
|
217
|
+
entry.path,
|
|
218
|
+
);
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
total += entry.size;
|
|
222
|
+
if (
|
|
223
|
+
!Number.isSafeInteger(entry.size) ||
|
|
224
|
+
entry.size < 0 ||
|
|
225
|
+
entry.size > limits.maxEntryBytes ||
|
|
226
|
+
!Number.isSafeInteger(total) ||
|
|
227
|
+
total > limits.maxTotalBytes
|
|
228
|
+
) {
|
|
229
|
+
pushPackageDiagnostic(
|
|
230
|
+
diagnostics,
|
|
231
|
+
"package.bytes.limit",
|
|
232
|
+
"error",
|
|
233
|
+
"Package entry or total size exceeds its configured budget.",
|
|
234
|
+
path,
|
|
235
|
+
);
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
inventory.set(path, entry.size);
|
|
239
|
+
}
|
|
240
|
+
return inventory;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function validStreamLimits(limits: QtiPackageStreamLimits): boolean {
|
|
244
|
+
return (
|
|
245
|
+
Number.isSafeInteger(limits.maxEntries) &&
|
|
246
|
+
limits.maxEntries > 0 &&
|
|
247
|
+
Number.isSafeInteger(limits.maxEntryBytes) &&
|
|
248
|
+
limits.maxEntryBytes > 0 &&
|
|
249
|
+
Number.isSafeInteger(limits.maxTotalBytes) &&
|
|
250
|
+
limits.maxTotalBytes > 0 &&
|
|
251
|
+
Number.isSafeInteger(limits.maxDiagnostics) &&
|
|
252
|
+
limits.maxDiagnostics > 0
|
|
253
|
+
);
|
|
254
|
+
}
|
package/src/qti-package.ts
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
import { pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
detectPackageShape,
|
|
5
|
-
diagnoseManifestDependencyReferences,
|
|
6
|
-
diagnosePrimaryResourceHrefs,
|
|
7
|
-
indexManifestResources,
|
|
8
|
-
isQtiAssessmentTestResource,
|
|
9
|
-
isQtiItemResource,
|
|
10
|
-
parseManifestResources,
|
|
11
|
-
QTI_PACKAGE_MANIFEST_PATH,
|
|
12
|
-
} from "./qti-package-manifest.js";
|
|
2
|
+
import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
|
|
3
|
+
import { buildPackageSummary, inspectPackageManifest } from "./qti-package-plan.js";
|
|
13
4
|
import {
|
|
14
5
|
itemReferencesForPackageShape,
|
|
15
6
|
parseAssessmentTestPackageModel,
|
|
16
7
|
parsePackageItems,
|
|
17
8
|
} from "./qti-package-items.js";
|
|
18
|
-
import {
|
|
19
|
-
packageTitle,
|
|
20
|
-
parseStandardAlignments,
|
|
21
|
-
primaryTiming,
|
|
22
|
-
uniqueStandards,
|
|
23
|
-
} from "./qti-package-metadata.js";
|
|
24
9
|
import type { QtiPackageParseResult } from "./qti-package-types.js";
|
|
25
|
-
import {
|
|
26
|
-
import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
|
|
10
|
+
import { parseXmlFiles } from "./qti-package-xml.js";
|
|
27
11
|
import {
|
|
28
12
|
DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
|
|
29
13
|
decodeUtf8,
|
|
@@ -85,53 +69,18 @@ function parseQtiPackageEntries(
|
|
|
85
69
|
): QtiPackageParseResult {
|
|
86
70
|
const entriesByPath = indexEntries(entries, diagnostics);
|
|
87
71
|
const xmlFilesByPath = parseXmlFiles(entriesByPath);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
pushXmlDiagnostics(manifestXml, diagnostics);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const manifestRoot =
|
|
103
|
-
manifestXml?.root?.localName === "manifest" &&
|
|
104
|
-
manifestXml.root.uri === QTI_PACKAGE_MANIFEST_NAMESPACE
|
|
105
|
-
? manifestXml.root
|
|
106
|
-
: undefined;
|
|
107
|
-
if (manifestXml?.root && !manifestRoot) {
|
|
108
|
-
pushPackageDiagnostic(
|
|
109
|
-
diagnostics,
|
|
110
|
-
"package.manifest.root",
|
|
111
|
-
"error",
|
|
112
|
-
manifestXml.root.localName === "manifest"
|
|
113
|
-
? `Expected imsmanifest.xml manifest in namespace ${QTI_PACKAGE_MANIFEST_NAMESPACE}, found ${manifestXml.root.uri ?? "(none)"}.`
|
|
114
|
-
: `Expected imsmanifest.xml root manifest, found ${manifestXml.root.localName}.`,
|
|
115
|
-
QTI_PACKAGE_MANIFEST_PATH,
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const manifestResourceRecords = manifestRoot
|
|
120
|
-
? parseManifestResources(manifestRoot, entriesByPath, diagnostics)
|
|
121
|
-
: [];
|
|
122
|
-
const manifestResources = manifestResourceRecords;
|
|
123
|
-
const resourcesByIdentifier = indexManifestResources(manifestResourceRecords, diagnostics);
|
|
124
|
-
diagnoseManifestDependencyReferences(manifestResourceRecords, resourcesByIdentifier, diagnostics);
|
|
125
|
-
|
|
126
|
-
const itemResources = manifestResourceRecords.filter((resource) =>
|
|
127
|
-
isQtiItemResource(resource.type),
|
|
128
|
-
);
|
|
129
|
-
const assessmentTestResources = manifestResourceRecords.filter((resource) =>
|
|
130
|
-
isQtiAssessmentTestResource(resource.type),
|
|
72
|
+
const {
|
|
73
|
+
manifestRoot,
|
|
74
|
+
manifestResources,
|
|
75
|
+
resourcesByIdentifier,
|
|
76
|
+
itemResources,
|
|
77
|
+
assessmentTestResources,
|
|
78
|
+
packageShape,
|
|
79
|
+
} = inspectPackageManifest(
|
|
80
|
+
entriesByPath,
|
|
81
|
+
xmlFilesByPath.get(QTI_PACKAGE_MANIFEST_PATH),
|
|
82
|
+
diagnostics,
|
|
131
83
|
);
|
|
132
|
-
diagnosePrimaryResourceHrefs([...itemResources, ...assessmentTestResources], diagnostics);
|
|
133
|
-
|
|
134
|
-
const packageShape = detectPackageShape(itemResources, assessmentTestResources, diagnostics);
|
|
135
84
|
const assessmentTest = parseAssessmentTestPackageModel(
|
|
136
85
|
packageShape,
|
|
137
86
|
assessmentTestResources,
|
|
@@ -146,36 +95,27 @@ function parseQtiPackageEntries(
|
|
|
146
95
|
diagnostics,
|
|
147
96
|
);
|
|
148
97
|
const items = parsePackageItems(itemReferences, entriesByPath, xmlFilesByPath, diagnostics);
|
|
149
|
-
const
|
|
98
|
+
const timedItems = items.filter((item) => item.timing !== undefined);
|
|
99
|
+
const { itemCount: _itemCount, ...summary } = buildPackageSummary({
|
|
100
|
+
manifestRoot,
|
|
101
|
+
manifestResources,
|
|
150
102
|
resourcesByIdentifier,
|
|
103
|
+
packageShape,
|
|
151
104
|
assessmentTest,
|
|
152
|
-
items,
|
|
105
|
+
assetItems: items,
|
|
106
|
+
titleItems: items,
|
|
107
|
+
timedItemCount: timedItems.length,
|
|
108
|
+
soleItemTiming: timedItems.length === 1 ? timedItems[0]?.timing : undefined,
|
|
109
|
+
itemStandards: items.flatMap((item) => item.standards),
|
|
153
110
|
entriesByPath,
|
|
154
111
|
diagnostics,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
? parseStandardAlignments(manifestRoot, QTI_PACKAGE_MANIFEST_PATH)
|
|
158
|
-
: [];
|
|
159
|
-
const standards = uniqueStandards([
|
|
160
|
-
...manifestStandards,
|
|
161
|
-
...(assessmentTest?.standards ?? []),
|
|
162
|
-
...items.flatMap((item) => item.standards),
|
|
163
|
-
]);
|
|
164
|
-
const timing = primaryTiming(assessmentTest, items);
|
|
165
|
-
const title = packageTitle(manifestRoot, assessmentTest, items);
|
|
112
|
+
itemCount: items.length,
|
|
113
|
+
});
|
|
166
114
|
|
|
167
115
|
return {
|
|
168
|
-
|
|
169
|
-
title,
|
|
116
|
+
...summary,
|
|
170
117
|
entries,
|
|
171
|
-
packageShape,
|
|
172
118
|
items,
|
|
173
|
-
assets,
|
|
174
|
-
manifestResources,
|
|
175
|
-
assessmentTest,
|
|
176
|
-
timing,
|
|
177
|
-
standards,
|
|
178
|
-
diagnostics,
|
|
179
119
|
};
|
|
180
120
|
}
|
|
181
121
|
|
|
@@ -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,7 +129,7 @@ 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);
|
|
@@ -225,3 +257,39 @@ export function matchMaxDiagnostics(
|
|
|
225
257
|
}
|
|
226
258
|
return diagnostics;
|
|
227
259
|
}
|
|
260
|
+
|
|
261
|
+
/** Enforce authored per-choice minimum uses when a response is finalized. */
|
|
262
|
+
export function matchMinDiagnostics(
|
|
263
|
+
responseIdentifier: string,
|
|
264
|
+
interaction: QtiInteraction,
|
|
265
|
+
response: QtiValue,
|
|
266
|
+
): QtiDiagnostic[] {
|
|
267
|
+
if (
|
|
268
|
+
!["associate", "graphicAssociate", "match", "gapMatch", "graphicGapMatch"].includes(
|
|
269
|
+
interaction.type,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return [];
|
|
273
|
+
const pairs = qtiValueToIdentifierList(response);
|
|
274
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
275
|
+
for (const choice of interaction.choices) {
|
|
276
|
+
const minimum = parseNonNegativeInteger(choice.attributes["match-min"] ?? "0") ?? 0;
|
|
277
|
+
if (minimum === 0) continue;
|
|
278
|
+
const side =
|
|
279
|
+
choice.role === "matchSource" || choice.role === "gapChoice"
|
|
280
|
+
? "source"
|
|
281
|
+
: choice.role === "matchTarget" ||
|
|
282
|
+
(interaction.type === "graphicGapMatch" && choice.role === "hotspot")
|
|
283
|
+
? "target"
|
|
284
|
+
: "either";
|
|
285
|
+
if (directedPairChoiceUseCount(choice, pairs, side) >= minimum) continue;
|
|
286
|
+
diagnostics.push({
|
|
287
|
+
code: "response.matchMin",
|
|
288
|
+
severity: "error",
|
|
289
|
+
message: `${choice.text || choice.identifier} must be used at least ${minimum} time${minimum === 1 ? "" : "s"}.`,
|
|
290
|
+
path: responseIdentifier,
|
|
291
|
+
source: choice.source,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return diagnostics;
|
|
295
|
+
}
|
|
@@ -8,11 +8,13 @@ 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
13
|
import { isNullResponse, isRecordValue, valueContainer } from "./processing-values.js";
|
|
13
14
|
import { listNamedResponseInputs, type QtiNamedResponseInput } from "./response-input.js";
|
|
14
15
|
import {
|
|
15
16
|
matchMaxDiagnostics,
|
|
17
|
+
matchMinDiagnostics,
|
|
16
18
|
maximumAllowedResponses,
|
|
17
19
|
maximumResponseDiagnostic,
|
|
18
20
|
mediaPlayCount,
|
|
@@ -33,6 +35,7 @@ export type QtiResponseValidationDiagnosticCode =
|
|
|
33
35
|
| "response.required"
|
|
34
36
|
| "response.maximum"
|
|
35
37
|
| "response.matchMax"
|
|
38
|
+
| "response.matchMin"
|
|
36
39
|
| "response.cardinality"
|
|
37
40
|
| "response.baseType"
|
|
38
41
|
| "response.domain"
|
|
@@ -65,6 +68,8 @@ export interface QtiResponseValidationInput {
|
|
|
65
68
|
item: QtiAssessmentItem;
|
|
66
69
|
responses: QtiResponseVariablesInput;
|
|
67
70
|
allowIncompleteResponses?: boolean | undefined;
|
|
71
|
+
/** Host policy: require a scored response when no minimum is authored; defaults to false. */
|
|
72
|
+
requireScoredResponses?: boolean | undefined;
|
|
68
73
|
allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
|
|
69
74
|
responseIdentifiers?: Iterable<string> | undefined;
|
|
70
75
|
}
|
|
@@ -113,6 +118,17 @@ export function parseQtiResponseVariables(
|
|
|
113
118
|
if (cardinalityMatches && parsedValue !== undefined) {
|
|
114
119
|
responses.set(declaration.identifier, parsedValue);
|
|
115
120
|
validateResponseDomain(declaration, interactions ?? [], parsedValue, diagnostics);
|
|
121
|
+
if (
|
|
122
|
+
declaration.cardinality === "record" &&
|
|
123
|
+
parsedValue !== null &&
|
|
124
|
+
interactions?.some(
|
|
125
|
+
(interaction) =>
|
|
126
|
+
interaction.type === "textEntry" || interaction.type === "extendedText",
|
|
127
|
+
) &&
|
|
128
|
+
!isQtiTextResponseRecord(parsedValue)
|
|
129
|
+
) {
|
|
130
|
+
pushResponseDomainDiagnostic(declaration, parsedValue, diagnostics);
|
|
131
|
+
}
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
|
|
@@ -122,6 +138,7 @@ export function parseQtiResponseVariables(
|
|
|
122
138
|
undefined,
|
|
123
139
|
value,
|
|
124
140
|
input.allowIncompleteResponses,
|
|
141
|
+
input.requireScoredResponses,
|
|
125
142
|
diagnostics,
|
|
126
143
|
);
|
|
127
144
|
continue;
|
|
@@ -133,6 +150,7 @@ export function parseQtiResponseVariables(
|
|
|
133
150
|
interaction,
|
|
134
151
|
value,
|
|
135
152
|
input.allowIncompleteResponses,
|
|
153
|
+
input.requireScoredResponses,
|
|
136
154
|
diagnostics,
|
|
137
155
|
);
|
|
138
156
|
}
|
|
@@ -379,17 +397,37 @@ function validateDeclarationResponse(
|
|
|
379
397
|
interaction: QtiInteraction | undefined,
|
|
380
398
|
value: QtiValue | undefined,
|
|
381
399
|
allowIncompleteResponses: boolean | undefined,
|
|
400
|
+
requireScoredResponses: boolean | undefined,
|
|
382
401
|
diagnostics: QtiResponseValidationDiagnostic[],
|
|
383
402
|
): void {
|
|
384
|
-
|
|
403
|
+
if (interaction && !allowIncompleteResponses) {
|
|
404
|
+
diagnostics.push(
|
|
405
|
+
...matchMinDiagnostics(declaration.identifier, interaction, value ?? null).map((diagnostic) =>
|
|
406
|
+
attachResponseIdentifier(declaration.identifier, diagnostic),
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const policy = responseValidationPolicy(declaration, interaction, requireScoredResponses);
|
|
385
411
|
if (!policy.checkMinimum && !policy.checkMaximum && !policy.checkMatchMax) return;
|
|
386
412
|
|
|
387
413
|
const effectiveValue = value ?? null;
|
|
388
414
|
const count =
|
|
389
|
-
interaction?.type === "
|
|
415
|
+
interaction?.type === "extendedText" || interaction?.type === "textEntry"
|
|
416
|
+
? Array.isArray(effectiveValue)
|
|
417
|
+
? effectiveValue.filter((entry) => entry !== "").length
|
|
418
|
+
: qtiTextResponseString(effectiveValue) === ""
|
|
419
|
+
? 0
|
|
420
|
+
: 1
|
|
421
|
+
: interaction?.type === "media"
|
|
422
|
+
? mediaPlayCount(effectiveValue)
|
|
423
|
+
: responseCount(effectiveValue);
|
|
390
424
|
|
|
391
425
|
if (policy.checkMinimum && !allowIncompleteResponses) {
|
|
392
|
-
const minimum = effectiveMinimumRequiredResponses(
|
|
426
|
+
const minimum = effectiveMinimumRequiredResponses(
|
|
427
|
+
declaration,
|
|
428
|
+
interaction,
|
|
429
|
+
requireScoredResponses,
|
|
430
|
+
);
|
|
393
431
|
if (count < minimum) {
|
|
394
432
|
diagnostics.push(
|
|
395
433
|
attachResponseIdentifier(
|
|
@@ -424,9 +462,15 @@ function validateDeclarationResponse(
|
|
|
424
462
|
function effectiveMinimumRequiredResponses(
|
|
425
463
|
declaration: { readonly correctResponse: QtiValue | null },
|
|
426
464
|
interaction: QtiInteraction | undefined,
|
|
465
|
+
requireScoredResponses: boolean | undefined,
|
|
427
466
|
): number {
|
|
428
467
|
const minimum = minimumRequiredResponses(interaction);
|
|
429
|
-
if (
|
|
468
|
+
if (
|
|
469
|
+
!requireScoredResponses ||
|
|
470
|
+
declaration.correctResponse === null ||
|
|
471
|
+
hasAuthoredMinimum(interaction)
|
|
472
|
+
)
|
|
473
|
+
return minimum;
|
|
430
474
|
return Math.max(minimum, 1);
|
|
431
475
|
}
|
|
432
476
|
|
|
@@ -453,6 +497,7 @@ function isResponseValidationDiagnosticCode(
|
|
|
453
497
|
code === "response.required" ||
|
|
454
498
|
code === "response.maximum" ||
|
|
455
499
|
code === "response.matchMax" ||
|
|
500
|
+
code === "response.matchMin" ||
|
|
456
501
|
code === "response.cardinality" ||
|
|
457
502
|
code === "response.baseType" ||
|
|
458
503
|
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";
|