@longsightgroup/qti3-core 0.1.2 → 0.2.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.
package/src/validation.ts CHANGED
@@ -20,6 +20,7 @@ import type {
20
20
  QtiValue,
21
21
  QtiValidationResult,
22
22
  } from "./types.js";
23
+ import { validateQtiDataSsmlMetadata } from "./tts.js";
23
24
 
24
25
  const BUILT_IN_COMPLETION_STATUS = "completionStatus";
25
26
 
@@ -34,6 +35,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
34
35
  validateModalFeedback(item, diagnostics);
35
36
  validateCatalogInfo(item, diagnostics);
36
37
  validateStylesheets(item, diagnostics);
38
+ diagnostics.push(...validateQtiDataSsmlMetadata(item));
37
39
  validateProcessingReferences(item, diagnostics);
38
40
 
39
41
  return {
@@ -1443,7 +1445,9 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
1443
1445
  validateInteractionChoices(interaction, diagnostics);
1444
1446
  validateInteractionChildren(interaction, diagnostics);
1445
1447
  validateInteractionRequiredAttributes(interaction, diagnostics);
1448
+ validatePortableCustomInteraction(interaction, item, diagnostics);
1446
1449
  validateInteractionLimitAttributes(interaction, diagnostics);
1450
+ validateGraphicHotspotObjectDimensions(interaction, diagnostics);
1447
1451
  validateCorrectResponseReferences(
1448
1452
  interaction,
1449
1453
  interaction.responseIdentifier
@@ -1709,12 +1713,6 @@ function validateInteractionRequiredAttributes(
1709
1713
  "interaction.portableCustom.typeIdentifier",
1710
1714
  diagnostics,
1711
1715
  );
1712
- requireInteractionAttribute(
1713
- interaction,
1714
- "module",
1715
- "interaction.portableCustom.module",
1716
- diagnostics,
1717
- );
1718
1716
  }
1719
1717
 
1720
1718
  if (interaction.type === "slider") {
@@ -1800,6 +1798,116 @@ function requireInteractionAttribute(
1800
1798
  });
1801
1799
  }
1802
1800
 
