@longsightgroup/qti3-core 0.7.3 → 0.8.1

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 (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +7 -1
  3. package/dist/content-text.d.ts.map +1 -1
  4. package/dist/content-text.js +14 -0
  5. package/dist/content-text.js.map +1 -1
  6. package/dist/index.d.ts +7 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/package-xml.d.ts +12 -0
  11. package/dist/package-xml.d.ts.map +1 -0
  12. package/dist/package-xml.js +17 -0
  13. package/dist/package-xml.js.map +1 -0
  14. package/dist/parser-custom-interactions.d.ts +8 -0
  15. package/dist/parser-custom-interactions.d.ts.map +1 -0
  16. package/dist/parser-custom-interactions.js +178 -0
  17. package/dist/parser-custom-interactions.js.map +1 -0
  18. package/dist/parser-declarations.d.ts +6 -0
  19. package/dist/parser-declarations.d.ts.map +1 -0
  20. package/dist/parser-declarations.js +136 -0
  21. package/dist/parser-declarations.js.map +1 -0
  22. package/dist/parser-item-metadata.d.ts +9 -0
  23. package/dist/parser-item-metadata.d.ts.map +1 -0
  24. package/dist/parser-item-metadata.js +158 -0
  25. package/dist/parser-item-metadata.js.map +1 -0
  26. package/dist/parser-processing.d.ts +5 -0
  27. package/dist/parser-processing.d.ts.map +1 -0
  28. package/dist/parser-processing.js +642 -0
  29. package/dist/parser-processing.js.map +1 -0
  30. package/dist/parser-values.d.ts +8 -0
  31. package/dist/parser-values.d.ts.map +1 -0
  32. package/dist/parser-values.js +49 -0
  33. package/dist/parser-values.js.map +1 -0
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +15 -1083
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-expression-tags.d.ts +56 -0
  38. package/dist/processing-expression-tags.d.ts.map +1 -0
  39. package/dist/processing-expression-tags.js +52 -0
  40. package/dist/processing-expression-tags.js.map +1 -0
  41. package/dist/processing-rules.d.ts +3 -0
  42. package/dist/processing-rules.d.ts.map +1 -0
  43. package/dist/processing-rules.js +10 -0
  44. package/dist/processing-rules.js.map +1 -0
  45. package/dist/serializer-processing-expressions.d.ts +8 -0
  46. package/dist/serializer-processing-expressions.d.ts.map +1 -0
  47. package/dist/serializer-processing-expressions.js +198 -0
  48. package/dist/serializer-processing-expressions.js.map +1 -0
  49. package/dist/serializer-processing-xml.d.ts +15 -0
  50. package/dist/serializer-processing-xml.d.ts.map +1 -0
  51. package/dist/serializer-processing-xml.js +46 -0
  52. package/dist/serializer-processing-xml.js.map +1 -0
  53. package/dist/serializer-processing.d.ts +8 -0
  54. package/dist/serializer-processing.d.ts.map +1 -0
  55. package/dist/serializer-processing.js +106 -0
  56. package/dist/serializer-processing.js.map +1 -0
  57. package/dist/shared-vocabulary-authoring.d.ts +52 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +327 -0
  60. package/dist/shared-vocabulary-authoring.js.map +1 -0
  61. package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
  62. package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
  63. package/dist/shared-vocabulary-interaction-sets.js +23 -0
  64. package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
  65. package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
  66. package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
  67. package/dist/shared-vocabulary-interaction-validation.js +99 -0
  68. package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
  69. package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
  70. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
  71. package/dist/shared-vocabulary-registry-validation.js +147 -0
  72. package/dist/shared-vocabulary-registry-validation.js.map +1 -0
  73. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  74. package/dist/shared-vocabulary-support.js +160 -67
  75. package/dist/shared-vocabulary-support.js.map +1 -1
  76. package/dist/shared-vocabulary.d.ts +6 -0
  77. package/dist/shared-vocabulary.d.ts.map +1 -1
  78. package/dist/shared-vocabulary.js +12 -0
  79. package/dist/shared-vocabulary.js.map +1 -1
  80. package/dist/support.d.ts +4 -1
  81. package/dist/support.d.ts.map +1 -1
  82. package/dist/support.js +130 -0
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +32 -7
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/validation.d.ts.map +1 -1
  87. package/dist/validation.js +29 -250
  88. package/dist/validation.js.map +1 -1
  89. package/dist/xml.d.ts +10 -0
  90. package/dist/xml.d.ts.map +1 -1
  91. package/dist/xml.js +321 -272
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +20 -1
  95. package/src/index.ts +68 -0
  96. package/src/interaction-test-fixtures.ts +44 -0
  97. package/src/package-xml.ts +30 -0
  98. package/src/parser-custom-interactions.ts +309 -0
  99. package/src/parser-declarations.ts +174 -0
  100. package/src/parser-item-metadata.ts +194 -0
  101. package/src/parser-processing.ts +701 -0
  102. package/src/parser-values.ts +61 -0
  103. package/src/parser.ts +36 -1227
  104. package/src/processing-expression-tags.ts +65 -0
  105. package/src/processing-rules.ts +11 -0
  106. package/src/serializer-processing-expressions.ts +456 -0
  107. package/src/serializer-processing-xml.ts +67 -0
  108. package/src/serializer-processing.fixtures.ts +469 -0
  109. package/src/serializer-processing.ts +196 -0
  110. package/src/shared-vocabulary-authoring.ts +438 -0
  111. package/src/shared-vocabulary-interaction-sets.ts +28 -0
  112. package/src/shared-vocabulary-interaction-validation.ts +143 -0
  113. package/src/shared-vocabulary-registry-validation.ts +210 -0
  114. package/src/shared-vocabulary-support.ts +200 -127
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +144 -0
  117. package/src/types.ts +42 -7
  118. package/src/validation.ts +33 -341
  119. package/src/xml.ts +376 -276
package/src/support.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import type {
2
+ QtiDiagnostic,
2
3
  QtiElementSupport,
3
4
  QtiInteractionElementSupport,
5
+ QtiInteractionRegistryStatus,
4
6
  QtiInteractionType,
7
+ QtiItemMetadataElementSupport,
5
8
  QtiProcessingElementSupport,
9
+ QtiSourceLocation,
6
10
  } from "./types.js";
7
11
 
8
12
  export const interactionSupport: QtiInteractionElementSupport[] = [
@@ -188,10 +192,114 @@ export const processingSupport: QtiProcessingElementSupport[] = [
188
192
  processingEntry("qti-custom-operator", "packages/core/src/core.test.ts"),
189
193
  ];
190
194
 
195
+ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
196
+ {
197
+ qtiName: "qti-catalog-info",
198
+ category: "itemMetadata",
199
+ support: "parsed",
200
+ specReference: "QTI 3.0.1 ASI",
201
+ parse: true,
202
+ validate: true,
203
+ render: false,
204
+ process: false,
205
+ fixtures: [
206
+ "packages/fixtures/packages/basic-item-player/valid-item-only/items/tolerance-extra-features.xml",
207
+ ],
208
+ tests: ["packages/core/src/core.test.ts", "packages/core/src/parser-item-metadata.test.ts"],
209
+ notes:
210
+ "Parsed by parser-item-metadata.ts and validated by validateCatalogInfo. Duplicate containers emit item.child.duplicate.",
211
+ },
212
+ {
213
+ qtiName: "qti-stylesheet",
214
+ category: "itemMetadata",
215
+ support: "parsed",
216
+ specReference: "QTI 3.0.1 ASI",
217
+ parse: true,
218
+ validate: true,
219
+ render: false,
220
+ process: false,
221
+ fixtures: [
222
+ "packages/fixtures/packages/basic-item-player/valid-item-only/items/tolerance-extra-features.xml",
223
+ ],
224
+ tests: ["packages/core/src/core.test.ts", "packages/core/src/parser-item-metadata.test.ts"],
225
+ notes: "Parsed by parser-item-metadata.ts and validated by validateStylesheets.",
226
+ },
227
+ {
228
+ qtiName: "qti-modal-feedback",
229
+ category: "itemMetadata",
230
+ support: "parsed",
231
+ specReference: "QTI 3.0.1 ASI",
232
+ parse: true,
233
+ validate: true,
234
+ render: false,
235
+ process: false,
236
+ fixtures: ["packages/fixtures/src/index.ts"],
237
+ tests: ["packages/core/src/core.test.ts", "packages/core/src/parser-item-metadata.test.ts"],
238
+ notes:
239
+ "Parsed by parser-item-metadata.ts and validated by validateModalFeedback. Player rendering remains out of scope for this metadata slice.",
240
+ },
241
+ {
242
+ qtiName: "qti-companion-materials-info",
243
+ category: "itemMetadata",
244
+ support: "parsed",
245
+ specReference: "QTI 3.0.1 ASI",
246
+ parse: true,
247
+ validate: true,
248
+ render: false,
249
+ process: false,
250
+ fixtures: [
251
+ "packages/fixtures/packages/basic-item-player/valid-item-only/items/tolerance-extra-features.xml",
252
+ ],
253
+ tests: [
254
+ "packages/core/src/parser-companion-materials.test.ts",
255
+ "packages/core/src/parser-item-metadata.test.ts",
256
+ ],
257
+ notes:
258
+ "Parses qti-physical-material text only. qti-digital-material is preserved in unparsedChildren and emits companionMaterials.child.unsupported at parse time.",
259
+ },
260
+ {
261
+ qtiName: "qti-physical-material",
262
+ category: "itemMetadata",
263
+ support: "parsed",
264
+ specReference: "QTI 3.0.1 ASI",
265
+ parse: true,
266
+ validate: true,
267
+ render: false,
268
+ process: false,
269
+ fixtures: ["packages/core/src/parser-companion-materials.test.ts"],
270
+ tests: [
271
+ "packages/core/src/parser-companion-materials.test.ts",
272
+ "packages/core/src/parser-item-metadata.test.ts",
273
+ ],
274
+ notes:
275
+ "Child of qti-companion-materials-info. Empty XML text emits companionMaterials.physicalMaterial.empty at parse time; invalid model entries emit companionMaterials.physicalMaterial.empty.model at validation time.",
276
+ },
277
+ {
278
+ qtiName: "qti-digital-material",
279
+ category: "itemMetadata",
280
+ support: "unsupported",
281
+ specReference: "QTI 3.0.1 ASI",
282
+ parse: false,
283
+ validate: false,
284
+ render: false,
285
+ process: false,
286
+ fixtures: [
287
+ "packages/fixtures/packages/basic-item-player/valid-item-only/items/tolerance-extra-features.xml",
288
+ ],
289
+ tests: [
290
+ "packages/core/src/parser-companion-materials.test.ts",
291
+ "packages/core/src/parser-item-metadata.test.ts",
292
+ ],
293
+ notes:
294
+ "Recognized as an unparsed child of qti-companion-materials-info. Full digital material parsing is not implemented yet.",
295
+ },
296
+ ];
297
+
191
298
  export const elementSupport: QtiElementSupport[] = [
192
299
  ...interactionSupport,
193
300
  ...deprecatedInteractionSupport,
194
301
  ...processingSupport,
302
+ ...itemMetadataSupport,
195
303
  ];
196
304
 
197
305
  const allInteractionSupport = [...interactionSupport, ...deprecatedInteractionSupport];
@@ -204,6 +312,42 @@ export function getInteractionSupport(qtiName: string): QtiElementSupport | unde
204
312
  return allInteractionSupport.find((item) => item.qtiName === qtiName);
205
313
  }
