@longsightgroup/qti3-core 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/parser-processing.d.ts.map +1 -1
  6. package/dist/parser-processing.js +4 -0
  7. package/dist/parser-processing.js.map +1 -1
  8. package/dist/processing-expression-collection.d.ts +4 -0
  9. package/dist/processing-expression-collection.d.ts.map +1 -0
  10. package/dist/processing-expression-collection.js +44 -0
  11. package/dist/processing-expression-collection.js.map +1 -0
  12. package/dist/qti-package-assets.d.ts +3 -1
  13. package/dist/qti-package-assets.d.ts.map +1 -1
  14. package/dist/qti-package-assets.js +22 -0
  15. package/dist/qti-package-assets.js.map +1 -1
  16. package/dist/qti-package-items.d.ts +2 -1
  17. package/dist/qti-package-items.d.ts.map +1 -1
  18. package/dist/qti-package-items.js +96 -18
  19. package/dist/qti-package-items.js.map +1 -1
  20. package/dist/qti-package-manifest.d.ts.map +1 -1
  21. package/dist/qti-package-manifest.js +2 -0
  22. package/dist/qti-package-manifest.js.map +1 -1
  23. package/dist/qti-package-metadata.d.ts +5 -2
  24. package/dist/qti-package-metadata.d.ts.map +1 -1
  25. package/dist/qti-package-metadata.js +116 -8
  26. package/dist/qti-package-metadata.js.map +1 -1
  27. package/dist/qti-package-types.d.ts +62 -0
  28. package/dist/qti-package-types.d.ts.map +1 -1
  29. package/dist/qti-package-xml.d.ts.map +1 -1
  30. package/dist/qti-package-xml.js +1 -1
  31. package/dist/qti-package-xml.js.map +1 -1
  32. package/dist/qti-package.d.ts +2 -1
  33. package/dist/qti-package.d.ts.map +1 -1
  34. package/dist/qti-package.js +2 -0
  35. package/dist/qti-package.js.map +1 -1
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/index.ts +9 -0
  40. package/src/parser-processing.ts +4 -0
  41. package/src/processing-expression-collection.ts +57 -0
  42. package/src/qti-package-assets.ts +30 -0
  43. package/src/qti-package-items.ts +190 -29
  44. package/src/qti-package-manifest.ts +2 -0
  45. package/src/qti-package-metadata.ts +174 -7
  46. package/src/qti-package-types.ts +69 -0
  47. package/src/qti-package-xml.ts +2 -1
  48. package/src/qti-package.ts +9 -0
  49. package/src/types.ts +2 -0
package/src/index.ts CHANGED
@@ -52,31 +52,40 @@ export {
52
52
  } from "./package-xml.js";
53
53
  export {
54
54
  decodeUtf8,
55
+ discoverQtiPackageContentAssets,
55
56
  normalizePackagePath,
56
57
  QTI_ITEM_RESOURCE_TYPE,
57
58
  QTI_PACKAGE_MANIFEST_PATH,
58
59
  parseQtiPackage,
59
60
  readQtiPackageZipEntries,
61
+ type QtiAssessmentSectionPackageModel,
60
62
  type QtiAssessmentTestItemRef,
61
63
  type QtiAssessmentTestPackageModel,
62
64
  type QtiManifestFile,
63
65
  type QtiManifestResource,
64
66
  type QtiPackageAsset,
67
+ type QtiPackageContentAssetDiscovery,
65
68
  type QtiPackageAssetSource,
66
69
  type QtiPackageEntry,
67
70
  type QtiPackageInflateContext,
68
71
  type QtiPackageInflateRaw,
72
+ type QtiItemSessionControl,
69
73
  type QtiPackageItem,
70
74
  type QtiPackageItemSource,
71
75
  type QtiPackageParseOptions,
72
76
  type QtiPackageParseResult,
73
77
  type QtiPackageShape,
74
78
  type QtiStandardAlignment,
79
+ type QtiTestPartNavigationMode,
80
+ type QtiTestPartPackageModel,
81
+ type QtiTestPartSubmissionMode,
82
+ type QtiTimeLimits,
75
83
  type QtiTimingMetadata,
76
84
  } from "./qti-package.js";
