@longsightgroup/qti3-core 0.1.2 → 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.
- package/README.md +1 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +126 -0
- package/dist/catalog.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +225 -3
- package/dist/parser.js.map +1 -1
- package/dist/session.d.ts +3 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +68 -3
- package/dist/session.js.map +1 -1
- package/dist/support.js +7 -1
- package/dist/support.js.map +1 -1
- package/dist/tts.d.ts +61 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +368 -0
- package/dist/tts.js.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +200 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +193 -0
- package/src/index.ts +35 -0
- package/src/parser.ts +266 -3
- package/src/session.ts +90 -0
- package/src/support.ts +9 -1
- package/src/tts.ts +555 -0
- package/src/types.ts +52 -0
- package/src/validation.ts +229 -8
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, [
|
|
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 {
|
|
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
|
}
|