206
314
 
315
+ export function interactionRegistryStatus(qtiName: string): QtiInteractionRegistryStatus {
316
+ const support = getInteractionSupport(qtiName);
317
+ if (!support) return "unsupported";
318
+ return support.support === "deprecated" ? "deprecated" : "supported";
319
+ }
320
+
321
+ export function interactionRegistryDiagnostics(
322
+ qtiName: string,
323
+ source: QtiSourceLocation,
324
+ ): QtiDiagnostic[] {
325
+ const support = getInteractionSupport(qtiName);
326
+ if (!support) {
327
+ return [
328
+ {
329
+ code: "interaction.unsupported",
330
+ severity: "warning",
331
+ message: `${qtiName} is not currently in the support registry.`,
332
+ path: source.path,
333
+ source,
334
+ },
335
+ ];
336
+ }
337
+ if (support.support === "deprecated") {
338
+ return [
339
+ {
340
+ code: "interaction.deprecated",
341
+ severity: "warning",
342
+ message: `${qtiName} is deprecated. ${support.notes ?? ""}`.trim(),
343
+ path: source.path,
344
+ source,
345
+ },
346
+ ];
347
+ }
348
+ return [];
349
+ }
350
+
207
351
  function entry(qtiName: string, interactionType: QtiInteractionType): QtiInteractionElementSupport {
208
352
  return {
209
353
  qtiName,
package/src/types.ts CHANGED
@@ -189,8 +189,13 @@ export type QtiInteractionType =
189
189
  | "textEntry"
190
190
  | "upload";
191
191
 
192
+ /** Parse-time status from the core interaction support registry. */
193
+ export type QtiInteractionRegistryStatus = "supported" | "deprecated" | "unsupported";
194
+
192
195
  export interface QtiInteraction {
193
196
  type: QtiInteractionType;
197
+ /** Parse-time status from the core interaction support registry. */
198
+ registryStatus: QtiInteractionRegistryStatus;
194
199
  qtiName: string;
195
200
  responseIdentifier?: string | undefined;
196
201
  responseCardinality?: QtiCardinality | undefined;
@@ -204,6 +209,7 @@ export interface QtiInteraction {
204
209
  contextText?: string | undefined;
205
210
  object?: QtiObjectAsset | undefined;
206
211
  positionObjectStage?: QtiObjectAsset | undefined;
212
+ customInteraction?: QtiCustomInteractionDefinition | undefined;
207
213
  portableCustom?: QtiPortableCustomDefinition | undefined;
208
214
  choices: QtiChoice[];
209
215
  hottextSegments?: QtiHottextSegment[] | undefined;
@@ -272,20 +278,25 @@ export interface QtiMediaTrack {
272
278
  source?: QtiSourceLocation | undefined;
273
279
  }
274
280
 
275
- export interface QtiPortableCustomDefinition {
281
+ export interface QtiInteractionMarkupDefinition {
276
282
  responseIdentifier?: string | undefined;
283
+ interactionMarkup: QtiContentNode[];
284
+ interactionMarkupRaw?: string | undefined;
285
+ dataAttributes: Record<string, string>;
286
+ attributes: Record<string, string>;
287
+ source?: QtiSourceLocation | undefined;
288
+ }
289
+
290
+ export type QtiCustomInteractionDefinition = QtiInteractionMarkupDefinition;
291
+
292
+ export interface QtiPortableCustomDefinition extends QtiInteractionMarkupDefinition {
277
293
  customInteractionTypeIdentifier?: string | undefined;
278
294
  module?: string | undefined;
279
295
  interactionModules?: QtiPortableCustomInteractionModules | undefined;
280
- interactionMarkup: QtiContentNode[];
281
- interactionMarkupRaw?: string | undefined;
282
296
  templateVariables: QtiPortableCustomVariableBinding[];
283
297
  contextVariables: QtiPortableCustomVariableBinding[];
284
298
  stylesheets: QtiStylesheet[];
285
299
  catalogInfo?: QtiCatalogInfo | undefined;
286
- dataAttributes: Record<string, string>;
287
- attributes: Record<string, string>;
288
- source?: QtiSourceLocation | undefined;
289
300
  }
290
301
 
291
302
  export interface QtiPortableCustomInteractionModules {
@@ -435,6 +446,7 @@ export interface QtiAssessmentItem {
435
446
  interactions: QtiInteraction[];
436
447
  modalFeedback: QtiModalFeedback[];
437
448
  catalogInfo?: QtiCatalogInfo | undefined;
449
+ companionMaterials?: QtiCompanionMaterialsInfo | undefined;
438
450
  catalogReferences: QtiCatalogReference[];
439
451
  stylesheets: QtiStylesheet[];
440
452
  body: QtiContentNode[];
@@ -442,6 +454,22 @@ export interface QtiAssessmentItem {
442
454
  source?: QtiSourceLocation | undefined;
443
455
  }
444
456
 
457
+ export interface QtiCompanionMaterialsInfo {
458
+ physicalMaterials: QtiPhysicalMaterial[];
459
+ unparsedChildren: QtiCompanionMaterialsUnparsedChild[];
460
+ source?: QtiSourceLocation | undefined;
461
+ }
462
+
463
+ export interface QtiCompanionMaterialsUnparsedChild {
464
+ qtiName: string;
465
+ source?: QtiSourceLocation | undefined;
466
+ }
467
+
468
+ export interface QtiPhysicalMaterial {
469
+ text: string;
470
+ source?: QtiSourceLocation | undefined;
471
+ }
472
+
445
473
  export interface QtiModalFeedback {
446
474
  identifier: string;
447
475
  outcomeIdentifier: string;
@@ -688,7 +716,10 @@ export interface QtiScoreResult {
688
716
  state: QtiAttemptStateV1;
689
717
  }
690
718
 
691
- export type QtiElementSupport = QtiInteractionElementSupport | QtiProcessingElementSupport;
719
+ export type QtiElementSupport =
720
+ | QtiInteractionElementSupport
721
+ | QtiProcessingElementSupport
722
+ | QtiItemMetadataElementSupport;
692
723
 
693
724
  interface QtiElementSupportBase {
694
725
  qtiName: string;
@@ -712,6 +743,10 @@ export interface QtiProcessingElementSupport extends QtiElementSupportBase {
712
743
  category: "processing";
713
744
  }
714
745
 
746
+ export interface QtiItemMetadataElementSupport extends QtiElementSupportBase {
747
+ category: "itemMetadata";
748
+ }
749
+
715
750
  export interface SharedVocabularyClassSupport {
716
751
  className: string;
717
752
  scope: "interaction" | "content" | "gap";
package/src/validation.ts CHANGED
@@ -21,11 +21,7 @@ import type {
21
21
  QtiValue,
22
22
  QtiValidationResult,
23
23
  } from "./types.js";
24
- import {
25
- validateSharedVocabularyExtendedText,
26
- validateSharedVocabularyInputWidth,
27
- validateSharedVocabularyMediaPlayerControls,
28
- } from "./shared-vocabulary-validation.js";
24
+ import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interaction-validation.js";
29
25
  import { isValidQtiPatternMask } from "./pattern-mask.js";
30
26
  import { validateQtiDataSsmlMetadata } from "./tts.js";
31
27
  import { qtiValueToStringList } from "./value-format.js";
@@ -45,6 +41,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
45
41
  validateInteractions(item, diagnostics);
46
42
  validateModalFeedback(item, diagnostics);
47
43
  validateCatalogInfo(item, diagnostics);
44
+ validateCompanionMaterials(item, diagnostics);
48
45
  validateStylesheets(item, diagnostics);
49
46
  diagnostics.push(...validateQtiDataSsmlMetadata(item));
50
47
  validateResponseProcessingTemplate(item, diagnostics);
@@ -716,6 +713,37 @@ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDi
716
713
  }
717
714
  }
718
715
 
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
+
719
747
  function validateStylesheets(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
720
748
  for (const stylesheet of item.stylesheets) {
721
749
  if (stylesheet.href.trim().length > 0) continue;
@@ -1692,342 +1720,6 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
1692
1720
  }
1693
1721
  }
1694
1722
 
1695
- function validateInteractionSharedVocabulary(
1696
- interaction: QtiInteraction,
1697
- diagnostics: QtiDiagnostic[],
1698
- ): void {
1699
- if (
1700
- interaction.type !== "choice" &&
1701
- interaction.type !== "match" &&
1702
- interaction.type !== "gapMatch" &&
1703
- interaction.type !== "graphicGapMatch" &&
1704
- interaction.type !== "inlineChoice" &&
1705
- interaction.type !== "textEntry" &&
1706
- interaction.type !== "extendedText" &&
1707
- interaction.type !== "media" &&
1708
- interaction.type !== "order"
1709
- ) {
1710
- return;
1711
- }
1712
- const classNames = sharedClassNames(interaction.attributes);
1713
- const classNameSet = new Set(classNames);
1714
- if (interaction.type === "media") {
1715
- validateMediaInteractionSharedVocabulary(interaction, diagnostics);
1716
- return;
1717
- }
1718
-
1719
- if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
1720
- validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics);
1721
- return;
1722
- }
1723
-
1724
- if (interaction.type === "extendedText") {
1725
- diagnostics.push(
1726
- ...validateSharedVocabularyExtendedText({
1727
- classNames,
1728
- subjectQtiName: interaction.qtiName,
1729
- expectedLength: interaction.attributes["expected-length"],
1730
- path: interaction.source?.path,
1731
- source: interaction.source,
1732
- }),
1733
- );
1734
- return;
1735
- }
1736
-
1737
- if (interaction.type === "choice" || interaction.type === "order") {
1738
- validateSharedVocabularyLabelClasses(interaction, classNames, diagnostics);
1739
- }
1740
-
1741
- if (
1742
- interaction.type === "match" ||
1743
- interaction.type === "gapMatch" ||
1744
- interaction.type === "graphicGapMatch"
1745
- ) {
1746
- if (interaction.type === "match") {
1747
- validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics);
1748
- }
1749
- validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1750
- validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1751
- if (interaction.type === "gapMatch") {
1752
- validateGapInputWidthSharedVocabulary(interaction, diagnostics);
1753
- }
1754
- return;
1755
- }
1756
-
1757
- if (interaction.type === "order") {
1758
- validateOrderInteractionSharedVocabulary(interaction, classNames, classNameSet, diagnostics);
1759
- return;
1760
- }
1761
-
1762
- validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1763
-
1764
- const validStackingClasses = new Set<string>();
1765
- const invalidStackingClasses = new Set<string>();
1766
- for (const className of classNames) {
1767
- const stacking = /^qti-choices-stacking-(\d+)$/.exec(className)?.[1];
1768
- if (stacking === undefined) continue;
1769
- const count = Number(stacking);
1770
- if (count >= 1 && count <= 5) validStackingClasses.add(className);
1771
- else invalidStackingClasses.add(className);
1772
- }
1773
-
1774
- if (validStackingClasses.size > 1) {
1775
- diagnostics.push({
1776
- code: "interaction.sharedVocabulary.stackingConflict",
1777
- severity: "warning",
1778
- message: `qti-choice-interaction should not include multiple qti-choices-stacking-* classes: ${[...validStackingClasses].join(", ")}. The first valid stacking class in class attribute order takes precedence at runtime.`,
1779
- path: interaction.source?.path,
1780
- source: interaction.source,
1781
- });
1782
- }
1783
-
1784
- for (const className of invalidStackingClasses) {
1785
- diagnostics.push({
1786
- code: "interaction.sharedVocabulary.stackingInvalid",
1787
- severity: "warning",
1788
- message: `qti-choice-interaction shared vocabulary class ${className} is not supported; expected qti-choices-stacking-1 through qti-choices-stacking-5.`,
1789
- path: interaction.source?.path,
1790
- source: interaction.source,
1791
- });
1792
- }
1793
- }
1794
-
1795
- function validateSharedVocabularyLabelClasses(
1796
- interaction: QtiInteraction,
1797
- classNames: string[],
1798
- diagnostics: QtiDiagnostic[],
1799
- ): void {
1800
- const labelClasses = classNames.filter((className) =>
1801
- [
1802
- "qti-labels-decimal",
1803
- "qti-labels-cjk-ideographic",
1804
- "qti-labels-lower-alpha",
1805
- "qti-labels-upper-alpha",
1806
- ].includes(className),
1807
- );
1808
- if (new Set(labelClasses).size > 1) {
1809
- diagnostics.push({
1810
- code: "interaction.sharedVocabulary.labelsConflict",
1811
- severity: "warning",
1812
- message: `${interaction.qtiName} should not include multiple qti-labels-* style classes: ${[...new Set(labelClasses)].join(", ")}. qti-labels-decimal takes precedence over qti-labels-cjk-ideographic, then qti-labels-lower-alpha, then qti-labels-upper-alpha at runtime.`,
1813
- path: interaction.source?.path,
1814
- source: interaction.source,
1815
- });
1816
- }
1817
-
1818
- const suffixClasses = classNames.filter((className) =>
1819
- [
1820
- "qti-labels-suffix-none",
1821
- "qti-labels-suffix-period",
1822
- "qti-labels-suffix-parenthesis",
1823
- ].includes(className),
1824
- );
1825
- if (new Set(suffixClasses).size <= 1) return;
1826
- diagnostics.push({
1827
- code: "interaction.sharedVocabulary.labelSuffixConflict",
1828
- severity: "warning",
1829
- message: `${interaction.qtiName} should not include multiple qti-labels-suffix-* classes: ${[...new Set(suffixClasses)].join(", ")}. qti-labels-suffix-none takes precedence over qti-labels-suffix-period, then qti-labels-suffix-parenthesis at runtime.`,
1830
- path: interaction.source?.path,
1831
- source: interaction.source,
1832
- });
1833
- }
1834
-
1835
- function validateOrientationSharedVocabulary(
1836
- interaction: QtiInteraction,
1837
- classNameSet: Set<string>,
1838
- diagnostics: QtiDiagnostic[],
1839
- ): void {
1840
- if (
1841
- !classNameSet.has("qti-orientation-horizontal") ||
1842
- !classNameSet.has("qti-orientation-vertical")
1843
- ) {
1844
- return;
1845
- }
1846
- diagnostics.push({
1847
- code: "interaction.sharedVocabulary.orientationConflict",
1848
- severity: "warning",
1849
- message: `${interaction.qtiName} should not include both qti-orientation-horizontal and qti-orientation-vertical; qti-orientation-horizontal takes precedence at runtime.`,
1850
- path: interaction.source?.path,
1851
- source: interaction.source,
1852
- });
1853
- }
1854
-
1855
- function validateOrderInteractionSharedVocabulary(
1856
- interaction: QtiInteraction,
1857
- classNames: string[],
1858
- classNameSet: Set<string>,
1859
- diagnostics: QtiDiagnostic[],
1860
- ): void {
1861
- validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1862
- validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1863
- validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1864
- }
1865
-
1866
- const SHARED_VOCABULARY_CHOICES_POSITION_CLASSES = [
1867
- "qti-choices-top",
1868
- "qti-choices-bottom",
1869
- "qti-choices-left",
1870
- "qti-choices-right",
1871
- ] as const;
1872
-
1873
- function validateMatchInteractionSharedVocabulary(
1874
- interaction: QtiInteraction,
1875
- classNames: string[],
1876
- diagnostics: QtiDiagnostic[],
1877
- ): void {
1878
- const hasTabular = classNames.includes("qti-match-tabular");
1879
- const hasHeaderHidden = classNames.includes("qti-header-hidden");
1880
- const firstColumnHeader = interaction.attributes["data-first-column-header"];
1881
- if (!hasTabular && (hasHeaderHidden || firstColumnHeader !== undefined)) {
1882
- diagnostics.push({
1883
- code: "interaction.sharedVocabulary.matchTabularContext",
1884
- severity: "warning",
1885
- message:
1886
- "qti-match-interaction shared vocabulary class qti-header-hidden and data-first-column-header are only relevant when qti-match-tabular is specified; they are ignored at runtime.",
1887
- path: interaction.source?.path,
1888
- source: interaction.source,
1889
- });
1890
- }
1891
- if (hasTabular && hasHeaderHidden && firstColumnHeader !== undefined) {
1892
- diagnostics.push({
1893
- code: "interaction.sharedVocabulary.matchTabularHeaderHidden",
1894
- severity: "warning",
1895
- message:
1896
- "qti-match-interaction data-first-column-header is ignored when qti-header-hidden suppresses the tabular header row.",
1897
- path: interaction.source?.path,
1898
- source: interaction.source,
1899
- });
1900
- }
1901
- if (!hasTabular) return;
1902
-
1903
- const choicesPositionClasses = classNames.filter((className) =>
1904
- SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
1905
- className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
1906
- ),
1907
- );
1908
- if (
1909
- choicesPositionClasses.length > 0 ||
1910
- interaction.attributes["data-choices-container-width"] !== undefined
1911
- ) {
1912
- diagnostics.push({
1913
- code: "interaction.sharedVocabulary.matchTabularChoicesConflict",
1914
- severity: "warning",
1915
- message:
1916
- "qti-match-interaction qti-match-tabular uses a matrix layout; qti-choices-* position classes and data-choices-container-width are ignored at runtime.",
1917
- path: interaction.source?.path,
1918
- source: interaction.source,
1919
- });
1920
- }
1921
- if (!hasHeaderHidden && (firstColumnHeader === undefined || firstColumnHeader === "")) {
1922
- diagnostics.push({
1923
- code: "interaction.sharedVocabulary.matchTabularFirstColumnHeader",
1924
- severity: "warning",
1925
- message:
1926
- "qti-match-interaction with qti-match-tabular should specify data-first-column-header for the top-left table header when the tabular header row is shown.",
1927
- path: interaction.source?.path,
1928
- source: interaction.source,
1929
- });
1930
- }
1931
- }
1932
-
1933
- function validateChoicesPositionSharedVocabulary(
1934
- interaction: QtiInteraction,
1935
- classNames: string[],
1936
- diagnostics: QtiDiagnostic[],
1937
- ): void {
1938
- const choicesPositionClasses = classNames.filter((className) =>
1939
- SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
1940
- className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
1941
- ),
1942
- );
1943
- if (new Set(choicesPositionClasses).size > 1) {
1944
- diagnostics.push({
1945
- code: "interaction.sharedVocabulary.orderChoicesPositionConflict",
1946
- severity: "warning",
1947
- message: `${interaction.qtiName} should not include multiple qti-choices-* position classes: ${[...new Set(choicesPositionClasses)].join(", ")}. The first position class in class attribute order takes precedence at runtime.`,
1948
- path: interaction.source?.path,
1949
- source: interaction.source,
1950
- });
1951
- }
1952
- }
1953
-
1954
- function validateChoicesContainerWidthSharedVocabulary(
1955
- interaction: QtiInteraction,
1956
- diagnostics: QtiDiagnostic[],
1957
- ): void {
1958
- const width = interaction.attributes["data-choices-container-width"];
1959
- if (width === undefined) return;
1960
- const parsed = Number(width);
1961
- if (!Number.isFinite(parsed) || parsed <= 0) {
1962
- diagnostics.push({
1963
- code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
1964
- severity: "warning",
1965
- message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
1966
- path: interaction.source?.path,
1967
- source: interaction.source,
1968
- });
1969
- }
1970
- }
1971
-
1972
- function validateGapInputWidthSharedVocabulary(
1973
- interaction: QtiInteraction,
1974
- diagnostics: QtiDiagnostic[],
1975
- ): void {
1976
- for (const gap of interaction.choices.filter((choice) => choice.qtiName === "qti-gap")) {
1977
- diagnostics.push(
1978
- ...validateSharedVocabularyInputWidth({
1979
- classNames: sharedClassNames(gap.attributes),
1980
- subjectQtiName: "qti-gap",
1981
- path: gap.source?.path ?? interaction.source?.path,
1982
- source: gap.source ?? interaction.source,
1983
- conflictCode: "interaction.sharedVocabulary.gapInputWidthConflict",
1984
- invalidCode: "interaction.sharedVocabulary.gapInputWidthInvalid",
1985
- }),
1986
- );
1987
- }
1988
- }
1989
-
1990
- function validateInteractionInputWidthSharedVocabulary(
1991
- interaction: QtiInteraction,
1992
- classNames: string[],
1993
- diagnostics: QtiDiagnostic[],
1994
- ): void {
1995
- diagnostics.push(
1996
- ...validateSharedVocabularyInputWidth({
1997
- classNames,
1998
- subjectQtiName: interaction.qtiName,
1999
- path: interaction.source?.path,
2000
- source: interaction.source,
2001
- conflictCode: "interaction.sharedVocabulary.inputWidthConflict",
2002
- invalidCode: "interaction.sharedVocabulary.inputWidthInvalid",
2003
- }),
2004
- );
2005
- }
2006
-
2007
- function validateMediaInteractionSharedVocabulary(
2008
- interaction: QtiInteraction,
2009
- diagnostics: QtiDiagnostic[],
2010
- ): void {
2011
- diagnostics.push(
2012
- ...validateSharedVocabularyMediaPlayerControls({
2013
- value: interaction.attributes["data-qti-media-player-controls"],
2014
- subjectQtiName: interaction.qtiName,
2015
- path: interaction.source?.path,
2016
- source: interaction.source,
2017
- }),
2018
- );
2019
-
2020
- if (!interaction.object) return;
2021
- diagnostics.push(
2022
- ...validateSharedVocabularyMediaPlayerControls({
2023
- value: interaction.object.attributes["data-qti-media-player-controls"],
2024
- subjectQtiName: `${interaction.qtiName} media object`,
2025
- path: interaction.object.source?.path ?? interaction.source?.path,
2026
- source: interaction.object.source ?? interaction.source,
2027
- }),
2028
- );
2029
- }
2030
-
2031
1723
  function sharedClassNames(attributes: Record<string, string>): string[] {
2032
1724
  return (attributes.class ?? "").split(/\s+/).filter(Boolean);
2033
1725
  }