77
85
  export { detectPackageMediaType } from "./qti-package-assets.js";
78
86
  export { parseQtiXml } from "./parser.js";
79
87
  export { numericTuple3, numericTuple4 } from "./parser-values.js";
88
+ export { collectQtiResponseProcessingExpressions } from "./processing-expression-collection.js";
80
89
  export { isConformanceParseDiagnostic } from "./parse-diagnostics.js";
81
90
  export {
82
91
  scoreQtiItemServerSide,
@@ -21,6 +21,10 @@ export function parseResponseProcessing(
21
21
  template: node.attributes.template,
22
22
  rules,
23
23
  conditions: responseConditionsFromRules(rules),
24
+ expressions: node.children.flatMap((child) => {
25
+ const expression = parseExpression(child);
26
+ return expression ? [expression] : [];
27
+ }),
24
28
  };
25
29
  }
26
30
 
@@ -0,0 +1,57 @@
1
+ import { expressionChildren } from "./processing-expression-children.js";
2
+ import type {
3
+ QtiProcessingExpression,
4
+ QtiResponseCondition,
5
+ QtiResponseProcessing,
6
+ QtiResponseRule,
7
+ } from "./types.js";
8
+
9
+ /** Collect every processing expression in a parsed response-processing tree. */
10
+ export function collectQtiResponseProcessingExpressions(
11
+ processing: QtiResponseProcessing | undefined,
12
+ ): QtiProcessingExpression[] {
13
+ if (!processing) return [];
14
+ const expressions: QtiProcessingExpression[] = [];
15
+ for (const expression of processing.expressions ?? []) collectExpression(expression, expressions);
16
+ collectRules(processing.rules, expressions);
17
+ for (const condition of processing.conditions) collectCondition(condition, expressions);
18
+ return expressions;
19
+ }
20
+
21
+ function collectRules(
22
+ rules: readonly QtiResponseRule[],
23
+ expressions: QtiProcessingExpression[],
24
+ ): void {
25
+ for (const rule of rules) {
26
+ if (rule.type === "setOutcomeValue" || rule.type === "lookupOutcomeValue") {
27
+ collectExpression(rule.expression, expressions);
28
+ continue;
29
+ }
30
+ if (rule.type === "responseCondition") {
31
+ collectCondition(rule.condition, expressions);
32
+ continue;
33
+ }
34
+ if (rule.type === "responseProcessingFragment") collectRules(rule.rules, expressions);
35
+ }
36
+ }
37
+
38
+ function collectCondition(
39
+ condition: QtiResponseCondition,
40
+ expressions: QtiProcessingExpression[],
41
+ ): void {
42
+ if (condition.ifExpression) collectExpression(condition.ifExpression, expressions);
43
+ collectRules(condition.thenRules, expressions);
44
+ for (const branch of condition.elseIfs) {
45
+ if (branch.expression) collectExpression(branch.expression, expressions);
46
+ collectRules(branch.rules, expressions);
47
+ }
48
+ collectRules(condition.elseRules, expressions);
49
+ }
50
+
51
+ function collectExpression(
52
+ expression: QtiProcessingExpression,
53
+ expressions: QtiProcessingExpression[],
54
+ ): void {
55
+ expressions.push(expression);
56
+ for (const child of expressionChildren(expression)) collectExpression(child, expressions);
57
+ }
@@ -1,9 +1,11 @@
1
1
  import { pushPackageDiagnostic } from "./qti-package-paths.js";
2
+ import { parseQtiPackageXmlTree } from "./package-xml.js";
2
3
  import type {
3
4
  QtiAssessmentTestPackageModel,
4
5
  QtiManifestResource,
5
6
  QtiPackageAsset,
6
7
  QtiPackageAssetSource,
8
+ QtiPackageContentAssetDiscovery,
7
9
  QtiPackageItem,
8
10
  } from "./qti-package-types.js";
