@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.
Files changed (58) hide show
  1. package/dist/asset-url.d.ts +2 -0
  2. package/dist/asset-url.d.ts.map +1 -0
  3. package/dist/asset-url.js +8 -0
  4. package/dist/asset-url.js.map +1 -0
  5. package/dist/companion-materials-resolution.d.ts +30 -0
  6. package/dist/companion-materials-resolution.d.ts.map +1 -0
  7. package/dist/companion-materials-resolution.js +53 -0
  8. package/dist/companion-materials-resolution.js.map +1 -0
  9. package/dist/companion-materials.d.ts +6 -0
  10. package/dist/companion-materials.d.ts.map +1 -0
  11. package/dist/companion-materials.js +16 -0
  12. package/dist/companion-materials.js.map +1 -0
  13. package/dist/index.d.ts +5 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/parse-diagnostics.d.ts +2 -0
  18. package/dist/parse-diagnostics.d.ts.map +1 -0
  19. package/dist/parse-diagnostics.js +28 -0
  20. package/dist/parse-diagnostics.js.map +1 -0
  21. package/dist/parser-item-metadata.d.ts.map +1 -1
  22. package/dist/parser-item-metadata.js +37 -10
  23. package/dist/parser-item-metadata.js.map +1 -1
  24. package/dist/shared-vocabulary-authoring.d.ts +2 -0
  25. package/dist/shared-vocabulary-authoring.d.ts.map +1 -1
  26. package/dist/shared-vocabulary-authoring.js +29 -4
  27. package/dist/shared-vocabulary-authoring.js.map +1 -1
  28. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -1
  29. package/dist/shared-vocabulary-registry-validation.js +11 -11
  30. package/dist/shared-vocabulary-registry-validation.js.map +1 -1
  31. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  32. package/dist/shared-vocabulary-support.js +64 -11
  33. package/dist/shared-vocabulary-support.js.map +1 -1
  34. package/dist/support.js +5 -5
  35. package/dist/support.js.map +1 -1
  36. package/dist/types.d.ts +7 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/validation-companion-materials.d.ts +3 -0
  39. package/dist/validation-companion-materials.d.ts.map +1 -0
  40. package/dist/validation-companion-materials.js +40 -0
  41. package/dist/validation-companion-materials.js.map +1 -0
  42. package/dist/validation.d.ts.map +1 -1
  43. package/dist/validation.js +1 -27
  44. package/dist/validation.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/asset-url.ts +9 -0
  47. package/src/companion-materials-resolution.ts +99 -0
  48. package/src/companion-materials.ts +30 -0
  49. package/src/index.ts +12 -0
  50. package/src/parse-diagnostics.ts +28 -0
  51. package/src/parser-item-metadata.ts +79 -10
  52. package/src/shared-vocabulary-authoring.ts +33 -4
  53. package/src/shared-vocabulary-registry-validation.ts +13 -10
  54. package/src/shared-vocabulary-support.ts +73 -26
  55. package/src/support.ts +5 -5
  56. package/src/types.ts +8 -0
  57. package/src/validation-companion-materials.ts +48 -0
  58. 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
- for (const child of childElements(node)) {
152
- if (child.localName === "qti-physical-material") {
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
- diagnostics.push({
182
- code: "companionMaterials.physicalMaterial.empty",
183
- severity: "warning",
184
- message: "qti-physical-material requires non-empty text content.",
185
- path: node.source?.path,
186
- source: node.source,
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
- const minimum = field.numberMinimum ?? Number.NEGATIVE_INFINITY;
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
- const minimum = field.numberMinimum ?? Number.NEGATIVE_INFINITY;
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 = Number(width);
134
- if (!Number.isFinite(parsed) || parsed <= 0) {
135
- diagnostics.push({
136
- code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
137
- severity: "warning",
138
- message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
139
- path: interaction.source?.path,
140
- source: interaction.source,
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 only. qti-digital-material is preserved in unparsedChildren and emits companionMaterials.child.unsupported at parse time.",
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: "unsupported",
280
+ support: "parsed",
281
281
  specReference: "QTI 3.0.1 ASI",
282
- parse: false,
283
- validate: false,
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
- "Recognized as an unparsed child of qti-companion-materials-info. Full digital material parsing is not implemented yet.",
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;