@longsightgroup/qti3-core 0.8.0 → 0.8.2
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/dist/asset-url.d.ts +2 -0
- package/dist/asset-url.d.ts.map +1 -0
- package/dist/asset-url.js +8 -0
- package/dist/asset-url.js.map +1 -0
- package/dist/companion-materials-resolution.d.ts +30 -0
- package/dist/companion-materials-resolution.d.ts.map +1 -0
- package/dist/companion-materials-resolution.js +53 -0
- package/dist/companion-materials-resolution.js.map +1 -0
- package/dist/companion-materials.d.ts +6 -0
- package/dist/companion-materials.d.ts.map +1 -0
- package/dist/companion-materials.js +16 -0
- package/dist/companion-materials.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/parse-diagnostics.d.ts +2 -0
- package/dist/parse-diagnostics.d.ts.map +1 -0
- package/dist/parse-diagnostics.js +28 -0
- package/dist/parse-diagnostics.js.map +1 -0
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +37 -10
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/shared-vocabulary-authoring.d.ts +2 -0
- package/dist/shared-vocabulary-authoring.d.ts.map +1 -1
- package/dist/shared-vocabulary-authoring.js +29 -4
- package/dist/shared-vocabulary-authoring.js.map +1 -1
- package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -1
- package/dist/shared-vocabulary-registry-validation.js +11 -11
- package/dist/shared-vocabulary-registry-validation.js.map +1 -1
- package/dist/shared-vocabulary-support.d.ts.map +1 -1
- package/dist/shared-vocabulary-support.js +64 -11
- package/dist/shared-vocabulary-support.js.map +1 -1
- package/dist/support.js +5 -5
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-companion-materials.d.ts +3 -0
- package/dist/validation-companion-materials.d.ts.map +1 -0
- package/dist/validation-companion-materials.js +40 -0
- package/dist/validation-companion-materials.js.map +1 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +1 -27
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/asset-url.ts +9 -0
- package/src/companion-materials-resolution.ts +99 -0
- package/src/companion-materials.ts +30 -0
- package/src/index.ts +12 -0
- package/src/parse-diagnostics.ts +28 -0
- package/src/parser-item-metadata.ts +79 -10
- package/src/shared-vocabulary-authoring.ts +33 -4
- package/src/shared-vocabulary-registry-validation.ts +13 -10
- package/src/shared-vocabulary-support.ts +73 -26
- package/src/support.ts +5 -5
- package/src/types.ts +8 -0
- package/src/validation-companion-materials.ts +48 -0
- package/src/validation.ts +1 -31
|
@@ -9,10 +9,16 @@ import type {
|
|
|
9
9
|
QtiCompanionMaterialsUnparsedChild,
|
|
10
10
|
QtiContentNode,
|
|
11
11
|
QtiDiagnostic,
|
|
12
|
+
QtiDigitalMaterial,
|
|
12
13
|
QtiModalFeedback,
|
|
13
14
|
QtiPhysicalMaterial,
|
|
14
15
|
QtiStylesheet,
|
|
15
16
|
} from "./types.js";
|
|
17
|
+
import {
|
|
18
|
+
PARSED_COMPANION_MATERIAL_CHILD_NAMES,
|
|
19
|
+
type ParsedCompanionMaterialChildQtiName,
|
|
20
|
+
pushCompanionMaterialParseWarning,
|
|
21
|
+
} from "./companion-materials.js";
|
|
16
22
|
import { childElements, descendants, textContent, type XmlNode } from "./xml.js";
|
|
17
23
|
|
|
18
24
|
export function firstChildElement(
|
|
@@ -146,12 +152,23 @@ export function parseCompanionMaterialsInfo(
|
|
|
146
152
|
if (!node) return undefined;
|
|
147
153
|
|
|
148
154
|
const physicalMaterials: QtiPhysicalMaterial[] = [];
|
|
155
|
+
const digitalMaterials: QtiDigitalMaterial[] = [];
|
|
149
156
|
const unparsedChildren: QtiCompanionMaterialsUnparsedChild[] = [];
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
const companionMaterialHandlers = {
|
|
159
|
+
"qti-physical-material": (child) => {
|
|
153
160
|
const material = parsePhysicalMaterial(child, diagnostics);
|
|
154
161
|
if (material) physicalMaterials.push(material);
|
|
162
|
+
},
|
|
163
|
+
"qti-digital-material": (child) => {
|
|
164
|
+
const material = parseDigitalMaterial(child, diagnostics);
|
|
165
|
+
if (material) digitalMaterials.push(material);
|
|
166
|
+
},
|
|
167
|
+
} satisfies Record<ParsedCompanionMaterialChildQtiName, (child: XmlNode) => void>;
|
|
168
|
+
|
|
169
|
+
for (const child of childElements(node)) {
|
|
170
|
+
if (isParsedCompanionMaterialChildQtiName(child.localName)) {
|
|
171
|
+
companionMaterialHandlers[child.localName](child);
|
|
155
172
|
continue;
|
|
156
173
|
}
|
|
157
174
|
|
|
@@ -167,6 +184,7 @@ export function parseCompanionMaterialsInfo(
|
|
|
167
184
|
|
|
168
185
|
return {
|
|
169
186
|
physicalMaterials,
|
|
187
|
+
digitalMaterials,
|
|
170
188
|
unparsedChildren,
|
|
171
189
|
source: node.source,
|
|
172
190
|
};
|
|
@@ -178,17 +196,68 @@ function parsePhysicalMaterial(
|
|
|
178
196
|
): QtiPhysicalMaterial | undefined {
|
|
179
197
|
const text = textContent(node).trim();
|
|
180
198
|
if (text.length === 0) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return undefined;
|
|
199
|
+
return pushCompanionMaterialParseWarning(
|
|
200
|
+
diagnostics,
|
|
201
|
+
"companionMaterials.physicalMaterial.empty",
|
|
202
|
+
"qti-physical-material requires non-empty text content.",
|
|
203
|
+
node.source?.path,
|
|
204
|
+
node.source,
|
|
205
|
+
);
|
|
189
206
|
}
|
|
190
207
|
return {
|
|
191
208
|
text,
|
|
192
209
|
source: node.source,
|
|
193
210
|
};
|
|
194
211
|
}
|
|
212
|
+
|
|
213
|
+
function parseDigitalMaterial(
|
|
214
|
+
node: XmlNode,
|
|
215
|
+
diagnostics: QtiDiagnostic[],
|
|
216
|
+
): QtiDigitalMaterial | undefined {
|
|
217
|
+
const fileHrefNode = firstChildElement(
|
|
218
|
+
node,
|
|
219
|
+
"qti-file-href",
|
|
220
|
+
diagnostics,
|
|
221
|
+
"companionMaterials.digitalMaterial.fileHref.duplicate",
|
|
222
|
+
);
|
|
223
|
+
if (!fileHrefNode) {
|
|
224
|
+
return pushCompanionMaterialParseWarning(
|
|
225
|
+
diagnostics,
|
|
226
|
+
"companionMaterials.digitalMaterial.fileHref.missing",
|
|
227
|
+
"qti-digital-material requires a qti-file-href child.",
|
|
228
|
+
node.source?.path,
|
|
229
|
+
node.source,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fileHref = textContent(fileHrefNode).trim();
|
|
234
|
+
if (fileHref.length === 0) {
|
|
235
|
+
return pushCompanionMaterialParseWarning(
|
|
236
|
+
diagnostics,
|
|
237
|
+
"companionMaterials.digitalMaterial.fileHref.empty",
|
|
238
|
+
"qti-digital-material qti-file-href requires non-empty text content.",
|
|
239
|
+
fileHrefNode.source?.path,
|
|
240
|
+
fileHrefNode.source,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const resourceIconNode = firstChildElement(
|
|
245
|
+
node,
|
|
246
|
+
"qti-resource-icon",
|
|
247
|
+
diagnostics,
|
|
248
|
+
"companionMaterials.digitalMaterial.resourceIcon.duplicate",
|
|
249
|
+
);
|
|
250
|
+
const resourceIcon = resourceIconNode ? textContent(resourceIconNode).trim() : "";
|
|
251
|
+
return {
|
|
252
|
+
fileHref,
|
|
253
|
+
resourceIcon: resourceIcon.length > 0 ? resourceIcon : undefined,
|
|
254
|
+
attributes: node.attributes,
|
|
255
|
+
source: node.source,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isParsedCompanionMaterialChildQtiName(
|
|
260
|
+
localName: string,
|
|
261
|
+
): localName is ParsedCompanionMaterialChildQtiName {
|
|
262
|
+
return PARSED_COMPANION_MATERIAL_CHILD_NAMES.has(localName);
|
|
263
|
+
}
|
|
@@ -28,6 +28,12 @@ export type QtiSharedVocabularyState = Record<string, QtiSharedVocabularyStateVa
|
|
|
28
28
|
|
|
29
29
|
export type QtiSharedVocabularyAttributeValueType = "string" | "number" | "token-list";
|
|
30
30
|
|
|
31
|
+
export function parsePositiveNumber(value: string | undefined): number | undefined {
|
|
32
|
+
if (value === undefined || value === "") return undefined;
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export type QtiSharedVocabularyField =
|
|
32
38
|
| {
|
|
33
39
|
kind: "class-value";
|
|
@@ -51,6 +57,7 @@ export type QtiSharedVocabularyField =
|
|
|
51
57
|
values?: readonly string[] | undefined;
|
|
52
58
|
valueType?: QtiSharedVocabularyAttributeValueType | undefined;
|
|
53
59
|
numberMinimum?: number | undefined;
|
|
60
|
+
numberExclusiveMinimum?: number | undefined;
|
|
54
61
|
};
|
|
55
62
|
|
|
56
63
|
const choiceAndOrder = SHARED_VOCABULARY_CHOICE_AND_ORDER_INTERACTIONS;
|
|
@@ -145,6 +152,20 @@ export const sharedVocabularyInteractionFields: readonly QtiSharedVocabularyFiel
|
|
|
145
152
|
className: "qti-gap-placement",
|
|
146
153
|
interactions: ["gapMatch"],
|
|
147
154
|
},
|
|
155
|
+
{
|
|
156
|
+
kind: "attribute",
|
|
157
|
+
id: "choices-container-width",
|
|
158
|
+
attributeName: "data-choices-container-width",
|
|
159
|
+
valueType: "number",
|
|
160
|
+
numberExclusiveMinimum: 0,
|
|
161
|
+
interactions: choicesLayoutInteractions,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
kind: "attribute",
|
|
165
|
+
id: "first-column-header",
|
|
166
|
+
attributeName: "data-first-column-header",
|
|
167
|
+
interactions: ["match"],
|
|
168
|
+
},
|
|
148
169
|
{
|
|
149
170
|
kind: "attribute",
|
|
150
171
|
id: "media-player-controls",
|
|
@@ -361,8 +382,7 @@ function parseAttributeValue(
|
|
|
361
382
|
|
|
362
383
|
if (field.valueType === "number") {
|
|
363
384
|
const parsed = Number(value);
|
|
364
|
-
|
|
365
|
-
return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined;
|
|
385
|
+
return isValidRegistryNumber(field, parsed) ? parsed : undefined;
|
|
366
386
|
}
|
|
367
387
|
|
|
368
388
|
if (field.values !== undefined && !field.values.includes(value)) return undefined;
|
|
@@ -382,8 +402,7 @@ function serializeAttributeValue(
|
|
|
382
402
|
|
|
383
403
|
if (field.valueType === "number") {
|
|
384
404
|
if (typeof value !== "number") return undefined;
|
|
385
|
-
|
|
386
|
-
return Number.isFinite(value) && value >= minimum ? String(value) : undefined;
|
|
405
|
+
return isValidRegistryNumber(field, value) ? String(value) : undefined;
|
|
387
406
|
}
|
|
388
407
|
|
|
389
408
|
if (typeof value !== "string" && typeof value !== "number") return undefined;
|
|
@@ -392,6 +411,16 @@ function serializeAttributeValue(
|
|
|
392
411
|
return serialized;
|
|
393
412
|
}
|
|
394
413
|
|
|
414
|
+
function isValidRegistryNumber(
|
|
415
|
+
field: Extract<QtiSharedVocabularyField, { kind: "attribute" }>,
|
|
416
|
+
value: number,
|
|
417
|
+
): boolean {
|
|
418
|
+
if (!Number.isFinite(value)) return false;
|
|
419
|
+
const minimum = field.numberMinimum ?? Number.NEGATIVE_INFINITY;
|
|
420
|
+
const exclusiveMinimum = field.numberExclusiveMinimum;
|
|
421
|
+
return value >= minimum && (exclusiveMinimum === undefined || value > exclusiveMinimum);
|
|
422
|
+
}
|
|
423
|
+
|
|
395
424
|
function isAllowedFieldValue(
|
|
396
425
|
values: readonly QtiSharedVocabularyFieldValue[],
|
|
397
426
|
value: QtiSharedVocabularyStateValue,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
formatSharedVocabularyClassValueRange,
|
|
4
4
|
matchedSharedVocabularyClassNames,
|
|
5
5
|
parseClassValue,
|
|
6
|
+
parseSharedVocabularyAttributes,
|
|
6
7
|
sharedVocabularyFieldById,
|
|
7
8
|
sharedVocabularyFieldsForInteraction,
|
|
8
9
|
sharedVocabularyFixedClassName,
|
|
@@ -130,16 +131,18 @@ export function validateChoicesContainerWidthSharedVocabulary(
|
|
|
130
131
|
): void {
|
|
131
132
|
const width = interaction.attributes["data-choices-container-width"];
|
|
132
133
|
if (width === undefined) return;
|
|
133
|
-
const parsed =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
134
|
+
const parsed = parseSharedVocabularyAttributes(
|
|
135
|
+
{ "data-choices-container-width": width },
|
|
136
|
+
interaction.type,
|
|
137
|
+
)["choices-container-width"];
|
|
138
|
+
if (parsed !== undefined) return;
|
|
139
|
+
diagnostics.push({
|
|
140
|
+
code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
|
|
141
|
+
severity: "warning",
|
|
142
|
+
message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
|
|
143
|
+
path: interaction.source?.path,
|
|
144
|
+
source: interaction.source,
|
|
145
|
+
});
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export function validateMatchInteractionSharedVocabulary(
|
|
@@ -163,6 +163,78 @@ function registryClassFixedSupportEntries(): SharedVocabularyClassSupport[] {
|
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
function registryAttributeSupportProfile(fieldId: string): {
|
|
167
|
+
fixtures: string[];
|
|
168
|
+
tests: string[];
|
|
169
|
+
notes?: string;
|
|
170
|
+
} {
|
|
171
|
+
switch (fieldId) {
|
|
172
|
+
case "choices-container-width":
|
|
173
|
+
return {
|
|
174
|
+
fixtures: sharedVocabularyFixture,
|
|
175
|
+
tests: [
|
|
176
|
+
"packages/core/src/core.test.ts",
|
|
177
|
+
"packages/player/src/interactions/shared-vocabulary.test.ts",
|
|
178
|
+
...browserBehaviorTests,
|
|
179
|
+
...graphicBrowserTests,
|
|
180
|
+
],
|
|
181
|
+
notes:
|
|
182
|
+
"Sets the authored choices-bank width for interactions that support qti-choices-* layout classes.",
|
|
183
|
+
};
|
|
184
|
+
case "first-column-header":
|
|
185
|
+
return {
|
|
186
|
+
fixtures: sharedVocabularyFixture,
|
|
187
|
+
tests: [
|
|
188
|
+
"packages/core/src/core.test.ts",
|
|
189
|
+
...browserBehaviorTests,
|
|
190
|
+
"tests/browser/player-keyboard-a11y.spec.ts",
|
|
191
|
+
],
|
|
192
|
+
notes: "Provides the top-left header text for qti-match-tabular table rendering.",
|
|
193
|
+
};
|
|
194
|
+
case "media-player-controls":
|
|
195
|
+
return {
|
|
196
|
+
fixtures: [mediaPlayerFixture],
|
|
197
|
+
tests: [...mediaBrowserTests, "packages/core/src/shared-vocabulary-validation.test.ts"],
|
|
198
|
+
notes:
|
|
199
|
+
"Supports tokens none, default, play, rewind, captions, and audioDescription on media interactions and rendered media assets.",
|
|
200
|
+
};
|
|
201
|
+
case "media-player-pause-delay":
|
|
202
|
+
return {
|
|
203
|
+
fixtures: [mediaPlayerFixture],
|
|
204
|
+
tests: mediaBrowserTests,
|
|
205
|
+
notes:
|
|
206
|
+
"Reflects authored pause-delay values on rendered media assets. Pause timer behavior is covered in tests/browser/player.spec.ts.",
|
|
207
|
+
};
|
|
208
|
+
case "media-player-pause-duration":
|
|
209
|
+
return {
|
|
210
|
+
fixtures: [mediaPlayerFixture],
|
|
211
|
+
tests: mediaBrowserTests,
|
|
212
|
+
notes:
|
|
213
|
+
"Reflects authored pause-duration values on rendered media assets. Pause timer behavior is covered in tests/browser/player.spec.ts.",
|
|
214
|
+
};
|
|
215
|
+
default:
|
|
216
|
+
return {
|
|
217
|
+
fixtures: sharedVocabularyFixture,
|
|
218
|
+
tests: [...sharedVocabularyUnitTests, ...browserBehaviorTests],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function registryAttributeSupportEntries(): SharedVocabularyClassSupport[] {
|
|
224
|
+
return sharedVocabularyInteractionFields.flatMap((field) => {
|
|
225
|
+
if (field.kind !== "attribute") return [];
|
|
226
|
+
const profile = registryAttributeSupportProfile(field.id);
|
|
227
|
+
return [
|
|
228
|
+
svEntry(field.attributeName, "interaction", "full", {
|
|
229
|
+
interactions: [...field.interactions],
|
|
230
|
+
fixtures: profile.fixtures,
|
|
231
|
+
tests: profile.tests,
|
|
232
|
+
...(profile.notes === undefined ? {} : { notes: profile.notes }),
|
|
233
|
+
}),
|
|
234
|
+
];
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
166
238
|
function svEntry(
|
|
167
239
|
className: string,
|
|
168
240
|
scope: SharedVocabularyClassSupport["scope"],
|
|
@@ -213,19 +285,6 @@ function interactionStylesheetEntry(
|
|
|
213
285
|
});
|
|
214
286
|
}
|
|
215
287
|
|
|
216
|
-
function mediaPlayerSvEntry(
|
|
217
|
-
className: string,
|
|
218
|
-
notes: string,
|
|
219
|
-
tests: string[] = mediaBrowserTests,
|
|
220
|
-
): SharedVocabularyClassSupport {
|
|
221
|
-
return svEntry(className, "interaction", "full", {
|
|
222
|
-
interactions: ["media"],
|
|
223
|
-
fixtures: [mediaPlayerFixture],
|
|
224
|
-
tests,
|
|
225
|
-
notes,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
288
|
export const sharedVocabularyClassSupport: SharedVocabularyClassSupport[] = [
|
|
230
289
|
contentStylesheetEntry(
|
|
231
290
|
"qti-layout-row",
|
|
@@ -285,6 +344,7 @@ export const sharedVocabularyClassSupport: SharedVocabularyClassSupport[] = [
|
|
|
285
344
|
|
|
286
345
|
...registryClassValueSupportEntries(),
|
|
287
346
|
...registryClassFixedSupportEntries(),
|
|
347
|
+
...registryAttributeSupportEntries(),
|
|
288
348
|
svEntry("data-min-selections-message", "interaction", "full", {
|
|
289
349
|
interactions: ["order"],
|
|
290
350
|
fixtures: [orderMinMaxMessagesFixture],
|
|
@@ -353,17 +413,4 @@ export const sharedVocabularyClassSupport: SharedVocabularyClassSupport[] = [
|
|
|
353
413
|
],
|
|
354
414
|
),
|
|
355
415
|
),
|
|
356
|
-
mediaPlayerSvEntry(
|
|
357
|
-
"data-qti-media-player-controls",
|
|
358
|
-
"Supports tokens none, default, play, rewind, captions, and audioDescription on media interactions and rendered media assets.",
|
|
359
|
-
[...mediaBrowserTests, "packages/core/src/shared-vocabulary-validation.test.ts"],
|
|
360
|
-
),
|
|
361
|
-
mediaPlayerSvEntry(
|
|
362
|
-
"data-qti-media-player-pause-delay",
|
|
363
|
-
"Reflects authored pause-delay values on rendered media assets. Pause timer behavior is covered in tests/browser/player.spec.ts.",
|
|
364
|
-
),
|
|
365
|
-
mediaPlayerSvEntry(
|
|
366
|
-
"data-qti-media-player-pause-duration",
|
|
367
|
-
"Reflects authored pause-duration values on rendered media assets. Pause timer behavior is covered in tests/browser/player.spec.ts.",
|
|
368
|
-
),
|
|
369
416
|
];
|
package/src/support.ts
CHANGED
|
@@ -255,7 +255,7 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
|
|
|
255
255
|
"packages/core/src/parser-item-metadata.test.ts",
|
|
256
256
|
],
|
|
257
257
|
notes:
|
|
258
|
-
"Parses qti-physical-material text
|
|
258
|
+
"Parses qti-physical-material text and qti-digital-material file references. Digital materials require non-empty qti-file-href text and may include label, mime-type, and qti-resource-icon metadata. Hosts read resolved materials through createCompanionMaterialsResolution() or player.getCompanionMaterialsResolution().",
|
|
259
259
|
},
|
|
260
260
|
{
|
|
261
261
|
qtiName: "qti-physical-material",
|
|
@@ -277,10 +277,10 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
|
|
|
277
277
|
{
|
|
278
278
|
qtiName: "qti-digital-material",
|
|
279
279
|
category: "itemMetadata",
|
|
280
|
-
support: "
|
|
280
|
+
support: "parsed",
|
|
281
281
|
specReference: "QTI 3.0.1 ASI",
|
|
282
|
-
parse:
|
|
283
|
-
validate:
|
|
282
|
+
parse: true,
|
|
283
|
+
validate: true,
|
|
284
284
|
render: false,
|
|
285
285
|
process: false,
|
|
286
286
|
fixtures: [
|
|
@@ -291,7 +291,7 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
|
|
|
291
291
|
"packages/core/src/parser-item-metadata.test.ts",
|
|
292
292
|
],
|
|
293
293
|
notes:
|
|
294
|
-
"
|
|
294
|
+
"Child of qti-companion-materials-info. Parsed from qti-file-href with optional label, mime-type, and qti-resource-icon metadata. Element attributes are preserved on the parsed model. Missing or empty qti-file-href emits companionMaterials.digitalMaterial.fileHref.* diagnostics.",
|
|
295
295
|
},
|
|
296
296
|
];
|
|
297
297
|
|
package/src/types.ts
CHANGED
|
@@ -456,6 +456,7 @@ export interface QtiAssessmentItem {
|
|
|
456
456
|
|
|
457
457
|
export interface QtiCompanionMaterialsInfo {
|
|
458
458
|
physicalMaterials: QtiPhysicalMaterial[];
|
|
459
|
+
digitalMaterials: QtiDigitalMaterial[];
|
|
459
460
|
unparsedChildren: QtiCompanionMaterialsUnparsedChild[];
|
|
460
461
|
source?: QtiSourceLocation | undefined;
|
|
461
462
|
}
|
|
@@ -470,6 +471,13 @@ export interface QtiPhysicalMaterial {
|
|
|
470
471
|
source?: QtiSourceLocation | undefined;
|
|
471
472
|
}
|
|
472
473
|
|
|
474
|
+
export interface QtiDigitalMaterial {
|
|
475
|
+
fileHref: string;
|
|
476
|
+
resourceIcon?: string | undefined;
|
|
477
|
+
attributes: Record<string, string>;
|
|
478
|
+
source?: QtiSourceLocation | undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
473
481
|
export interface QtiModalFeedback {
|
|
474
482
|
identifier: string;
|
|
475
483
|
outcomeIdentifier: string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { QtiAssessmentItem, QtiDiagnostic } from "./types.js";
|
|
2
|
+
import { PARSED_COMPANION_MATERIAL_CHILD_NAMES } from "./companion-materials.js";
|
|
3
|
+
|
|
4
|
+
export function validateCompanionMaterials(
|
|
5
|
+
item: QtiAssessmentItem,
|
|
6
|
+
diagnostics: QtiDiagnostic[],
|
|
7
|
+
): void {
|
|
8
|
+
const companionMaterials = item.companionMaterials;
|
|
9
|
+
if (!companionMaterials) return;
|
|
10
|
+
|
|
11
|
+
for (const child of companionMaterials.unparsedChildren) {
|
|
12
|
+
if (!PARSED_COMPANION_MATERIAL_CHILD_NAMES.has(child.qtiName)) continue;
|
|
13
|
+
|
|
14
|
+
diagnostics.push({
|
|
15
|
+
code: "companionMaterials.model.inconsistent",
|
|
16
|
+
severity: "error",
|
|
17
|
+
message: `${child.qtiName} must be represented in the parsed companion materials model, not as an unparsed child.`,
|
|
18
|
+
path: child.source?.path,
|
|
19
|
+
source: child.source,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const material of companionMaterials.physicalMaterials) {
|
|
24
|
+
if (material.text.trim().length === 0) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
code: "companionMaterials.physicalMaterial.empty.model",
|
|
27
|
+
severity: "error",
|
|
28
|
+
message:
|
|
29
|
+
"qti-physical-material requires non-empty text content in the parsed companion materials model.",
|
|
30
|
+
path: material.source?.path,
|
|
31
|
+
source: material.source,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const material of companionMaterials.digitalMaterials) {
|
|
37
|
+
if (material.fileHref.trim().length === 0) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
code: "companionMaterials.digitalMaterial.fileHref.empty.model",
|
|
40
|
+
severity: "error",
|
|
41
|
+
message:
|
|
42
|
+
"qti-digital-material requires non-empty qti-file-href text content in the parsed companion materials model.",
|
|
43
|
+
path: material.source?.path,
|
|
44
|
+
source: material.source,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/validation.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interac
|
|
|
25
25
|
import { isValidQtiPatternMask } from "./pattern-mask.js";
|
|
26
26
|
import { validateQtiDataSsmlMetadata } from "./tts.js";
|
|
27
27
|
import { qtiValueToStringList } from "./value-format.js";
|
|
28
|
+
import { validateCompanionMaterials } from "./validation-companion-materials.js";
|
|
28
29
|
|
|
29
30
|
const BUILT_IN_COMPLETION_STATUS = "completionStatus";
|
|
30
31
|
|
|
@@ -713,37 +714,6 @@ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDi
|
|
|
713
714
|
}
|
|
714
715
|
}
|
|
715
716
|
|
|
716
|
-
function validateCompanionMaterials(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
|
|
717
|
-
const companionMaterials = item.companionMaterials;
|
|
718
|
-
if (!companionMaterials) return;
|
|
719
|
-
|
|
720
|
-
for (const child of companionMaterials.unparsedChildren) {
|
|
721
|
-
if (child.qtiName === "qti-physical-material") {
|
|
722
|
-
diagnostics.push({
|
|
723
|
-
code: "companionMaterials.model.inconsistent",
|
|
724
|
-
severity: "error",
|
|
725
|
-
message:
|
|
726
|
-
"Empty qti-physical-material must be represented as a parse warning, not an unparsed child.",
|
|
727
|
-
path: child.source?.path,
|
|
728
|
-
source: child.source,
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
for (const material of companionMaterials.physicalMaterials) {
|
|
734
|
-
if (material.text.trim().length === 0) {
|
|
735
|
-
diagnostics.push({
|
|
736
|
-
code: "companionMaterials.physicalMaterial.empty.model",
|
|
737
|
-
severity: "error",
|
|
738
|
-
message:
|
|
739
|
-
"qti-physical-material requires non-empty text content in the parsed companion materials model.",
|
|
740
|
-
path: material.source?.path,
|
|
741
|
-
source: material.source,
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
717
|
function validateStylesheets(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
|
|
748
718
|
for (const stylesheet of item.stylesheets) {
|
|
749
719
|
if (stylesheet.href.trim().length > 0) continue;
|