@longsightgroup/qti3-core 0.6.0 → 0.7.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 (50) hide show
  1. package/dist/index.d.ts +6 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/parser.js +13 -1
  6. package/dist/parser.js.map +1 -1
  7. package/dist/shared-vocabulary-coverage-policy.d.ts +10 -0
  8. package/dist/shared-vocabulary-coverage-policy.d.ts.map +1 -0
  9. package/dist/shared-vocabulary-coverage-policy.js +61 -0
  10. package/dist/shared-vocabulary-coverage-policy.js.map +1 -0
  11. package/dist/shared-vocabulary-generated-families.d.ts +21 -0
  12. package/dist/shared-vocabulary-generated-families.d.ts.map +1 -0
  13. package/dist/shared-vocabulary-generated-families.js +194 -0
  14. package/dist/shared-vocabulary-generated-families.js.map +1 -0
  15. package/dist/shared-vocabulary-levels.d.ts +4 -0
  16. package/dist/shared-vocabulary-levels.d.ts.map +1 -0
  17. package/dist/shared-vocabulary-levels.js +4 -0
  18. package/dist/shared-vocabulary-levels.js.map +1 -0
  19. package/dist/shared-vocabulary-support.d.ts +3 -0
  20. package/dist/shared-vocabulary-support.d.ts.map +1 -0
  21. package/dist/shared-vocabulary-support.js +208 -0
  22. package/dist/shared-vocabulary-support.js.map +1 -0
  23. package/dist/shared-vocabulary-validation.d.ts +40 -0
  24. package/dist/shared-vocabulary-validation.d.ts.map +1 -0
  25. package/dist/shared-vocabulary-validation.js +108 -0
  26. package/dist/shared-vocabulary-validation.js.map +1 -0
  27. package/dist/shared-vocabulary.d.ts +35 -0
  28. package/dist/shared-vocabulary.d.ts.map +1 -0
  29. package/dist/shared-vocabulary.js +128 -0
  30. package/dist/shared-vocabulary.js.map +1 -0
  31. package/dist/types.d.ts +10 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/validation.d.ts.map +1 -1
  34. package/dist/validation.js +364 -0
  35. package/dist/validation.js.map +1 -1
  36. package/dist/xml.d.ts.map +1 -1
  37. package/dist/xml.js +100 -0
  38. package/dist/xml.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/index.ts +56 -0
  41. package/src/parser.ts +17 -1
  42. package/src/shared-vocabulary-coverage-policy.ts +79 -0
  43. package/src/shared-vocabulary-generated-families.ts +252 -0
  44. package/src/shared-vocabulary-levels.ts +7 -0
  45. package/src/shared-vocabulary-support.ts +340 -0
  46. package/src/shared-vocabulary-validation.ts +182 -0
  47. package/src/shared-vocabulary.ts +177 -0
  48. package/src/types.ts +11 -0
  49. package/src/validation.ts +466 -0
  50. package/src/xml.ts +107 -0
@@ -1,3 +1,4 @@
1
+ import { validateSharedVocabularyExtendedText, validateSharedVocabularyInputWidth, validateSharedVocabularyMediaPlayerControls, } from "./shared-vocabulary-validation.js";
1
2
  import { validateQtiDataSsmlMetadata } from "./tts.js";
2
3
  import { qtiValueToStringList } from "./value-format.js";
3
4
  const BUILT_IN_COMPLETION_STATUS = "completionStatus";
@@ -7,6 +8,7 @@ export function validateAssessmentItem(document) {
7
8
  requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
8
9
  validateAssessmentItemRoot(item, diagnostics);
9
10
  validateItemBody(item, diagnostics);
11
+ validateItemBodySharedVocabulary(item, diagnostics);
10
12
  validateDeclarationIdentifiers(item, diagnostics);
11
13
  validateOutcomeLookupTables(item, diagnostics);
12
14
  validateInteractions(item, diagnostics);
@@ -152,6 +154,64 @@ function validateItemBody(item, diagnostics) {
152
154
  source: item.source,
153
155
  });
154
156
  }