9
11
  import {
@@ -21,6 +23,27 @@ interface PendingAssetReference {
21
23
  readonly referencedBy: string;
22
24
  }
23
25
 
26
+ /** Discover and resolve package-local asset references in one QTI XML document. */
27
+ export function discoverQtiPackageContentAssets(
28
+ xml: string,
29
+ sourcePath: string,
30
+ ): QtiPackageContentAssetDiscovery {
31
+ const parsed = parseQtiPackageXmlTree(xml);
32
+ const diagnostics: QtiDiagnostic[] = [];
33
+ for (const message of parsed.errors) {
34
+ pushPackageDiagnostic(diagnostics, "xml.parse", "error", message, sourcePath);
35
+ }
36
+ if (!parsed.root) return { hrefs: [], diagnostics };
37
+
38
+ return {
39
+ hrefs: discoverContentAssetHrefs(
40
+ { path: sourcePath, xml, root: parsed.root, errors: parsed.errors },
41
+ diagnostics,
42
+ ),
43
+ diagnostics,
44
+ };
45
+ }
46
+
24
47
  type PackageAssetRefSpec =
25
48
  | { readonly kind: "attribute"; readonly localName: string; readonly attribute: string }
26
49
  | { readonly kind: "text"; readonly localName: string };
@@ -232,6 +255,8 @@ export function detectPackageMediaType(href: string): string | undefined {
232
255
  return "image/jpeg";
233
256
  case "mp3":
234
257
  return "audio/mpeg";
258
+ case "m4a":
259
+ return "audio/mp4";
235
260
  case "mp4":
236
261
  return "video/mp4";
237
262
  case "ogg":
@@ -250,6 +275,11 @@ export function detectPackageMediaType(href: string): string | undefined {
250
275
  return "image/webp";
251
276
  case "xml":
252
277
  return "application/xml";
278
+ case "html":
279
+ case "htm":
280
+ return "text/html";
281
+ case "json":
282
+ return "application/json";
253
283
  default:
254
284
  return undefined;
255
285
  }
@@ -1,19 +1,31 @@
1
1
  import type { QtiPackageXmlNode } from "./package-xml.js";
2
2
  import { parseQtiXml } from "./parser.js";
3
+ import { parseXmlBoolean } from "./parser-values.js";
3
4
  import { pushPackageDiagnostic } from "./qti-package-paths.js";
4
5
  import { discoverContentAssetHrefs } from "./qti-package-assets.js";
5
6
  import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
6
- import { parseStandardAlignments, parseTimingMetadata } from "./qti-package-metadata.js";
7
+ import {
8
+ parseItemSessionControl,
9
+ parseStandardAlignments,
10
+ parseTimeLimits,
11
+ parseTimingMetadata,
12
+ uniqueStandards,
13
+ } from "./qti-package-metadata.js";
7
14
  import type {
15
+ QtiAssessmentSectionPackageModel,
8
16
  QtiAssessmentTestItemRef,
9
17
  QtiAssessmentTestPackageModel,
10
18
  QtiManifestResource,
11
19
  QtiPackageItem,
12
20
  QtiPackageItemSource,
13
21
  QtiPackageShape,
22
+ QtiStandardAlignment,
23
+ QtiTestPartNavigationMode,
24
+ QtiTestPartPackageModel,
25
+ QtiTestPartSubmissionMode,
14
26
  } from "./qti-package-types.js";
15
27
  import {
16
- packageDescendants,
28
+ childPackageElements,
17
29
  parsePackageXml,
18
30
  pushXmlDiagnostics,
19
31
  resolvePackageHref,
@@ -28,6 +40,7 @@ export interface PackageItemReference {
28
40
  readonly source: QtiPackageItemSource;
29
41
  readonly manifestResourceIdentifier?: string | undefined;
30
42
  readonly assessmentItemRefIdentifier?: string | undefined;
43
+ readonly standards: readonly QtiStandardAlignment[];
31
44
  }
32
45
 
33
46
  export function parseAssessmentTestPackageModel(
@@ -81,8 +94,13 @@ export function parseAssessmentTestPackageModel(
81
94
  );
82
95
  }
83
96
 
84
- const itemRefs = root ? parseAssessmentTestItemRefs(root, href, testDiagnostics) : [];
97
+ const testParts = root ? parseAssessmentTestParts(root, href, testDiagnostics) : [];
98
+ const itemRefs = testParts.flatMap(flattenTestPartItemRefs);
85
99
  const assetHrefs = root ? discoverContentAssetHrefs(xmlFile, testDiagnostics) : [];
100
+ const timeLimits = root
101
+ ? parseTimeLimits(childPackageElements(root, "qti-time-limits")[0], href, testDiagnostics)
102
+ : undefined;
103
+ const timing = root ? parseTimingMetadata(root, href, testDiagnostics, timeLimits) : undefined;
86
104
  const scopedDiagnostics = testDiagnostics.map((diagnostic) =>
87
105
  scopeDiagnosticToPackagePath(href, diagnostic),
88
106
  );
@@ -94,7 +112,9 @@ export function parseAssessmentTestPackageModel(
94
112
  title: root?.attributes.title,
95
113
  manifestResourceIdentifier: firstResource.identifier,
96
114
  itemRefs,
97
- timing: root ? parseTimingMetadata(root, href) : undefined,
115
+ testParts,
116
+ timing,
117
+ timeLimits,
98
118
  standards: root ? parseStandardAlignments(root, href) : [],
99
119
  assetHrefs,
100
120
  diagnostics: scopedDiagnostics,
@@ -103,34 +123,158 @@ export function parseAssessmentTestPackageModel(
103
123
  };
104
124
  }
105
125
 
106
- function parseAssessmentTestItemRefs(
126
+ function parseAssessmentTestParts(
107
127
  root: QtiPackageXmlNode,
108
128
  sourcePath: string,
109
129
  diagnostics: QtiDiagnostic[],
110
- ): QtiAssessmentTestItemRef[] {
111
- const itemRefs: QtiAssessmentTestItemRef[] = [];
112
- for (const refNode of packageDescendants(root, "qti-assessment-item-ref")) {
113
- const rawHref = refNode.attributes.href;
114
- if (!rawHref) {
115
- pushPackageDiagnostic(
116
- diagnostics,
117
- "package.assessmentTest.itemRef.href.missing",
118
- "error",
119
- "qti-assessment-item-ref is missing href.",
130
+ ): QtiTestPartPackageModel[] {
131
+ return childPackageElements(root, "qti-test-part").map((partNode) => {
132
+ const identifier = partNode.attributes.identifier ?? "";
133
+ return {
134
+ identifier,
135
+ navigationMode: parseNavigationMode(partNode, sourcePath, diagnostics),
136
+ submissionMode: parseSubmissionMode(partNode, sourcePath, diagnostics),
137
+ timeLimits: parseTimeLimits(
138
+ childPackageElements(partNode, "qti-time-limits")[0],
120
139
  sourcePath,
121
- );
122
- continue;
123
- }
140
+ diagnostics,
141
+ ),
142
+ sections: childPackageElements(partNode, "qti-assessment-section").map((sectionNode) =>
143
+ parseAssessmentSection(sectionNode, identifier, undefined, sourcePath, diagnostics),
144
+ ),
145
+ attributes: { ...partNode.attributes },
146
+ };
147
+ });
148
+ }
124
149
 
125
- const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
126
- if (!href) continue;
127
- itemRefs.push({
128
- identifier: refNode.attributes.identifier,
129
- href,
130
- attributes: { ...refNode.attributes },
131
- });
132
- }
133
- return itemRefs;
150
+ function parseAssessmentSection(
151
+ sectionNode: QtiPackageXmlNode,
152
+ testPartIdentifier: string,
153
+ parentSectionIdentifier: string | undefined,
154
+ sourcePath: string,
155
+ diagnostics: QtiDiagnostic[],
156
+ ): QtiAssessmentSectionPackageModel {
157
+ const identifier = sectionNode.attributes.identifier ?? "";
158
+ const visible = parseOptionalXmlBoolean(sectionNode, "visible", sourcePath, diagnostics);
159
+ const itemRefs = childPackageElements(sectionNode, "qti-assessment-item-ref").flatMap(
160
+ (refNode) => {
161
+ const rawHref = refNode.attributes.href;
162
+ if (!rawHref) {
163
+ pushPackageDiagnostic(
164
+ diagnostics,
165
+ "package.assessmentTest.itemRef.href.missing",
166
+ "error",
167
+ "qti-assessment-item-ref is missing href.",
168
+ sourcePath,
169
+ );
170
+ return [];
171
+ }
172
+
173
+ const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
174
+ if (!href) return [];
175
+ return [
176
+ {
177
+ identifier: refNode.attributes.identifier,
178
+ href,
179
+ testPartIdentifier,
180
+ sectionIdentifier: identifier,
181
+ timeLimits: parseTimeLimits(
182
+ childPackageElements(refNode, "qti-time-limits")[0],
183
+ sourcePath,
184
+ diagnostics,
185
+ ),
186
+ itemSessionControl: parseItemSessionControl(
187
+ childPackageElements(refNode, "qti-item-session-control")[0],
188
+ sourcePath,
189
+ diagnostics,
190
+ ),
191
+ attributes: { ...refNode.attributes },
192
+ },
193
+ ];
194
+ },
195
+ );
196
+
197
+ return {
198
+ identifier,
199
+ title: sectionNode.attributes.title,
200
+ visible,
201
+ testPartIdentifier,
202
+ parentSectionIdentifier,
203
+ timeLimits: parseTimeLimits(
204
+ childPackageElements(sectionNode, "qti-time-limits")[0],
205
+ sourcePath,
206
+ diagnostics,
207
+ ),
208
+ itemRefs,
209
+ sections: childPackageElements(sectionNode, "qti-assessment-section").map((child) =>
210
+ parseAssessmentSection(child, testPartIdentifier, identifier, sourcePath, diagnostics),
211
+ ),
212
+ attributes: { ...sectionNode.attributes },
213
+ };
214
+ }
215
+
216
+ function flattenTestPartItemRefs(part: QtiTestPartPackageModel): QtiAssessmentTestItemRef[] {
217
+ return part.sections.flatMap(flattenSectionItemRefs);
218
+ }
219
+
220
+ function flattenSectionItemRefs(
221
+ section: QtiAssessmentSectionPackageModel,
222
+ ): QtiAssessmentTestItemRef[] {
223
+ return [...section.itemRefs, ...section.sections.flatMap(flattenSectionItemRefs)];
224
+ }
225
+
226
+ function parseNavigationMode(
227
+ node: QtiPackageXmlNode,
228
+ sourcePath: string,
229
+ diagnostics: QtiDiagnostic[],
230
+ ): QtiTestPartNavigationMode | undefined {
231
+ const raw = node.attributes["navigation-mode"];
232
+ if (raw === "linear" || raw === "nonlinear") return raw;
233
+ pushPackageDiagnostic(
234
+ diagnostics,
235
+ "package.testPart.navigationMode.invalid",
236
+ "error",
237
+ `qti-test-part navigation-mode must be linear or nonlinear, found ${raw ?? "(missing)"}.`,
238
+ sourcePath,
239
+ );
240
+ return undefined;
241
+ }
242
+
243
+ function parseSubmissionMode(
244
+ node: QtiPackageXmlNode,
245
+ sourcePath: string,
246
+ diagnostics: QtiDiagnostic[],
247
+ ): QtiTestPartSubmissionMode | undefined {
248
+ const raw = node.attributes["submission-mode"];
249
+ if (raw === "individual" || raw === "simultaneous") return raw;
250
+ pushPackageDiagnostic(
251
+ diagnostics,
252
+ "package.testPart.submissionMode.invalid",
253
+ "error",
254
+ `qti-test-part submission-mode must be individual or simultaneous, found ${raw ?? "(missing)"}.`,
255
+ sourcePath,
256
+ );
257
+ return undefined;
258
+ }
259
+
260
+ function parseOptionalXmlBoolean(
261
+ node: QtiPackageXmlNode,
262
+ name: string,
263
+ sourcePath: string,
264
+ diagnostics: QtiDiagnostic[],
265
+ ): boolean | undefined {
266
+ const raw = node.attributes[name];
267
+ if (raw === undefined) return undefined;
268
+ const value = parseXmlBoolean(raw);
269
+ if (value !== undefined) return value;
270
+ pushPackageDiagnostic(
271
+ diagnostics,
272
+ `package.attribute.${name}.boolean`,
273
+ "error",
274
+ `${node.localName} ${name} must be an XML boolean, found ${raw}.`,
275
+ sourcePath,
276
+ );
277
+ return undefined;
134
278
  }
135
279
 
136
280
  export function itemReferencesForPackageShape(
@@ -144,6 +288,7 @@ export function itemReferencesForPackageShape(
144
288
  href: itemRef.href,
145
289
  source: "assessment-test",
146
290
  assessmentItemRefIdentifier: itemRef.identifier,
291
+ standards: assessmentTest?.standards ?? [],
147
292
  }));
148
293
  }
149
294
 
@@ -169,6 +314,7 @@ export function itemReferencesForPackageShape(
169
314
  href,
170
315
  source: "manifest",
171
316
  manifestResourceIdentifier: resource.identifier,
317
+ standards: resource.standards,
172
318
  });
173
319
  }
