@longsightgroup/qti3-core 0.10.2 → 0.10.4
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 +21 -2
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +20 -27
- package/dist/attempt-state.js.map +1 -1
- package/dist/content-text.d.ts +4 -0
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +137 -12
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/media-definition.d.ts +55 -0
- package/dist/media-definition.d.ts.map +1 -0
- package/dist/media-definition.js +97 -0
- package/dist/media-definition.js.map +1 -0
- package/dist/package-xml.d.ts +1 -0
- package/dist/package-xml.d.ts.map +1 -1
- package/dist/package-xml.js +1 -0
- package/dist/package-xml.js.map +1 -1
- package/dist/parser-custom-interactions.js +1 -0
- package/dist/parser-custom-interactions.js.map +1 -1
- package/dist/parser-declarations.d.ts +4 -4
- package/dist/parser-declarations.d.ts.map +1 -1
- package/dist/parser-declarations.js +87 -46
- package/dist/parser-declarations.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +5 -3
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-processing.js +10 -9
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts +6 -0
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +35 -2
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +81 -47
- package/dist/parser.js.map +1 -1
- package/dist/processing-evaluator-collection.d.ts.map +1 -1
- package/dist/processing-evaluator-collection.js +8 -2
- package/dist/processing-evaluator-collection.js.map +1 -1
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +22 -6
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-numeric.d.ts.map +1 -1
- package/dist/processing-evaluator-numeric.js +8 -1
- package/dist/processing-evaluator-numeric.js.map +1 -1
- package/dist/processing-evaluator-variable.d.ts.map +1 -1
- package/dist/processing-evaluator-variable.js +1 -1
- package/dist/processing-evaluator-variable.js.map +1 -1
- package/dist/processing-expression-children.d.ts.map +1 -1
- package/dist/processing-expression-children.js +2 -1
- package/dist/processing-expression-children.js.map +1 -1
- package/dist/processing-limits.d.ts +3 -0
- package/dist/processing-limits.d.ts.map +1 -0
- package/dist/processing-limits.js +3 -0
- package/dist/processing-limits.js.map +1 -0
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +28 -9
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-operators.d.ts +2 -0
- package/dist/processing-operators.d.ts.map +1 -1
- package/dist/processing-operators.js +34 -5
- package/dist/processing-operators.js.map +1 -1
- package/dist/processing-values.d.ts +4 -4
- package/dist/processing-values.d.ts.map +1 -1
- package/dist/processing-values.js +28 -14
- package/dist/processing-values.js.map +1 -1
- package/dist/processing-variables.d.ts +3 -1
- package/dist/processing-variables.d.ts.map +1 -1
- package/dist/processing-variables.js +38 -2
- package/dist/processing-variables.js.map +1 -1
- package/dist/qti-namespaces.d.ts +7 -0
- package/dist/qti-namespaces.d.ts.map +1 -0
- package/dist/qti-namespaces.js +7 -0
- package/dist/qti-namespaces.js.map +1 -0
- package/dist/qti-package-items.d.ts.map +1 -1
- package/dist/qti-package-items.js +23 -13
- package/dist/qti-package-items.js.map +1 -1
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js +4 -3
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +3 -2
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-xml.d.ts +4 -2
- package/dist/qti-package-xml.d.ts.map +1 -1
- package/dist/qti-package-xml.js +10 -5
- package/dist/qti-package-xml.js.map +1 -1
- package/dist/qti-package-zip.d.ts +17 -1
- package/dist/qti-package-zip.d.ts.map +1 -1
- package/dist/qti-package-zip.js +60 -5
- package/dist/qti-package-zip.js.map +1 -1
- package/dist/qti-package.d.ts +3 -3
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +10 -4
- package/dist/qti-package.js.map +1 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +5 -6
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts +12 -2
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +122 -19
- package/dist/response-validation.js.map +1 -1
- package/dist/serializer-processing-expressions.js +1 -1
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/session.js +1 -1
- package/dist/session.js.map +1 -1
- package/dist/slider-definition.d.ts +59 -0
- package/dist/slider-definition.d.ts.map +1 -0
- package/dist/slider-definition.js +198 -0
- package/dist/slider-definition.js.map +1 -0
- package/dist/support-evidence.d.ts +6 -2
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +15 -11
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +96 -100
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +5 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-declaration-entries.d.ts +13 -0
- package/dist/validation-declaration-entries.d.ts.map +1 -0
- package/dist/validation-declaration-entries.js +136 -0
- package/dist/validation-declaration-entries.js.map +1 -0
- package/dist/validation-interactions.d.ts.map +1 -1
- package/dist/validation-interactions.js +14 -46
- package/dist/validation-interactions.js.map +1 -1
- package/dist/validation-primitives.d.ts +5 -1
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +41 -3
- package/dist/validation-primitives.js.map +1 -1
- package/dist/validation-processing.d.ts.map +1 -1
- package/dist/validation-processing.js +125 -1
- package/dist/validation-processing.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +8 -134
- package/dist/validation.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +31 -2
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/attempt-state.ts +22 -26
- package/src/content-text.ts +174 -14
- package/src/index.ts +28 -0
- package/src/media-definition.ts +174 -0
- package/src/package-xml.ts +2 -0
- package/src/parser-custom-interactions.ts +1 -0
- package/src/parser-declarations.ts +134 -40
- package/src/parser-item-metadata.ts +5 -3
- package/src/parser-processing.ts +10 -9
- package/src/parser-values.ts +35 -2
- package/src/parser.ts +137 -103
- package/src/processing-evaluator-collection.ts +7 -2
- package/src/processing-evaluator-comparison.ts +30 -5
- package/src/processing-evaluator-numeric.ts +5 -1
- package/src/processing-evaluator-variable.ts +1 -0
- package/src/processing-expression-children.ts +2 -1
- package/src/processing-limits.ts +2 -0
- package/src/processing-mapping.ts +32 -8
- package/src/processing-operators.ts +38 -5
- package/src/processing-values.ts +59 -14
- package/src/processing-variables.ts +47 -2
- package/src/qti-namespaces.ts +8 -0
- package/src/qti-package-items.ts +56 -49
- package/src/qti-package-manifest.ts +13 -4
- package/src/qti-package-metadata.ts +3 -2
- package/src/qti-package-xml.ts +16 -3
- package/src/qti-package-zip.ts +128 -3
- package/src/qti-package.ts +12 -3
- package/src/response-validation-policy.ts +5 -5
- package/src/response-validation.ts +185 -20
- package/src/serializer-processing-expressions.ts +1 -1
- package/src/session.ts +1 -0
- package/src/slider-definition.ts +363 -0
- package/src/support-evidence.ts +15 -17
- package/src/support.ts +106 -102
- package/src/trusted-item-session.ts +5 -3
- package/src/types.ts +10 -1
- package/src/validation-declaration-entries.ts +162 -0
- package/src/validation-interactions.ts +14 -64
- package/src/validation-primitives.ts +44 -4
- package/src/validation-processing.ts +145 -1
- package/src/validation.ts +11 -151
- package/src/xml.ts +40 -4
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
} from "./types.js";
|
|
10
10
|
import { assertNever } from "./assert-never.js";
|
|
11
11
|
import { isValidQtiPatternMask } from "./pattern-mask.js";
|
|
12
|
+
import { parseQtiMediaDefinition } from "./media-definition.js";
|
|
13
|
+
import { parseQtiSliderDefinition } from "./slider-definition.js";
|
|
12
14
|
import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interaction-validation.js";
|
|
13
15
|
import { qtiValueToStringList } from "./value-format.js";
|
|
14
16
|
import {
|
|
@@ -17,7 +19,6 @@ import {
|
|
|
17
19
|
} from "./validation-geometry.js";
|
|
18
20
|
import {
|
|
19
21
|
isBooleanAttribute,
|
|
20
|
-
isFiniteNumber,
|
|
21
22
|
isNonNegativeInteger,
|
|
22
23
|
requireIdentifier,
|
|
23
24
|
} from "./validation-primitives.js";
|
|
@@ -104,7 +105,11 @@ function validateInteractionResponseShape(
|
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
if (
|
|
108
|
+
if (
|
|
109
|
+
interaction.type !== "slider" &&
|
|
110
|
+
interaction.responseBaseType &&
|
|
111
|
+
!expected.baseTypes.includes(interaction.responseBaseType)
|
|
112
|
+
) {
|
|
108
113
|
diagnostics.push({
|
|
109
114
|
code: "interaction.baseType",
|
|
110
115
|
severity: "error",
|
|
@@ -305,46 +310,13 @@ function validateInteractionRequiredAttributes(
|
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
if (interaction.type === "slider") {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
);
|
|
316
|
-
requireInteractionAttribute(
|
|
317
|
-
interaction,
|
|
318
|
-
"upper-bound",
|
|
319
|
-
"interaction.slider.upperBound",
|
|
320
|
-
diagnostics,
|
|
321
|
-
);
|
|
322
|
-
if (lower !== undefined && !isFiniteNumber(lower)) {
|
|
323
|
-
invalidNumber(interaction, "lower-bound", lower, diagnostics);
|
|
324
|
-
}
|
|
325
|
-
if (upper !== undefined && !isFiniteNumber(upper)) {
|
|
326
|
-
invalidNumber(interaction, "upper-bound", upper, diagnostics);
|
|
327
|
-
}
|
|
328
|
-
if (
|
|
329
|
-
lower !== undefined &&
|
|
330
|
-
upper !== undefined &&
|
|
331
|
-
isFiniteNumber(lower) &&
|
|
332
|
-
isFiniteNumber(upper)
|
|
333
|
-
) {
|
|
334
|
-
if (Number(lower) >= Number(upper)) {
|
|
335
|
-
diagnostics.push({
|
|
336
|
-
code: "interaction.slider.bounds",
|
|
337
|
-
severity: "error",
|
|
338
|
-
message: `${interaction.qtiName} requires lower-bound to be less than upper-bound.`,
|
|
339
|
-
path: interaction.source?.path,
|
|
340
|
-
source: interaction.source,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
const step = interaction.attributes.step;
|
|
345
|
-
if (step !== undefined && (!isFiniteNumber(step) || Number(step) <= 0)) {
|
|
346
|
-
invalidNumber(interaction, "step", step, diagnostics);
|
|
347
|
-
}
|
|
313
|
+
const definition = parseQtiSliderDefinition(interaction);
|
|
314
|
+
if (!definition.ok) diagnostics.push(...definition.diagnostics);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (interaction.type === "media") {
|
|
318
|
+
const definition = parseQtiMediaDefinition(interaction);
|
|
319
|
+
if (!definition.ok) diagnostics.push(...definition.diagnostics);
|
|
348
320
|
}
|
|
349
321
|
}
|
|
350
322
|
|
|
@@ -497,21 +469,6 @@ function warnExternalPortableCustomUrl(
|
|
|
497
469
|
});
|
|
498
470
|
}
|
|
499
471
|
|
|
500
|
-
function invalidNumber(
|
|
501
|
-
interaction: QtiInteraction,
|
|
502
|
-
attribute: string,
|
|
503
|
-
value: string,
|
|
504
|
-
diagnostics: QtiDiagnostic[],
|
|
505
|
-
): void {
|
|
506
|
-
diagnostics.push({
|
|
507
|
-
code: "interaction.numericAttribute",
|
|
508
|
-
severity: "error",
|
|
509
|
-
message: `${interaction.qtiName} requires numeric ${attribute}, got ${value}.`,
|
|
510
|
-
path: interaction.source?.path,
|
|
511
|
-
source: interaction.source,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
472
|
function validatePatternMaskAttribute(
|
|
516
473
|
interaction: QtiInteraction,
|
|
517
474
|
diagnostics: QtiDiagnostic[],
|
|
@@ -542,13 +499,6 @@ function validateInteractionLimitAttributes(
|
|
|
542
499
|
validateMinMaxPair(interaction, "min-choices", "max-choices", diagnostics);
|
|
543
500
|
validateMinMaxPair(interaction, "min-associations", "max-associations", diagnostics);
|
|
544
501
|
validateBooleanAttribute(interaction, "required", diagnostics);
|
|
545
|
-
if (interaction.type === "media") {
|
|
546
|
-
validateNonNegativeIntegerAttribute(interaction, "max-plays", diagnostics);
|
|
547
|
-
validateNonNegativeIntegerAttribute(interaction, "min-plays", diagnostics);
|
|
548
|
-
validateBooleanAttribute(interaction, "autostart", diagnostics);
|
|
549
|
-
validateBooleanAttribute(interaction, "loop", diagnostics);
|
|
550
|
-
validateMinMaxPair(interaction, "min-plays", "max-plays", diagnostics);
|
|
551
|
-
}
|
|
552
502
|
}
|
|
553
503
|
|
|
554
504
|
function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { QtiBaseType, QtiCardinality, QtiDiagnostic } from "./types.js";
|
|
2
|
-
import {
|
|
1
|
+
import type { QtiBaseType, QtiCardinality, QtiDiagnostic, QtiScalarValue } from "./types.js";
|
|
2
|
+
import { assertNever } from "./assert-never.js";
|
|
3
|
+
import { parseFiniteNumber, parseInteger, parseQtiPair, parseXmlBoolean } from "./parser-values.js";
|
|
3
4
|
|
|
4
5
|
export function requireIdentifier(
|
|
5
6
|
qtiName: string,
|
|
@@ -38,11 +39,11 @@ export function isBaseType(value: string): value is QtiBaseType {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function isFiniteNumber(value: string): boolean {
|
|
41
|
-
return
|
|
42
|
+
return parseFiniteNumber(value) !== undefined;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function isInteger(value: string): boolean {
|
|
45
|
-
return
|
|
46
|
+
return parseInteger(value) !== undefined;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export function isNonNegativeInteger(value: string): boolean {
|
|
@@ -67,3 +68,42 @@ export function isPair(value: string): boolean {
|
|
|
67
68
|
const parts = value.trim().split(/\s+/);
|
|
68
69
|
return parts.length === 2 && parts.every((part) => part.length > 0);
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
/** Parse and normalize a runtime scalar for a QTI base type. */
|
|
73
|
+
export function parseQtiScalarForBaseType(
|
|
74
|
+
value: QtiScalarValue,
|
|
75
|
+
baseType: QtiBaseType,
|
|
76
|
+
): QtiScalarValue | undefined {
|
|
77
|
+
switch (baseType) {
|
|
78
|
+
case "integer":
|
|
79
|
+
if (typeof value === "number") return Number.isInteger(value) ? value : undefined;
|
|
80
|
+
return typeof value === "string" ? parseInteger(value) : undefined;
|
|
81
|
+
case "float":
|
|
82
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
83
|
+
return typeof value === "string" ? parseFiniteNumber(value) : undefined;
|
|
84
|
+
case "boolean":
|
|
85
|
+
if (typeof value === "boolean") return value;
|
|
86
|
+
return typeof value === "string" ? parseXmlBoolean(value) : undefined;
|
|
87
|
+
case "point":
|
|
88
|
+
return typeof value === "string" && isPoint(value) ? value : undefined;
|
|
89
|
+
case "pair":
|
|
90
|
+
case "directedPair":
|
|
91
|
+
return typeof value === "string" ? parseQtiPair(value, baseType) : undefined;
|
|
92
|
+
case "identifier":
|
|
93
|
+
return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value)
|
|
94
|
+
? value
|
|
95
|
+
: undefined;
|
|
96
|
+
case "string":
|
|
97
|
+
case "duration":
|
|
98
|
+
case "file":
|
|
99
|
+
case "uri":
|
|
100
|
+
return typeof value === "string" ? value : undefined;
|
|
101
|
+
default:
|
|
102
|
+
return assertNever(baseType);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Check a runtime scalar against the value representation accepted for a QTI base type. */
|
|
107
|
+
export function qtiScalarMatchesBaseType(value: QtiScalarValue, baseType: QtiBaseType): boolean {
|
|
108
|
+
return parseQtiScalarForBaseType(value, baseType) !== undefined;
|
|
109
|
+
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
QtiTemplateRule,
|
|
9
9
|
} from "./types.js";
|
|
10
10
|
import { expressionChildren } from "./processing-expression-children.js";
|
|
11
|
+
import { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
|
|
11
12
|
import { MATH_OPERATOR_NAMES, STATS_OPERATOR_NAMES } from "./processing-operators.js";
|
|
12
13
|
import {
|
|
13
14
|
responseProcessingTemplateKind,
|
|
@@ -450,6 +451,11 @@ function validateExpressionReferences(
|
|
|
450
451
|
);
|
|
451
452
|
}
|
|
452
453
|
|
|
454
|
+
if (expression.type === "anyN") {
|
|
455
|
+
validateAnyNBound("min", expression.min, responses, variables, diagnostics, expression.source);
|
|
456
|
+
validateAnyNBound("max", expression.max, responses, variables, diagnostics, expression.source);
|
|
457
|
+
}
|
|
458
|
+
|
|
453
459
|
if (expression.type === "mathConstant" && !mathConstantNames.has(expression.name)) {
|
|
454
460
|
diagnostics.push({
|
|
455
461
|
code: "processing.mathConstant.name",
|
|
@@ -480,6 +486,35 @@ function validateExpressionReferences(
|
|
|
480
486
|
});
|
|
481
487
|
}
|
|
482
488
|
|
|
489
|
+
if (
|
|
490
|
+
(expression.type === "sum" ||
|
|
491
|
+
expression.type === "product" ||
|
|
492
|
+
expression.type === "min" ||
|
|
493
|
+
expression.type === "max") &&
|
|
494
|
+
expression.expressions.length === 0
|
|
495
|
+
) {
|
|
496
|
+
diagnostics.push({
|
|
497
|
+
code: "processing.numeric.arity",
|
|
498
|
+
severity: "error",
|
|
499
|
+
message: `qti-${expression.type} requires at least one child expression.`,
|
|
500
|
+
path: expression.source?.path,
|
|
501
|
+
source: expression.source,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
expression.type === "statsOperator" &&
|
|
507
|
+
(expression.expressions ?? [expression.expression]).length !== 1
|
|
508
|
+
) {
|
|
509
|
+
diagnostics.push({
|
|
510
|
+
code: "processing.statsOperator.arity",
|
|
511
|
+
severity: "error",
|
|
512
|
+
message: "qti-stats-operator requires exactly one child expression.",
|
|
513
|
+
path: expression.source?.path,
|
|
514
|
+
source: expression.source,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
483
518
|
if (expression.type === "repeat") {
|
|
484
519
|
validateRepeatExpression(expression, variables, diagnostics);
|
|
485
520
|
}
|
|
@@ -540,7 +575,31 @@ function validateRepeatExpression(
|
|
|
540
575
|
variables: Set<string>,
|
|
541
576
|
diagnostics: QtiDiagnostic[],
|
|
542
577
|
): void {
|
|
543
|
-
if (isInteger(expression.numberRepeats))
|
|
578
|
+
if (isInteger(expression.numberRepeats)) {
|
|
579
|
+
const repeats = BigInt(expression.numberRepeats);
|
|
580
|
+
if (repeats < 0n) {
|
|
581
|
+
diagnostics.push(repeatCountDiagnostic(expression));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const minimumPerRepeat = minimumProducedElements(expression.expressions);
|
|
585
|
+
if (
|
|
586
|
+
repeats > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS) ||
|
|
587
|
+
repeats * BigInt(minimumPerRepeat) > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS)
|
|
588
|
+
) {
|
|
589
|
+
diagnostics.push({
|
|
590
|
+
code: "processing.repeat.limit",
|
|
591
|
+
severity: "error",
|
|
592
|
+
message: `qti-repeat cannot exceed ${MAX_QTI_REPEAT_RESULT_ELEMENTS} iterations or produced elements.`,
|
|
593
|
+
path: expression.source?.path,
|
|
594
|
+
source: expression.source,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (Number.isFinite(Number(expression.numberRepeats))) {
|
|
600
|
+
diagnostics.push(repeatCountDiagnostic(expression));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
544
603
|
validateProcessingIdentifier(
|
|
545
604
|
expression.numberRepeats,
|
|
546
605
|
"processing.repeat.numberRepeats",
|
|
@@ -558,6 +617,91 @@ function validateRepeatExpression(
|
|
|
558
617
|
}
|
|
559
618
|
}
|
|
560
619
|
|
|
620
|
+
function validateAnyNBound(
|
|
621
|
+
attribute: "min" | "max",
|
|
622
|
+
value: string,
|
|
623
|
+
responses: Set<string>,
|
|
624
|
+
variables: Set<string>,
|
|
625
|
+
diagnostics: QtiDiagnostic[],
|
|
626
|
+
source: QtiDiagnostic["source"],
|
|
627
|
+
): void {
|
|
628
|
+
if (value.length === 0) {
|
|
629
|
+
diagnostics.push({
|
|
630
|
+
code: `processing.anyN.${attribute}.required`,
|
|
631
|
+
severity: "error",
|
|
632
|
+
message: `qti-any-n requires ${attribute}.`,
|
|
633
|
+
path: source?.path,
|
|
634
|
+
source,
|
|
635
|
+
});
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (isInteger(value)) return;
|
|
639
|
+
if (Number.isFinite(Number(value))) {
|
|
640
|
+
diagnostics.push({
|
|
641
|
+
code: `processing.anyN.${attribute}.integer`,
|
|
642
|
+
severity: "error",
|
|
643
|
+
message: `qti-any-n ${attribute} must be an integer or a declared variable.`,
|
|
644
|
+
path: source?.path,
|
|
645
|
+
source,
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!variables.has(value) || responses.has(value)) {
|
|
650
|
+
diagnostics.push({
|
|
651
|
+
code: `processing.anyN.${attribute}.reference`,
|
|
652
|
+
severity: "error",
|
|
653
|
+
message: `qti-any-n ${attribute} references missing template or outcome variable ${value}.`,
|
|
654
|
+
path: source?.path,
|
|
655
|
+
source,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function repeatCountDiagnostic(
|
|
661
|
+
expression: Extract<QtiProcessingExpression, { type: "repeat" }>,
|
|
662
|
+
): QtiDiagnostic {
|
|
663
|
+
return {
|
|
664
|
+
code: "processing.repeat.numberRepeats",
|
|
665
|
+
severity: "error",
|
|
666
|
+
message: "qti-repeat requires a non-negative integer or a declared variable.",
|
|
667
|
+
path: expression.source?.path,
|
|
668
|
+
source: expression.source,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function minimumProducedElements(expressions: QtiProcessingExpression[]): number {
|
|
673
|
+
let total = 0;
|
|
674
|
+
for (const expression of expressions) {
|
|
675
|
+
total = cappedElementCount(total + minimumExpressionElements(expression));
|
|
676
|
+
}
|
|
677
|
+
return total;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function minimumExpressionElements(expression: QtiProcessingExpression): number {
|
|
681
|
+
if (expression.type === "baseValue") return 1;
|
|
682
|
+
if (expression.type === "multiple" || expression.type === "ordered") {
|
|
683
|
+
return minimumProducedElements(expression.expressions);
|
|
684
|
+
}
|
|
685
|
+
if (expression.type === "random") {
|
|
686
|
+
if (expression.values.length === 0) return 0;
|
|
687
|
+
let minimum = MAX_QTI_REPEAT_RESULT_ELEMENTS + 1;
|
|
688
|
+
for (const value of expression.values) {
|
|
689
|
+
minimum = Math.min(minimum, minimumExpressionElements(value));
|
|
690
|
+
}
|
|
691
|
+
return minimum;
|
|
692
|
+
}
|
|
693
|
+
if (expression.type !== "repeat" || !/^\d+$/.test(expression.numberRepeats)) return 0;
|
|
694
|
+
const perRepeat = minimumProducedElements(expression.expressions);
|
|
695
|
+
const projected = BigInt(expression.numberRepeats) * BigInt(perRepeat);
|
|
696
|
+
return projected > BigInt(MAX_QTI_REPEAT_RESULT_ELEMENTS)
|
|
697
|
+
? MAX_QTI_REPEAT_RESULT_ELEMENTS + 1
|
|
698
|
+
: Number(projected);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function cappedElementCount(value: number): number {
|
|
702
|
+
return Math.min(value, MAX_QTI_REPEAT_RESULT_ELEMENTS + 1);
|
|
703
|
+
}
|
|
704
|
+
|
|
561
705
|
function validateInsideExpression(
|
|
562
706
|
expression: Extract<QtiProcessingExpression, { type: "inside" }>,
|
|
563
707
|
diagnostics: QtiDiagnostic[],
|
package/src/validation.ts
CHANGED
|
@@ -16,11 +16,10 @@ import { assertNever } from "./assert-never.js";
|
|
|
16
16
|
import { validateQtiDataSsmlMetadata } from "./tts.js";
|
|
17
17
|
import { validateCompanionMaterials } from "./validation-companion-materials.js";
|
|
18
18
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "./validation-geometry.js";
|
|
19
|
+
validateAreaMapEntryAttributes,
|
|
20
|
+
validateLookupTableEntryAttributes,
|
|
21
|
+
validateMapEntryAttributes,
|
|
22
|
+
} from "./validation-declaration-entries.js";
|
|
24
23
|
import {
|
|
25
24
|
isBaseType,
|
|
26
25
|
isBooleanAttribute,
|
|
@@ -237,7 +236,8 @@ function isValidDeclarationBaseValue(value: string, baseType: QtiBaseType): bool
|
|
|
237
236
|
function validateMapping(declaration: QtiResponseDeclaration, diagnostics: QtiDiagnostic[]): void {
|
|
238
237
|
const mapping = declaration.mapping;
|
|
239
238
|
if (!mapping) return;
|
|
240
|
-
|
|
239
|
+
const defaultValue = mapping.attributes["default-value"];
|
|
240
|
+
if (defaultValue !== undefined && !isFiniteNumber(defaultValue)) {
|
|
241
241
|
diagnostics.push({
|
|
242
242
|
code: "mapping.defaultValue",
|
|
243
243
|
severity: "error",
|
|
@@ -249,42 +249,7 @@ function validateMapping(declaration: QtiResponseDeclaration, diagnostics: QtiDi
|
|
|
249
249
|
validateMappingBounds(declaration, mapping.attributes, mapping.source, "mapping", diagnostics);
|
|
250
250
|
|
|
251
251
|
for (const entry of mapping.entries) {
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function validateMapEntry(
|
|
257
|
-
declaration: QtiResponseDeclaration,
|
|
258
|
-
entry: NonNullable<QtiResponseDeclaration["mapping"]>["entries"][number],
|
|
259
|
-
diagnostics: QtiDiagnostic[],
|
|
260
|
-
): void {
|
|
261
|
-
if (!entry.attributes["map-key"]) {
|
|
262
|
-
diagnostics.push({
|
|
263
|
-
code: "mapEntry.mapKey.required",
|
|
264
|
-
severity: "error",
|
|
265
|
-
message: `Response declaration ${declaration.identifier} map entry requires map-key.`,
|
|
266
|
-
path: entry.source?.path,
|
|
267
|
-
source: entry.source,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const mappedValue = entry.attributes["mapped-value"];
|
|
272
|
-
if (mappedValue === undefined) {
|
|
273
|
-
diagnostics.push({
|
|
274
|
-
code: "mapEntry.mappedValue.required",
|
|
275
|
-
severity: "error",
|
|
276
|
-
message: `Response declaration ${declaration.identifier} map entry requires mapped-value.`,
|
|
277
|
-
path: entry.source?.path,
|
|
278
|
-
source: entry.source,
|
|
279
|
-
});
|
|
280
|
-
} else if (!isFiniteNumber(mappedValue)) {
|
|
281
|
-
diagnostics.push({
|
|
282
|
-
code: "mapEntry.mappedValue",
|
|
283
|
-
severity: "error",
|
|
284
|
-
message: `Response declaration ${declaration.identifier} map entry requires numeric mapped-value.`,
|
|
285
|
-
path: entry.source?.path,
|
|
286
|
-
source: entry.source,
|
|
287
|
-
});
|
|
252
|
+
validateMapEntryAttributes(declaration.identifier, entry, diagnostics);
|
|
288
253
|
}
|
|
289
254
|
}
|
|
290
255
|
|
|
@@ -294,7 +259,8 @@ function validateAreaMapping(
|
|
|
294
259
|
): void {
|
|
295
260
|
const areaMapping = declaration.areaMapping;
|
|
296
261
|
if (!areaMapping) return;
|
|
297
|
-
|
|
262
|
+
const defaultValue = areaMapping.attributes["default-value"];
|
|
263
|
+
if (defaultValue !== undefined && !isFiniteNumber(defaultValue)) {
|
|
298
264
|
diagnostics.push({
|
|
299
265
|
code: "areaMapping.defaultValue",
|
|
300
266
|
severity: "error",
|
|
@@ -312,7 +278,7 @@ function validateAreaMapping(
|
|
|
312
278
|
);
|
|
313
279
|
|
|
314
280
|
for (const entry of areaMapping.entries) {
|
|
315
|
-
|
|
281
|
+
validateAreaMapEntryAttributes(declaration.identifier, entry, diagnostics);
|
|
316
282
|
}
|
|
317
283
|
}
|
|
318
284
|
|
|
@@ -360,82 +326,6 @@ function validateMappingBounds(
|
|
|
360
326
|
}
|
|
361
327
|
}
|
|
362
328
|
|
|
363
|
-
function validateAreaMapEntry(
|
|
364
|
-
declaration: QtiResponseDeclaration,
|
|
365
|
-
entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
|
|
366
|
-
diagnostics: QtiDiagnostic[],
|
|
367
|
-
): void {
|
|
368
|
-
const shape = entry.attributes.shape;
|
|
369
|
-
const coords = entry.attributes.coords;
|
|
370
|
-
const mappedValue = entry.attributes["mapped-value"];
|
|
371
|
-
|
|
372
|
-
if (!shape) {
|
|
373
|
-
diagnostics.push({
|
|
374
|
-
code: "areaMapEntry.shape.required",
|
|
375
|
-
severity: "error",
|
|
376
|
-
message: `Response declaration ${declaration.identifier} area map entry requires shape.`,
|
|
377
|
-
path: entry.source?.path,
|
|
378
|
-
source: entry.source,
|
|
379
|
-
});
|
|
380
|
-
} else if (!isAreaShape(shape)) {
|
|
381
|
-
diagnostics.push({
|
|
382
|
-
code: "areaMapEntry.shape",
|
|
383
|
-
severity: "error",
|
|
384
|
-
message: `Response declaration ${declaration.identifier} area map entry has unsupported shape ${shape}.`,
|
|
385
|
-
path: entry.source?.path,
|
|
386
|
-
source: entry.source,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (!coords) {
|
|
391
|
-
diagnostics.push({
|
|
392
|
-
code: "areaMapEntry.coords.required",
|
|
393
|
-
severity: "error",
|
|
394
|
-
message: `Response declaration ${declaration.identifier} area map entry requires coords.`,
|
|
395
|
-
path: entry.source?.path,
|
|
396
|
-
source: entry.source,
|
|
397
|
-
});
|
|
398
|
-
} else if (!isNumericCsv(coords)) {
|
|
399
|
-
diagnostics.push({
|
|
400
|
-
code: "areaMapEntry.coords",
|
|
401
|
-
severity: "error",
|
|
402
|
-
message: `Response declaration ${declaration.identifier} area map entry requires comma-separated numeric coords.`,
|
|
403
|
-
path: entry.source?.path,
|
|
404
|
-
source: entry.source,
|
|
405
|
-
});
|
|
406
|
-
} else if (
|
|
407
|
-
shape &&
|
|
408
|
-
isAreaShape(shape) &&
|
|
409
|
-
!hasValidShapeCoordinateCount(shape, numericCsv(coords))
|
|
410
|
-
) {
|
|
411
|
-
diagnostics.push({
|
|
412
|
-
code: "areaMapEntry.coords.shape",
|
|
413
|
-
severity: "error",
|
|
414
|
-
message: `Response declaration ${declaration.identifier} area map entry shape ${shape} has invalid coords arity.`,
|
|
415
|
-
path: entry.source?.path,
|
|
416
|
-
source: entry.source,
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (mappedValue === undefined) {
|
|
421
|
-
diagnostics.push({
|
|
422
|
-
code: "areaMapEntry.mappedValue.required",
|
|
423
|
-
severity: "error",
|
|
424
|
-
message: `Response declaration ${declaration.identifier} area map entry requires mapped-value.`,
|
|
425
|
-
path: entry.source?.path,
|
|
426
|
-
source: entry.source,
|
|
427
|
-
});
|
|
428
|
-
} else if (!isFiniteNumber(mappedValue)) {
|
|
429
|
-
diagnostics.push({
|
|
430
|
-
code: "areaMapEntry.mappedValue",
|
|
431
|
-
severity: "error",
|
|
432
|
-
message: `Response declaration ${declaration.identifier} area map entry requires numeric mapped-value.`,
|
|
433
|
-
path: entry.source?.path,
|
|
434
|
-
source: entry.source,
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
329
|
function validateDeclarationRequiredAttributes(
|
|
440
330
|
declaration: QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration,
|
|
441
331
|
diagnostics: QtiDiagnostic[],
|
|
@@ -502,37 +392,7 @@ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDi
|
|
|
502
392
|
});
|
|
503
393
|
}
|
|
504
394
|
for (const entry of lookupTable.entries) {
|
|
505
|
-
|
|
506
|
-
diagnostics.push({
|
|
507
|
-
code: "lookupTable.entry.sourceValue",
|
|
508
|
-
severity: "error",
|
|
509
|
-
message: "Lookup table entry requires numeric source-value.",
|
|
510
|
-
path: entry.source?.path,
|
|
511
|
-
source: entry.source,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
if (entry.attributes["target-value"] === undefined) {
|
|
515
|
-
diagnostics.push({
|
|
516
|
-
code: "lookupTable.entry.targetValue",
|
|
517
|
-
severity: "error",
|
|
518
|
-
message: "Lookup table entry requires target-value.",
|
|
519
|
-
path: entry.source?.path,
|
|
520
|
-
source: entry.source,
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
if (
|
|
524
|
-
lookupTable.type === "match" &&
|
|
525
|
-
entry.attributes["source-value"] !== undefined &&
|
|
526
|
-
!isInteger(entry.attributes["source-value"])
|
|
527
|
-
) {
|
|
528
|
-
diagnostics.push({
|
|
529
|
-
code: "lookupTable.match.sourceValue",
|
|
530
|
-
severity: "error",
|
|
531
|
-
message: "qti-match-table-entry source-value must be an integer.",
|
|
532
|
-
path: entry.source?.path,
|
|
533
|
-
source: entry.source,
|
|
534
|
-
});
|
|
535
|
-
}
|
|
395
|
+
validateLookupTableEntryAttributes(lookupTable.type, entry, diagnostics);
|
|
536
396
|
}
|
|
537
397
|
}
|
|
538
398
|
}
|
package/src/xml.ts
CHANGED
|
@@ -190,10 +190,9 @@ export function descendants(node: XmlNode, predicate: (node: XmlNode) => boolean
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
export function textContent(node: XmlNode): string {
|
|
193
|
-
|
|
194
|
-
typeof entry === "string" ? entry : textContent(entry)
|
|
195
|
-
|
|
196
|
-
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
193
|
+
return node.content
|
|
194
|
+
.map((entry) => (typeof entry === "string" ? entry : textContent(entry)))
|
|
195
|
+
.join("");
|
|
197
196
|
}
|
|
198
197
|
|
|
199
198
|
function openElement(
|
|
@@ -212,12 +211,21 @@ function openElement(
|
|
|
212
211
|
const attributes: Record<string, string> = {};
|
|
213
212
|
const inheritedNamespaces = state.namespaceStack.at(-1)?.namespaces ?? {};
|
|
214
213
|
const namespaces: Record<string, string> = { ...inheritedNamespaces };
|
|
214
|
+
const uniqueAttributes: ParsedAttribute[] = [];
|
|
215
|
+
const qualifiedAttributeNames = new Set<string>();
|
|
215
216
|
for (const attribute of parsedAttributes) {
|
|
217
|
+
if (qualifiedAttributeNames.has(attribute.name)) {
|
|
218
|
+
state.errors.push(new Error(`Duplicate XML attribute ${attribute.name}.`));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
qualifiedAttributeNames.add(attribute.name);
|
|
222
|
+
uniqueAttributes.push(attribute);
|
|
216
223
|
attributes[attribute.name] = attribute.value;
|
|
217
224
|
if (attribute.name === "xmlns") namespaces[""] = attribute.value;
|
|
218
225
|
else if (attribute.name.startsWith("xmlns:"))
|
|
219
226
|
namespaces[attribute.name.slice(6)] = attribute.value;
|
|
220
227
|
}
|
|
228
|
+
validateUniqueExpandedAttributeNames(uniqueAttributes, namespaces, state);
|
|
221
229
|
|
|
222
230
|
const { prefix, localName } = splitQualifiedName(name);
|
|
223
231
|
const path = nodePath(parent, localName);
|
|
@@ -498,6 +506,34 @@ function splitQualifiedName(name: string): {
|
|
|
498
506
|
};
|
|
499
507
|
}
|
|
500
508
|
|
|
509
|
+
function validateUniqueExpandedAttributeNames(
|
|
510
|
+
attributes: readonly ParsedAttribute[],
|
|
511
|
+
namespaces: Readonly<Record<string, string>>,
|
|
512
|
+
state: ParserState,
|
|
513
|
+
): void {
|
|
514
|
+
const expandedNames = new Map<string, string>();
|
|
515
|
+
for (const attribute of attributes) {
|
|
516
|
+
if (attribute.name === "xmlns" || attribute.name.startsWith("xmlns:")) continue;
|
|
517
|
+
|
|
518
|
+
const { prefix, localName } = splitQualifiedName(attribute.name);
|
|
519
|
+
const namespaceUri = prefix ? namespaces[prefix] : "";
|
|
520
|
+
if (namespaceUri === undefined) continue;
|
|
521
|
+
|
|
522
|
+
const expandedName = `${namespaceUri}\u0000${localName}`;
|
|
523
|
+
const previousName = expandedNames.get(expandedName);
|
|
524
|
+
if (previousName) {
|
|
525
|
+
const displayName = namespaceUri ? `{${namespaceUri}}${localName}` : localName;
|
|
526
|
+
state.errors.push(
|
|
527
|
+
new Error(
|
|
528
|
+
`XML attributes ${previousName} and ${attribute.name} have duplicate expanded name ${displayName}.`,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
expandedNames.set(expandedName, attribute.name);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
501
537
|
function buildLineStarts(xml: string): number[] {
|
|
502
538
|
const lineStarts = [0];
|
|
503
539
|
for (let index = 0; index < xml.length; index += 1) {
|