@longsightgroup/qti3-core 0.1.1 → 0.2.0

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.
@@ -1,3 +1,4 @@
1
+ import { validateQtiDataSsmlMetadata } from "./tts.js";
1
2
  const BUILT_IN_COMPLETION_STATUS = "completionStatus";
2
3
  export function validateAssessmentItem(document) {
3
4
  const diagnostics = [];
@@ -9,6 +10,7 @@ export function validateAssessmentItem(document) {
9
10
  validateModalFeedback(item, diagnostics);
10
11
  validateCatalogInfo(item, diagnostics);
11
12
  validateStylesheets(item, diagnostics);
13
+ diagnostics.push(...validateQtiDataSsmlMetadata(item));
12
14
  validateProcessingReferences(item, diagnostics);
13
15
  return {
14
16
  ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
@@ -1141,7 +1143,9 @@ function validateInteractions(item, diagnostics) {
1141
1143
  validateInteractionChoices(interaction, diagnostics);
1142
1144
  validateInteractionChildren(interaction, diagnostics);
1143
1145
  validateInteractionRequiredAttributes(interaction, diagnostics);
1146
+ validatePortableCustomInteraction(interaction, item, diagnostics);
1144
1147
  validateInteractionLimitAttributes(interaction, diagnostics);
1148
+ validateGraphicHotspotObjectDimensions(interaction, diagnostics);
1145
1149
  validateCorrectResponseReferences(interaction, interaction.responseIdentifier
1146
1150
  ? responseDeclarations.get(interaction.responseIdentifier)
1147
1151
  : undefined, diagnostics);
@@ -1152,7 +1156,7 @@ function validateInteractions(item, diagnostics) {
1152
1156
  }
1153
1157
  function validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics) {
1154
1158
  if (!interaction.responseIdentifier) {
1155
- if (interaction.type !== "endAttempt" && interaction.type !== "media") {
1159
+ if (interaction.type !== "endAttempt") {
1156
1160
  diagnostics.push({
1157
1161
  code: "interaction.responseIdentifier",
1158
1162
  severity: "error",
@@ -1323,18 +1327,19 @@ function validateInteractionChildren(interaction, diagnostics) {
1323
1327
  }
1324
1328
  }
1325
1329
  function validateInteractionRequiredAttributes(interaction, diagnostics) {
1326
- if (requiresObject(interaction) && !interaction.object?.data) {
1330
+ if (requiresObject(interaction) && !hasRequiredObjectAsset(interaction)) {
1327
1331
  diagnostics.push({
1328
1332
  code: "interaction.object.required",
1329
1333
  severity: "error",
1330
- message: `${interaction.qtiName} requires an object child with a data attribute.`,
1334
+ message: interaction.type === "drawing"
1335
+ ? `${interaction.qtiName} requires an object, img, or picture canvas with a data/src attribute.`
1336
+ : `${interaction.qtiName} requires an object, img, audio, or video child with a data/src attribute or media sources.`,
1331
1337
  path: interaction.source?.path,
1332
1338
  source: interaction.source,
1333
1339
  });
1334
1340
  }
1335
1341
  if (interaction.type === "portableCustom") {
1336
1342
  requireInteractionAttribute(interaction, "custom-interaction-type-identifier", "interaction.portableCustom.typeIdentifier", diagnostics);
1337
- requireInteractionAttribute(interaction, "module", "interaction.portableCustom.module", diagnostics);
1338
1343
  }
1339
1344
  if (interaction.type === "slider") {
1340
1345
  const lower = interaction.attributes["lower-bound"];
@@ -1374,7 +1379,16 @@ function requiresObject(interaction) {
1374
1379
  interaction.type === "hotspot" ||
1375
1380
  interaction.type === "selectPoint" ||
1376
1381
  interaction.type === "positionObject" ||
1377
- interaction.type === "media");
1382
+ interaction.type === "media" ||
1383
+ interaction.type === "drawing");
1384
+ }
1385
+ function hasRequiredObjectAsset(interaction) {
1386
+ if (interaction.type === "media") {
1387
+ return Boolean(interaction.object?.data || interaction.object?.sources.some((source) => Boolean(source.src)));
1388
+ }
1389
+ if (interaction.type === "drawing")
1390
+ return Boolean(interaction.object?.data);
1391
+ return Boolean(interaction.object?.data);
1378
1392
  }
1379
1393
  function requireInteractionAttribute(interaction, attribute, code, diagnostics) {
1380
1394
  if (interaction.attributes[attribute])
@@ -1387,6 +1401,95 @@ function requireInteractionAttribute(interaction, attribute, code, diagnostics)
1387
1401
  source: interaction.source,
1388
1402
  });
1389
1403
  }
1404
+ function validatePortableCustomInteraction(interaction, item, diagnostics) {
1405
+ if (interaction.type !== "portableCustom")
1406
+ return;
1407
+ const definition = interaction.portableCustom;
1408
+ if (!definition)
1409
+ return;
1410
+ const configuredModules = definition.interactionModules?.modules ?? [];
1411
+ const hasModuleAttribute = Boolean(definition.module?.trim());
1412
+ const hasConfiguredModule = configuredModules.some((module) => Boolean(module.id?.trim()));
1413
+ if (!hasModuleAttribute && !hasConfiguredModule) {
1414
+ diagnostics.push({
1415
+ code: "interaction.portableCustom.module",
1416
+ severity: "error",
1417
+ message: `${interaction.qtiName} requires a module attribute or at least one qti-interaction-module id.`,
1418
+ path: interaction.source?.path,
1419
+ source: interaction.source,
1420
+ });
1421
+ }
1422
+ for (const module of configuredModules) {
1423
+ if (!module.id?.trim()) {
1424
+ diagnostics.push({
1425
+ code: "interaction.portableCustom.moduleId",
1426
+ severity: "error",
1427
+ message: "qti-interaction-module requires a non-empty id.",
1428
+ path: module.source?.path,
1429
+ source: module.source,
1430
+ });
1431
+ }
1432
+ warnExternalPortableCustomUrl(module.primaryPath, module.source, diagnostics);
1433
+ warnExternalPortableCustomUrl(module.fallbackPath, module.source, diagnostics);
1434
+ }
1435
+ warnExternalPortableCustomUrl(definition.interactionModules?.primaryConfiguration, definition.interactionModules?.source, diagnostics);
1436
+ warnExternalPortableCustomUrl(definition.interactionModules?.secondaryConfiguration, definition.interactionModules?.source, diagnostics);
1437
+ const templateIdentifiers = new Set(item.templateDeclarations.map((declaration) => declaration.identifier));
1438
+ for (const variable of definition.templateVariables) {
1439
+ if (!variable.identifier?.trim()) {
1440
+ diagnostics.push({
1441
+ code: "interaction.portableCustom.templateVariable",
1442
+ severity: "error",
1443
+ message: "qti-template-variable requires template-identifier or identifier.",
1444
+ path: variable.source?.path,
1445
+ source: variable.source,
1446
+ });
1447
+ continue;
1448
+ }
1449
+ if (!templateIdentifiers.has(variable.identifier)) {
1450
+ diagnostics.push({
1451
+ code: "interaction.portableCustom.templateVariable.reference",
1452
+ severity: "error",
1453
+ message: `qti-template-variable references missing template declaration ${variable.identifier}.`,
1454
+ path: variable.source?.path,
1455
+ source: variable.source,
1456
+ });
1457
+ }
1458
+ }
1459
+ for (const variable of definition.contextVariables) {
1460
+ if (variable.identifier?.trim())
1461
+ continue;
1462
+ diagnostics.push({
1463
+ code: "interaction.portableCustom.contextVariable",
1464
+ severity: "error",
1465
+ message: "qti-context-variable requires identifier.",
1466
+ path: variable.source?.path,
1467
+ source: variable.source,
1468
+ });
1469
+ }
1470
+ for (const stylesheet of definition.stylesheets) {
1471
+ if (stylesheet.href.trim().length > 0)
1472
+ continue;
1473
+ diagnostics.push({
1474
+ code: "stylesheet.href.required",
1475
+ severity: "error",
1476
+ message: "qti-stylesheet requires a non-empty href attribute.",
1477
+ path: stylesheet.source?.path,
1478
+ source: stylesheet.source,
1479
+ });
1480
+ }
1481
+ }
1482
+ function warnExternalPortableCustomUrl(url, source, diagnostics) {
1483
+ if (!url || !/^https?:\/\//i.test(url))
1484
+ return;
1485
+ diagnostics.push({
1486
+ code: "interaction.portableCustom.externalModuleUrl",
1487
+ severity: "warning",
1488
+ message: `Portable custom interaction module URL ${url} requires host delivery policy approval.`,
1489
+ path: source?.path,
1490
+ source,
1491
+ });
1492
+ }
1390
1493
  function invalidNumber(interaction, attribute, value, diagnostics) {
1391
1494
  diagnostics.push({
1392
1495
  code: "interaction.numericAttribute",
@@ -1405,6 +1508,13 @@ function validateInteractionLimitAttributes(interaction, diagnostics) {
1405
1508
  validateNonNegativeIntegerAttribute(interaction, "expected-lines", diagnostics);
1406
1509
  validateMinMaxPair(interaction, "min-choices", "max-choices", diagnostics);
1407
1510
  validateMinMaxPair(interaction, "min-associations", "max-associations", diagnostics);
1511
+ if (interaction.type === "media") {
1512
+ validateNonNegativeIntegerAttribute(interaction, "max-plays", diagnostics);
1513
+ validateNonNegativeIntegerAttribute(interaction, "min-plays", diagnostics);
1514
+ validateBooleanAttribute(interaction, "autostart", diagnostics);
1515
+ validateBooleanAttribute(interaction, "loop", diagnostics);
1516
+ validateMinMaxPair(interaction, "min-plays", "max-plays", diagnostics);
1517
+ }
1408
1518
  }
1409
1519
  function validateChoiceLimitAttributes(choice, diagnostics) {
1410
1520
  if (requiresMatchMax(choice) && !choice.attributes["match-max"]) {
@@ -1482,6 +1592,89 @@ function validateHotspotGeometry(choice, diagnostics) {
1482
1592
  });
1483
1593
  }
1484
1594
  }
1595
+ function validateGraphicHotspotObjectDimensions(interaction, diagnostics) {
1596
+ if (!usesGraphicHotspots(interaction))
1597
+ return;
1598
+ const hotspotChoices = interaction.choices.filter(isHotspotChoice);
1599
+ if (hotspotChoices.length === 0)
1600
+ return;
1601
+ const width = positiveDimension(interaction.object?.width);
1602
+ const height = positiveDimension(interaction.object?.height);
1603
+ if (width === undefined || height === undefined) {
1604
+ diagnostics.push({
1605
+ code: "interaction.graphicObjectDimensions",
1606
+ severity: "warning",
1607
+ message: `${interaction.qtiName} should declare object width and height so hotspot coords map to the rendered image.`,
1608
+ path: interaction.object?.source?.path ?? interaction.source?.path,
1609
+ source: interaction.object?.source ?? interaction.source,
1610
+ });
1611
+ return;
1612
+ }
1613
+ for (const choice of hotspotChoices) {
1614
+ const bounds = hotspotBounds(choice);
1615
+ if (!bounds)
1616
+ continue;
1617
+ if (bounds.left >= 0 && bounds.top >= 0 && bounds.right <= width && bounds.bottom <= height) {
1618
+ continue;
1619
+ }
1620
+ diagnostics.push({
1621
+ code: "choice.coords.bounds",
1622
+ severity: "warning",
1623
+ message: `${choice.qtiName} ${choice.identifier} coords extend outside the ${width} by ${height} object image.`,
1624
+ path: choice.source?.path,
1625
+ source: choice.source,
1626
+ });
1627
+ }
1628
+ }
1629
+ function usesGraphicHotspots(interaction) {
1630
+ return (interaction.type === "graphicOrder" ||
1631
+ interaction.type === "graphicAssociate" ||
1632
+ interaction.type === "graphicGapMatch" ||
1633
+ interaction.type === "hotspot");
1634
+ }
1635
+ function isHotspotChoice(choice) {
1636
+ return choice.qtiName === "qti-hotspot-choice" || choice.qtiName === "qti-associable-hotspot";
1637
+ }
1638
+ function positiveDimension(value) {
1639
+ if (!value || value.trim().endsWith("%"))
1640
+ return undefined;
1641
+ const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
1642
+ if (!match?.[1])
1643
+ return undefined;
1644
+ const parsed = Number(match[1]);
1645
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
1646
+ }
1647
+ function hotspotBounds(choice) {
1648
+ const shape = choice.attributes.shape;
1649
+ const coords = choice.attributes.coords;
1650
+ if (!shape || !coords || !isHotspotShape(shape) || !isNumericCsv(coords))
1651
+ return undefined;
1652
+ const values = numericCsv(coords);
1653
+ if (!hasValidShapeCoordinateCount(shape, values))
1654
+ return undefined;
1655
+ if (shape === "default")
1656
+ return undefined;
1657
+ if (shape === "circle") {
1658
+ const [x, y, radius] = values;
1659
+ return { left: x - radius, top: y - radius, right: x + radius, bottom: y + radius };
1660
+ }
1661
+ if (shape === "ellipse") {
1662
+ const [x, y, radiusX, radiusY] = values;
1663
+ return { left: x - radiusX, top: y - radiusY, right: x + radiusX, bottom: y + radiusY };
1664
+ }
1665
+ if (shape === "rect") {
1666
+ const [left, top, right, bottom] = values;
1667
+ return { left, top, right, bottom };
1668
+ }
1669
+ const xs = values.filter((_, index) => index % 2 === 0);
1670
+ const ys = values.filter((_, index) => index % 2 === 1);
1671
+ return {
1672
+ left: Math.min(...xs),
1673
+ top: Math.min(...ys),
1674
+ right: Math.max(...xs),
1675
+ bottom: Math.max(...ys),
1676
+ };
1677
+ }
1485
1678
  function isHotspotShape(value) {
1486
1679
  return (value === "circle" ||
1487
1680
  value === "default" ||
@@ -1528,6 +1721,18 @@ function validateNonNegativeIntegerAttribute(interaction, attribute, diagnostics
1528
1721
  source: interaction.source,
1529
1722
  });
1530
1723
  }
1724
+ function validateBooleanAttribute(interaction, attribute, diagnostics) {
1725
+ const value = interaction.attributes[attribute];
1726
+ if (value === undefined || isBooleanAttribute(value))
1727
+ return;
1728
+ diagnostics.push({
1729
+ code: "interaction.booleanAttribute",
1730
+ severity: "error",
1731
+ message: `${interaction.qtiName} requires boolean ${attribute}, got ${value}.`,
1732
+ path: interaction.source?.path,
1733
+ source: interaction.source,
1734
+ });
1735
+ }
1531
1736
  function validateChoiceNonNegativeIntegerAttribute(choice, attribute, diagnostics) {
1532
1737
  const value = choice.attributes[attribute];
1533
1738
  if (value === undefined || isNonNegativeInteger(value))
@@ -1615,13 +1820,20 @@ function allowedInteractionChildren(interaction) {
1615
1820
  return setOf(common, ["object", "img", "qti-position-object-stage"]);
1616
1821
  case "selectPoint":
1617
1822
  case "media":
1618
- return setOf(common, ["object", "img"]);
1823
+ return setOf(common, ["audio", "video", "object", "img"]);
1619
1824
  case "drawing":
1620
- return setOf(common, ["object", "img"]);
1825
+ return setOf(common, ["object", "img", "picture"]);
1621
1826
  case "extendedText":
1622
1827
  return new Set(common);
1623
1828
  case "portableCustom":
1624
- return setOf(common, ["qti-interaction-markup"]);
1829
+ return setOf(common, [
1830
+ "qti-interaction-markup",
1831
+ "qti-interaction-modules",
1832
+ "qti-template-variable",
1833
+ "qti-context-variable",
1834
+ "qti-stylesheet",
1835
+ "qti-catalog-info",
1836
+ ]);
1625
1837
  case "slider":
1626
1838
  case "textEntry":
1627
1839
  case "upload":
@@ -1662,6 +1874,9 @@ function isInteger(value) {
1662
1874
  function isNonNegativeInteger(value) {
1663
1875
  return /^\d+$/.test(value);
1664
1876
  }
1877
+ function isBooleanAttribute(value) {
1878
+ return value === "true" || value === "false" || value === "1" || value === "0";
1879
+ }
1665
1880
  function isPoint(value) {
1666
1881
  const parts = value.trim().split(/\s+/);
1667
1882
  return parts.length === 2 && parts.every(isFiniteNumber);
@@ -1675,7 +1890,7 @@ function expectedResponseShape(interaction) {
1675
1890
  return { cardinalities: ["single"], baseTypes: ["boolean"] };
1676
1891
  }
1677
1892
  if (interaction.type === "media")
1678
- return undefined;
1893
+ return { cardinalities: ["single"], baseTypes: ["integer"] };
1679
1894
  if (interaction.type === "custom")
1680
1895
  return undefined;
1681
1896
  if (interaction.type === "order" || interaction.type === "graphicOrder") {
@@ -1701,8 +1916,25 @@ function expectedResponseShape(interaction) {
1701
1916
  if (interaction.type === "textEntry" || interaction.type === "extendedText") {
1702
1917
  return { cardinalities: ["single"], baseTypes: ["string"] };
1703
1918
  }
1704
- if (interaction.type === "drawing" || interaction.type === "portableCustom") {
1705
- return { cardinalities: ["single"], baseTypes: ["string", "file", "uri"] };
1919
+ if (interaction.type === "drawing")
1920
+ return { cardinalities: ["single"], baseTypes: ["file"] };
1921
+ if (interaction.type === "portableCustom") {
1922
+ return {
1923
+ cardinalities: ["single", "multiple", "ordered", "record"],
1924
+ baseTypes: [
1925
+ "identifier",
1926
+ "boolean",
1927
+ "integer",
1928
+ "float",
1929
+ "string",
1930
+ "point",
1931
+ "pair",
1932
+ "directedPair",
1933
+ "duration",
1934
+ "file",
1935
+ "uri",
1936
+ ],
1937
+ };
1706
1938
  }
1707
1939
  return { cardinalities: ["single", "multiple"], baseTypes: ["identifier"] };
1708
1940
  }