174
320
  return references;
@@ -219,6 +365,13 @@ export function parsePackageItems(
219
365
  const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
220
366
  const root = xmlFile.root?.localName === "qti-assessment-item" ? xmlFile.root : undefined;
221
367
  const item = parsed.document?.item;
368
+ const timeLimits = root
369
+ ? parseTimeLimits(
370
+ childPackageElements(root, "qti-time-limits")[0],
371
+ reference.href,
372
+ diagnostics,
373
+ )
374
+ : undefined;
222
375
 
223
376
  items.push({
224
377
  href: reference.href,
@@ -228,8 +381,16 @@ export function parsePackageItems(
228
381
  identifier: item?.identifier,
229
382
  title: item?.title,
230
383
  document: parsed.document,
231
- timing: root ? parseTimingMetadata(root, reference.href) : undefined,
232
- standards: root ? parseStandardAlignments(root, reference.href) : [],
384
+ timing: root ? parseTimingMetadata(root, reference.href, diagnostics, timeLimits) : undefined,
385
+ timeLimits,
386
+ standards: uniqueStandards([
387
+ ...reference.standards.filter(
388
+ (standard) =>
389
+ standard.resourcePartIdentifier === undefined ||
390
+ standard.resourcePartIdentifier === item?.identifier,
391
+ ),
392
+ ...(root ? parseStandardAlignments(root, reference.href) : []),
393
+ ]),
233
394
  assetHrefs,
234
395
  diagnostics: itemDiagnostics,
235
396
  xml: xmlFile.xml,
@@ -1,5 +1,6 @@
1
1
  import type { QtiPackageXmlNode } from "./package-xml.js";
2
2
  import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
3
+ import { parseStandardAlignments } from "./qti-package-metadata.js";
3
4
  import type { QtiManifestFile, QtiManifestResource, QtiPackageShape } from "./qti-package-types.js";
4
5
  import { childPackageElements, packageDescendants, stripHrefSuffix } from "./qti-package-xml.js";
5
6
  import type { QtiPackageEntry } from "./qti-package-zip.js";
@@ -56,6 +57,7 @@ export function parseManifestResources(
56
57
  href,
57
58
  files,
58
59
  dependencies,
60
+ standards: parseStandardAlignments(node, QTI_PACKAGE_MANIFEST_PATH),
59
61
  attributes: { ...node.attributes },
60
62
  });
61
63
  }