157
+ function validateItemBodySharedVocabulary(item, diagnostics) {
158
+ validateContentSharedVocabulary(item.body, diagnostics);
159
+ }
160
+ function validateContentSharedVocabulary(nodes, diagnostics) {
161
+ for (const node of nodes) {
162
+ if (node.kind !== "element")
163
+ continue;
164
+ validateLayoutVocabularyClasses(node, diagnostics);
165
+ if (sharedClassNames(node.attributes).includes("qti-layout-row")) {
166
+ validateLayoutRow(node, diagnostics);
167
+ }
168
+ validateContentSharedVocabulary(node.children, diagnostics);
169
+ }
170
+ }
171
+ function validateLayoutVocabularyClasses(node, diagnostics) {
172
+ const classNames = sharedClassNames(node.attributes);
173
+ for (const className of classNames) {
174
+ if (isLayoutColumnClassName(className) && layoutColumnValue(className) === undefined) {
175
+ diagnostics.push({
176
+ code: "item.sharedVocabulary.layoutColumnInvalid",
177
+ severity: "warning",
178
+ message: `Shared vocabulary class ${className} is not supported; expected qti-layout-col1 through qti-layout-col12, or dashed qti-layout-col-1 through qti-layout-col-12.`,
179
+ path: node.source?.path,
180
+ source: node.source,
181
+ });
182
+ }
183
+ if (isLayoutOffsetClassName(className) && layoutOffsetValue(className) === undefined) {
184
+ diagnostics.push({
185
+ code: "item.sharedVocabulary.layoutOffsetInvalid",
186
+ severity: "warning",
187
+ message: `Shared vocabulary class ${className} is not supported; expected qti-layout-offset1 through qti-layout-offset11, or dashed qti-layout-offset-1 through qti-layout-offset-11.`,
188
+ path: node.source?.path,
189
+ source: node.source,
190
+ });
191
+ }
192
+ }
193
+ }
194
+ function validateLayoutRow(node, diagnostics) {
195
+ let totalColumns = 0;
196
+ for (const child of node.children) {
197
+ if (child.kind !== "element")
198
+ continue;
199
+ const classNames = sharedClassNames(child.attributes);
200
+ const column = firstLayoutColumnValue(classNames);
201
+ if (column === undefined)
202
+ continue;
203
+ totalColumns += (firstLayoutOffsetValue(classNames) ?? 0) + column;
204
+ }
205
+ if (totalColumns <= 12)
206
+ return;
207
+ diagnostics.push({
208
+ code: "item.sharedVocabulary.layoutRowOverflow",
209
+ severity: "warning",
210
+ message: `qti-layout-row column groupings plus offsets total ${totalColumns}; the QTI shared vocabulary grid allows at most twelve columns per row.`,
211
+ path: node.source?.path,
212
+ source: node.source,
213
+ });
214
+ }
155
215
  function requireIdentifier(elementName, identifier, diagnostics, source) {
156
216
  if (!identifier || identifier.trim().length === 0) {
157
217
  diagnostics.push({
@@ -1275,6 +1335,7 @@ function validateInteractions(item, diagnostics) {
1275
1335
  for (const interaction of item.interactions) {
1276
1336
  validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics);
1277
1337
  validateInteractionResponseShape(interaction, diagnostics);
1338
+ validateInteractionSharedVocabulary(interaction, diagnostics);
1278
1339
  validateInteractionChoices(interaction, diagnostics);
1279
1340
  validateInteractionChildren(interaction, diagnostics);
1280
1341
  validateInteractionRequiredAttributes(interaction, diagnostics);
@@ -1289,6 +1350,297 @@ function validateInteractions(item, diagnostics) {
1289
1350
  : undefined, diagnostics);
1290
1351
  }
1291
1352
  }
1353
+ function validateInteractionSharedVocabulary(interaction, diagnostics) {
1354
+ if (interaction.type !== "choice" &&
1355
+ interaction.type !== "match" &&
1356
+ interaction.type !== "gapMatch" &&
1357
+ interaction.type !== "graphicGapMatch" &&
1358
+ interaction.type !== "inlineChoice" &&
1359
+ interaction.type !== "textEntry" &&
1360
+ interaction.type !== "extendedText" &&
1361
+ interaction.type !== "media" &&
1362
+ interaction.type !== "order") {
1363
+ return;
1364
+ }
1365
+ const classNames = sharedClassNames(interaction.attributes);
1366
+ const classNameSet = new Set(classNames);
1367
+ if (interaction.type === "media") {
1368
+ validateMediaInteractionSharedVocabulary(interaction, diagnostics);
1369
+ return;
1370
+ }
1371
+ if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
1372
+ validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics);
1373
+ return;
1374
+ }
1375
+ if (interaction.type === "extendedText") {
1376
+ diagnostics.push(...validateSharedVocabularyExtendedText({
1377
+ classNames,
1378
+ subjectQtiName: interaction.qtiName,
1379
+ path: interaction.source?.path,
1380
+ source: interaction.source,
1381
+ }));
1382
+ return;
1383
+ }
1384
+ if (interaction.type === "choice" || interaction.type === "order") {
1385
+ validateSharedVocabularyLabelClasses(interaction, classNames, diagnostics);
1386
+ }
1387
+ if (interaction.type === "match" ||
1388
+ interaction.type === "gapMatch" ||
1389
+ interaction.type === "graphicGapMatch") {
1390
+ if (interaction.type === "match") {
1391
+ validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics);
1392
+ }
1393
+ validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1394
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1395
+ if (interaction.type === "gapMatch") {
1396
+ validateGapInputWidthSharedVocabulary(interaction, diagnostics);
1397
+ }
1398
+ return;
1399
+ }
1400
+ if (interaction.type === "order") {
1401
+ validateOrderInteractionSharedVocabulary(interaction, classNames, classNameSet, diagnostics);
1402
+ return;
1403
+ }
1404
+ validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1405
+ const validStackingClasses = new Set();
1406
+ const invalidStackingClasses = new Set();
1407
+ for (const className of classNames) {
1408
+ const stacking = /^qti-choices-stacking-(\d+)$/.exec(className)?.[1];
1409
+ if (stacking === undefined)
1410
+ continue;
1411
+ const count = Number(stacking);
1412
+ if (count >= 1 && count <= 5)
1413
+ validStackingClasses.add(className);
1414
+ else
1415
+ invalidStackingClasses.add(className);
1416
+ }
1417
+ if (validStackingClasses.size > 1) {
1418
+ diagnostics.push({
1419
+ code: "interaction.sharedVocabulary.stackingConflict",
1420
+ severity: "warning",
1421
+ 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.`,
1422
+ path: interaction.source?.path,
1423
+ source: interaction.source,
1424
+ });
1425
+ }
1426
+ for (const className of invalidStackingClasses) {
1427
+ diagnostics.push({
1428
+ code: "interaction.sharedVocabulary.stackingInvalid",
1429
+ severity: "warning",
1430
+ message: `qti-choice-interaction shared vocabulary class ${className} is not supported; expected qti-choices-stacking-1 through qti-choices-stacking-5.`,
1431
+ path: interaction.source?.path,
1432
+ source: interaction.source,
1433
+ });
1434
+ }
1435
+ }
1436
+ function validateSharedVocabularyLabelClasses(interaction, classNames, diagnostics) {
1437
+ const labelClasses = classNames.filter((className) => [
1438
+ "qti-labels-decimal",
1439
+ "qti-labels-cjk-ideographic",
1440
+ "qti-labels-lower-alpha",
1441
+ "qti-labels-upper-alpha",
1442
+ ].includes(className));
1443
+ if (new Set(labelClasses).size > 1) {
1444
+ diagnostics.push({
1445
+ code: "interaction.sharedVocabulary.labelsConflict",
1446
+ severity: "warning",
1447
+ 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.`,
1448
+ path: interaction.source?.path,
1449
+ source: interaction.source,
1450
+ });
1451
+ }
1452
+ const suffixClasses = classNames.filter((className) => [
1453
+ "qti-labels-suffix-none",
1454
+ "qti-labels-suffix-period",
1455
+ "qti-labels-suffix-parenthesis",
1456
+ ].includes(className));
1457
+ if (new Set(suffixClasses).size <= 1)
1458
+ return;
1459
+ diagnostics.push({
1460
+ code: "interaction.sharedVocabulary.labelSuffixConflict",
1461
+ severity: "warning",
1462
+ 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.`,
1463
+ path: interaction.source?.path,
1464
+ source: interaction.source,
1465
+ });
1466
+ }
1467
+ function validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics) {
1468
+ if (!classNameSet.has("qti-orientation-horizontal") ||
1469
+ !classNameSet.has("qti-orientation-vertical")) {
1470
+ return;
1471
+ }
1472
+ diagnostics.push({
1473
+ code: "interaction.sharedVocabulary.orientationConflict",
1474
+ severity: "warning",
1475
+ message: `${interaction.qtiName} should not include both qti-orientation-horizontal and qti-orientation-vertical; qti-orientation-horizontal takes precedence at runtime.`,
1476
+ path: interaction.source?.path,
1477
+ source: interaction.source,
1478
+ });
1479
+ }
1480
+ function validateOrderInteractionSharedVocabulary(interaction, classNames, classNameSet, diagnostics) {
1481
+ validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1482
+ validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1483
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1484
+ }
1485
+ const SHARED_VOCABULARY_CHOICES_POSITION_CLASSES = [
1486
+ "qti-choices-top",
1487
+ "qti-choices-bottom",
1488
+ "qti-choices-left",
1489
+ "qti-choices-right",
1490
+ ];
1491
+ function validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics) {
1492
+ const hasTabular = classNames.includes("qti-match-tabular");
1493
+ const hasHeaderHidden = classNames.includes("qti-header-hidden");
1494
+ const firstColumnHeader = interaction.attributes["data-first-column-header"];
1495
+ if (!hasTabular && (hasHeaderHidden || firstColumnHeader !== undefined)) {
1496
+ diagnostics.push({
1497
+ code: "interaction.sharedVocabulary.matchTabularContext",
1498
+ severity: "warning",
1499
+ message: "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.",
1500
+ path: interaction.source?.path,
1501
+ source: interaction.source,
1502
+ });
1503
+ }
1504
+ if (hasTabular && hasHeaderHidden && firstColumnHeader !== undefined) {
1505
+ diagnostics.push({
1506
+ code: "interaction.sharedVocabulary.matchTabularHeaderHidden",
1507
+ severity: "warning",
1508
+ message: "qti-match-interaction data-first-column-header is ignored when qti-header-hidden suppresses the tabular header row.",
1509
+ path: interaction.source?.path,
1510
+ source: interaction.source,
1511
+ });
1512
+ }
1513
+ if (!hasTabular)
1514
+ return;
1515
+ const choicesPositionClasses = classNames.filter((className) => SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(className));
1516
+ if (choicesPositionClasses.length > 0 ||
1517
+ interaction.attributes["data-choices-container-width"] !== undefined) {
1518
+ diagnostics.push({
1519
+ code: "interaction.sharedVocabulary.matchTabularChoicesConflict",
1520
+ severity: "warning",
1521
+ message: "qti-match-interaction qti-match-tabular uses a matrix layout; qti-choices-* position classes and data-choices-container-width are ignored at runtime.",
1522
+ path: interaction.source?.path,
1523
+ source: interaction.source,
1524
+ });
1525
+ }
1526
+ if (!hasHeaderHidden && (firstColumnHeader === undefined || firstColumnHeader === "")) {
1527
+ diagnostics.push({
1528
+ code: "interaction.sharedVocabulary.matchTabularFirstColumnHeader",
1529
+ severity: "warning",
1530
+ message: "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.",
1531
+ path: interaction.source?.path,
1532
+ source: interaction.source,
1533
+ });
1534
+ }
1535
+ }
1536
+ function validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics) {
1537
+ const choicesPositionClasses = classNames.filter((className) => SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(className));
1538
+ if (new Set(choicesPositionClasses).size > 1) {
1539
+ diagnostics.push({
1540
+ code: "interaction.sharedVocabulary.orderChoicesPositionConflict",
1541
+ severity: "warning",
1542
+ 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.`,
1543
+ path: interaction.source?.path,
1544
+ source: interaction.source,
1545
+ });
1546
+ }
1547
+ }
1548
+ function validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics) {
1549
+ const width = interaction.attributes["data-choices-container-width"];
1550
+ if (width === undefined)
1551
+ return;
1552
+ const parsed = Number(width);
1553
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1554
+ diagnostics.push({
1555
+ code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
1556
+ severity: "warning",
1557
+ message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
1558
+ path: interaction.source?.path,
1559
+ source: interaction.source,
1560
+ });
1561
+ }
1562
+ }
1563
+ function validateGapInputWidthSharedVocabulary(interaction, diagnostics) {
1564
+ for (const gap of interaction.choices.filter((choice) => choice.qtiName === "qti-gap")) {
1565
+ diagnostics.push(...validateSharedVocabularyInputWidth({
1566
+ classNames: sharedClassNames(gap.attributes),
1567
+ subjectQtiName: "qti-gap",
1568
+ path: gap.source?.path ?? interaction.source?.path,
1569
+ source: gap.source ?? interaction.source,
1570
+ conflictCode: "interaction.sharedVocabulary.gapInputWidthConflict",
1571
+ invalidCode: "interaction.sharedVocabulary.gapInputWidthInvalid",
1572
+ }));
1573
+ }
1574
+ }
1575
+ function validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics) {
1576
+ diagnostics.push(...validateSharedVocabularyInputWidth({
1577
+ classNames,
1578
+ subjectQtiName: interaction.qtiName,
1579
+ path: interaction.source?.path,
1580
+ source: interaction.source,
1581
+ conflictCode: "interaction.sharedVocabulary.inputWidthConflict",
1582
+ invalidCode: "interaction.sharedVocabulary.inputWidthInvalid",
1583
+ }));
1584
+ }
1585
+ function validateMediaInteractionSharedVocabulary(interaction, diagnostics) {
1586
+ diagnostics.push(...validateSharedVocabularyMediaPlayerControls({
1587
+ value: interaction.attributes["data-qti-media-player-controls"],
1588
+ subjectQtiName: interaction.qtiName,
1589
+ path: interaction.source?.path,
1590
+ source: interaction.source,
1591
+ }));
1592
+ if (!interaction.object)
1593
+ return;
1594
+ diagnostics.push(...validateSharedVocabularyMediaPlayerControls({
1595
+ value: interaction.object.attributes["data-qti-media-player-controls"],
1596
+ subjectQtiName: `${interaction.qtiName} media object`,
1597
+ path: interaction.object.source?.path ?? interaction.source?.path,
1598
+ source: interaction.object.source ?? interaction.source,
1599
+ }));
1600
+ }
1601
+ function sharedClassNames(attributes) {
1602
+ return (attributes.class ?? "").split(/\s+/).filter(Boolean);
1603
+ }
1604
+ function isLayoutColumnClassName(className) {
1605
+ return /^qti-layout-col-?\w+$/.test(className);
1606
+ }
1607
+ function layoutColumnValue(className) {
1608
+ const rawValue = /^qti-layout-col-?(\d+)$/.exec(className)?.[1];
1609
+ if (rawValue === undefined)
1610
+ return undefined;
1611
+ const value = Number(rawValue);
1612
+ if (value < 1 || value > 12)
1613
+ return undefined;
1614
+ return value;
1615
+ }
1616
+ function firstLayoutColumnValue(classNames) {
1617
+ for (const className of classNames) {
1618
+ const value = layoutColumnValue(className);
1619
+ if (value !== undefined)
1620
+ return value;
1621
+ }
1622
+ return undefined;
1623
+ }
1624
+ function isLayoutOffsetClassName(className) {
1625
+ return /^qti-layout-offset-?\w+$/.test(className);
1626
+ }
1627
+ function layoutOffsetValue(className) {
1628
+ const rawValue = /^qti-layout-offset-?(\d+)$/.exec(className)?.[1];
1629
+ if (rawValue === undefined)
1630
+ return undefined;
1631
+ const value = Number(rawValue);
1632
+ if (value < 1 || value > 11)
1633
+ return undefined;
1634
+ return value;
1635
+ }
1636
+ function firstLayoutOffsetValue(classNames) {
1637
+ for (const className of classNames) {
1638
+ const value = layoutOffsetValue(className);
1639
+ if (value !== undefined)
1640
+ return value;
1641
+ }
1642
+ return undefined;
1643
+ }
1292
1644
  function validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics) {
1293
1645
  if (!interaction.responseIdentifier) {
1294
1646
  if (interaction.type !== "endAttempt") {
@@ -1662,8 +2014,20 @@ function validateChoiceLimitAttributes(choice, diagnostics) {
1662
2014
  validateChoiceNonNegativeIntegerAttribute(choice, "match-max", diagnostics);
1663
2015
  validateChoiceNonNegativeIntegerAttribute(choice, "match-min", diagnostics);
1664
2016
  validateChoiceMinMaxPair(choice, "match-min", "match-max", diagnostics);
2017
+ validateGapImageAsset(choice, diagnostics);
1665
2018
  validateHotspotGeometry(choice, diagnostics);
1666
2019
  }
2020
+ function validateGapImageAsset(choice, diagnostics) {
2021
+ if (choice.qtiName !== "qti-gap-img" || choice.asset?.data)
2022
+ return;
2023
+ diagnostics.push({
2024
+ code: "choice.gapImg.media.required",
2025
+ severity: "error",
2026
+ message: `qti-gap-img ${choice.identifier} requires an img, object, or picture child with a usable src or data attribute.`,
2027
+ path: choice.source?.path,
2028
+ source: choice.source,
2029
+ });
2030
+ }
1667
2031
  function requiresMatchMax(choice) {
1668
2032
  return (choice.qtiName === "qti-simple-associable-choice" ||
1669
2033
  choice.qtiName === "qti-associable-hotspot" ||