1801
+ function validatePortableCustomInteraction(
1802
+ interaction: QtiInteraction,
1803
+ item: QtiAssessmentItem,
1804
+ diagnostics: QtiDiagnostic[],
1805
+ ): void {
1806
+ if (interaction.type !== "portableCustom") return;
1807
+ const definition = interaction.portableCustom;
1808
+ if (!definition) return;
1809
+
1810
+ const configuredModules = definition.interactionModules?.modules ?? [];
1811
+ const hasModuleAttribute = Boolean(definition.module?.trim());
1812
+ const hasConfiguredModule = configuredModules.some((module) => Boolean(module.id?.trim()));
1813
+ if (!hasModuleAttribute && !hasConfiguredModule) {
1814
+ diagnostics.push({
1815
+ code: "interaction.portableCustom.module",
1816
+ severity: "error",
1817
+ message: `${interaction.qtiName} requires a module attribute or at least one qti-interaction-module id.`,
1818
+ path: interaction.source?.path,
1819
+ source: interaction.source,
1820
+ });
1821
+ }
1822
+
1823
+ for (const module of configuredModules) {
1824
+ if (!module.id?.trim()) {
1825
+ diagnostics.push({
1826
+ code: "interaction.portableCustom.moduleId",
1827
+ severity: "error",
1828
+ message: "qti-interaction-module requires a non-empty id.",
1829
+ path: module.source?.path,
1830
+ source: module.source,
1831
+ });
1832
+ }
1833
+ warnExternalPortableCustomUrl(module.primaryPath, module.source, diagnostics);
1834
+ warnExternalPortableCustomUrl(module.fallbackPath, module.source, diagnostics);
1835
+ }
1836
+
1837
+ warnExternalPortableCustomUrl(
1838
+ definition.interactionModules?.primaryConfiguration,
1839
+ definition.interactionModules?.source,
1840
+ diagnostics,
1841
+ );
1842
+ warnExternalPortableCustomUrl(
1843
+ definition.interactionModules?.secondaryConfiguration,
1844
+ definition.interactionModules?.source,
1845
+ diagnostics,
1846
+ );
1847
+
1848
+ const templateIdentifiers = new Set(
1849
+ item.templateDeclarations.map((declaration) => declaration.identifier),
1850
+ );
1851
+ for (const variable of definition.templateVariables) {
1852
+ if (!variable.identifier?.trim()) {
1853
+ diagnostics.push({
1854
+ code: "interaction.portableCustom.templateVariable",
1855
+ severity: "error",
1856
+ message: "qti-template-variable requires template-identifier or identifier.",
1857
+ path: variable.source?.path,
1858
+ source: variable.source,
1859
+ });
1860
+ continue;
1861
+ }
1862
+ if (!templateIdentifiers.has(variable.identifier)) {
1863
+ diagnostics.push({
1864
+ code: "interaction.portableCustom.templateVariable.reference",
1865
+ severity: "error",
1866
+ message: `qti-template-variable references missing template declaration ${variable.identifier}.`,
1867
+ path: variable.source?.path,
1868
+ source: variable.source,
1869
+ });
1870
+ }
1871
+ }
1872
+
1873
+ for (const variable of definition.contextVariables) {
1874
+ if (variable.identifier?.trim()) continue;
1875
+ diagnostics.push({
1876
+ code: "interaction.portableCustom.contextVariable",
1877
+ severity: "error",
1878
+ message: "qti-context-variable requires identifier.",
1879
+ path: variable.source?.path,
1880
+ source: variable.source,
1881
+ });
1882
+ }
1883
+
1884
+ for (const stylesheet of definition.stylesheets) {
1885
+ if (stylesheet.href.trim().length > 0) continue;
1886
+ diagnostics.push({
1887
+ code: "stylesheet.href.required",
1888
+ severity: "error",
1889
+ message: "qti-stylesheet requires a non-empty href attribute.",
1890
+ path: stylesheet.source?.path,
1891
+ source: stylesheet.source,
1892
+ });
1893
+ }
1894
+ }
1895
+
1896
+ function warnExternalPortableCustomUrl(
1897
+ url: string | undefined,
1898
+ source: QtiDiagnostic["source"],
1899
+ diagnostics: QtiDiagnostic[],
1900
+ ): void {
1901
+ if (!url || !/^https?:\/\//i.test(url)) return;
1902
+ diagnostics.push({
1903
+ code: "interaction.portableCustom.externalModuleUrl",
1904
+ severity: "warning",
1905
+ message: `Portable custom interaction module URL ${url} requires host delivery policy approval.`,
1906
+ path: source?.path,
1907
+ source,
1908
+ });
1909
+ }
1910
+
1803
1911
  function invalidNumber(
1804
1912
  interaction: QtiInteraction,
1805
1913
  attribute: string,
@@ -1922,6 +2030,97 @@ function validateHotspotGeometry(choice: QtiChoice, diagnostics: QtiDiagnostic[]
1922
2030
  }
1923
2031
  }
1924
2032
 
2033
+ function validateGraphicHotspotObjectDimensions(
2034
+ interaction: QtiInteraction,
2035
+ diagnostics: QtiDiagnostic[],
2036
+ ): void {
2037
+ if (!usesGraphicHotspots(interaction)) return;
2038
+ const hotspotChoices = interaction.choices.filter(isHotspotChoice);
2039
+ if (hotspotChoices.length === 0) return;
2040
+
2041
+ const width = positiveDimension(interaction.object?.width);
2042
+ const height = positiveDimension(interaction.object?.height);
2043
+ if (width === undefined || height === undefined) {
2044
+ diagnostics.push({
2045
+ code: "interaction.graphicObjectDimensions",
2046
+ severity: "warning",
2047
+ message: `${interaction.qtiName} should declare object width and height so hotspot coords map to the rendered image.`,
2048
+ path: interaction.object?.source?.path ?? interaction.source?.path,
2049
+ source: interaction.object?.source ?? interaction.source,
2050
+ });
2051
+ return;
2052
+ }
2053
+
2054
+ for (const choice of hotspotChoices) {
2055
+ const bounds = hotspotBounds(choice);
2056
+ if (!bounds) continue;
2057
+ if (bounds.left >= 0 && bounds.top >= 0 && bounds.right <= width && bounds.bottom <= height) {
2058
+ continue;
2059
+ }
2060
+ diagnostics.push({
2061
+ code: "choice.coords.bounds",
2062
+ severity: "warning",
2063
+ message: `${choice.qtiName} ${choice.identifier} coords extend outside the ${width} by ${height} object image.`,
2064
+ path: choice.source?.path,
2065
+ source: choice.source,
2066
+ });
2067
+ }
2068
+ }
2069
+
2070
+ function usesGraphicHotspots(interaction: QtiInteraction): boolean {
2071
+ return (
2072
+ interaction.type === "graphicOrder" ||
2073
+ interaction.type === "graphicAssociate" ||
2074
+ interaction.type === "graphicGapMatch" ||
2075
+ interaction.type === "hotspot"
2076
+ );
2077
+ }
2078
+
2079
+ function isHotspotChoice(choice: QtiChoice): boolean {
2080
+ return choice.qtiName === "qti-hotspot-choice" || choice.qtiName === "qti-associable-hotspot";
2081
+ }
2082
+
2083
+ function positiveDimension(value: string | undefined): number | undefined {
2084
+ if (!value || value.trim().endsWith("%")) return undefined;
2085
+ const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
2086
+ if (!match?.[1]) return undefined;
2087
+ const parsed = Number(match[1]);
2088
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
2089
+ }
2090
+
2091
+ function hotspotBounds(
2092
+ choice: QtiChoice,
2093
+ ): { left: number; top: number; right: number; bottom: number } | undefined {
2094
+ const shape = choice.attributes.shape;
2095
+ const coords = choice.attributes.coords;
2096
+ if (!shape || !coords || !isHotspotShape(shape) || !isNumericCsv(coords)) return undefined;
2097
+ const values = numericCsv(coords);
2098
+ if (!hasValidShapeCoordinateCount(shape, values)) return undefined;
2099
+
2100
+ if (shape === "default") return undefined;
2101
+ if (shape === "circle") {
2102
+ const [x, y, radius] = values as [number, number, number];
2103
+ return { left: x - radius, top: y - radius, right: x + radius, bottom: y + radius };
2104
+ }
2105
+ if (shape === "ellipse") {
2106
+ const [x, y, radiusX, radiusY] = values as [number, number, number, number];
2107
+ return { left: x - radiusX, top: y - radiusY, right: x + radiusX, bottom: y + radiusY };
2108
+ }
2109
+ if (shape === "rect") {
2110
+ const [left, top, right, bottom] = values as [number, number, number, number];
2111
+ return { left, top, right, bottom };
2112
+ }
2113
+
2114
+ const xs = values.filter((_, index) => index % 2 === 0);
2115
+ const ys = values.filter((_, index) => index % 2 === 1);
2116
+ return {
2117
+ left: Math.min(...xs),
2118
+ top: Math.min(...ys),
2119
+ right: Math.max(...xs),
2120
+ bottom: Math.max(...ys),
2121
+ };
2122
+ }
2123
+
1925
2124
  function isHotspotShape(value: string): boolean {
1926
2125
  return (
1927
2126
  value === "circle" ||
@@ -2106,7 +2305,14 @@ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> |
2106
2305
  case "extendedText":
2107
2306
  return new Set(common);
2108
2307
  case "portableCustom":
2109
- return setOf(common, ["qti-interaction-markup"]);
2308
+ return setOf(common, [
2309
+ "qti-interaction-markup",
2310
+ "qti-interaction-modules",
2311
+ "qti-template-variable",
2312
+ "qti-context-variable",
2313
+ "qti-stylesheet",
2314
+ "qti-catalog-info",
2315
+ ]);
2110
2316
  case "slider":
2111
2317
  case "textEntry":
2112
2318
  case "upload":
@@ -2206,7 +2412,22 @@ function expectedResponseShape(
2206
2412
  }
2207
2413
  if (interaction.type === "drawing") return { cardinalities: ["single"], baseTypes: ["file"] };
2208
2414
  if (interaction.type === "portableCustom") {
2209
- return { cardinalities: ["single"], baseTypes: ["string", "file", "uri"] };
2415
+ return {
2416
+ cardinalities: ["single", "multiple", "ordered", "record"],
2417
+ baseTypes: [
2418
+ "identifier",
2419
+ "boolean",
2420
+ "integer",
2421
+ "float",
2422
+ "string",
2423
+ "point",
2424
+ "pair",
2425
+ "directedPair",
2426
+ "duration",
2427
+ "file",
2428
+ "uri",
2429
+ ],
2430
+ };
2210
2431
  }
2211
2432
  return { cardinalities: ["single", "multiple"], baseTypes: ["identifier"] };
2212
2433
  }