@longsightgroup/qti3-core 0.1.1 → 0.1.2

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.
@@ -0,0 +1,2228 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiBaseType,
4
+ QtiCatalogCard,
5
+ QtiCatalogCardEntry,
6
+ QtiCardinality,
7
+ QtiChoice,
8
+ QtiContentNode,
9
+ QtiDiagnostic,
10
+ QtiDocument,
11
+ QtiInteraction,
12
+ QtiOutcomeDeclaration,
13
+ QtiProcessingExpression,
14
+ QtiResponseCondition,
15
+ QtiResponseDeclaration,
16
+ QtiResponseRule,
17
+ QtiSetOutcomeValue,
18
+ QtiTemplateDeclaration,
19
+ QtiTemplateRule,
20
+ QtiValue,
21
+ QtiValidationResult,
22
+ } from "./types.js";
23
+
24
+ const BUILT_IN_COMPLETION_STATUS = "completionStatus";
25
+
26
+ export function validateAssessmentItem(document: QtiDocument): QtiValidationResult {
27
+ const diagnostics: QtiDiagnostic[] = [];
28
+ const item = document.item;
29
+
30
+ requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
31
+ validateDeclarationIdentifiers(item, diagnostics);
32
+ validateOutcomeLookupTables(item, diagnostics);
33
+ validateInteractions(item, diagnostics);
34
+ validateModalFeedback(item, diagnostics);
35
+ validateCatalogInfo(item, diagnostics);
36
+ validateStylesheets(item, diagnostics);
37
+ validateProcessingReferences(item, diagnostics);
38
+
39
+ return {
40
+ ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
41
+ diagnostics,
42
+ };
43
+ }
44
+
45
+ function requireIdentifier(
46
+ elementName: string,
47
+ identifier: string | undefined,
48
+ diagnostics: QtiDiagnostic[],
49
+ source?: QtiDiagnostic["source"],
50
+ ): void {
51
+ if (!identifier || identifier.trim().length === 0) {
52
+ diagnostics.push({
53
+ code: "identifier.required",
54
+ severity: "error",
55
+ message: `${elementName} requires a non-empty identifier.`,
56
+ path: source?.path,
57
+ source,
58
+ });
59
+ }
60
+ }
61
+
62
+ function validateDeclarationIdentifiers(
63
+ item: QtiAssessmentItem,
64
+ diagnostics: QtiDiagnostic[],
65
+ ): void {
66
+ const seen = new Set<string>();
67
+ for (const declaration of [
68
+ ...item.responseDeclarations,
69
+ ...item.outcomeDeclarations,
70
+ ...item.templateDeclarations,
71
+ ]) {
72
+ requireIdentifier(
73
+ `${declaration.kind} declaration`,
74
+ declaration.identifier,
75
+ diagnostics,
76
+ declaration.source,
77
+ );
78
+ validateDeclarationRequiredAttributes(declaration, diagnostics);
79
+ validateDeclarationValueMetadata(declaration, diagnostics);
80
+ if (declaration.kind === "outcome" && declaration.identifier === BUILT_IN_COMPLETION_STATUS) {
81
+ diagnostics.push({
82
+ code: "declaration.outcome.builtIn",
83
+ severity: "error",
84
+ message:
85
+ "completionStatus is a built-in QTI outcome variable and must not be declared explicitly.",
86
+ path: declaration.source?.path,
87
+ source: declaration.source,
88
+ });
89
+ }
90
+ if (seen.has(declaration.identifier)) {
91
+ diagnostics.push({
92
+ code: "identifier.duplicate",
93
+ severity: "error",
94
+ message: `Duplicate declaration identifier ${declaration.identifier}.`,
95
+ path: declaration.source?.path,
96
+ source: declaration.source,
97
+ });
98
+ }
99
+ seen.add(declaration.identifier);
100
+ }
101
+ }
102
+
103
+ function validateDeclarationValueMetadata(
104
+ declaration: QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration,
105
+ diagnostics: QtiDiagnostic[],
106
+ ): void {
107
+ validateDeclarationValue(declaration, declaration.defaultValue, "defaultValue", diagnostics);
108
+ if (declaration.kind === "response") {
109
+ validateDeclarationValue(
110
+ declaration,
111
+ declaration.correctResponse,
112
+ "correctResponse",
113
+ diagnostics,
114
+ );
115
+ }
116
+ if (declaration.kind !== "response") return;
117
+ validateMapping(declaration, diagnostics);
118
+ validateAreaMapping(declaration, diagnostics);
119
+ }
120
+
121
+ function validateDeclarationValue(
122
+ declaration: QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration,
123
+ value: QtiValue,
124
+ role: "defaultValue" | "correctResponse",
125
+ diagnostics: QtiDiagnostic[],
126
+ ): void {
127
+ if (value === null || declaration.cardinality === "record" || !declaration.baseType) return;
128
+ if (!isBaseType(declaration.baseType)) return;
129
+
130
+ if (declaration.cardinality === "single" && Array.isArray(value)) {
131
+ const severity =
132
+ declaration.kind === "response" &&
133
+ role === "correctResponse" &&
134
+ declaration.baseType === "string"
135
+ ? "warning"
136
+ : "error";
137
+ diagnostics.push({
138
+ code: `declaration.${role}.cardinality`,
139
+ severity,
140
+ message: `${declaration.kind} declaration ${declaration.identifier} ${role} must contain one value for single cardinality.`,
141
+ path: declaration.source?.path,
142
+ source: declaration.source,
143
+ });
144
+ return;
145
+ }
146
+
147
+ for (const entry of declarationValueEntries(value)) {
148
+ if (isValidDeclarationBaseValue(entry, declaration.baseType)) continue;
149
+ diagnostics.push({
150
+ code: `declaration.${role}.baseType`,
151
+ severity: "error",
152
+ message: `${declaration.kind} declaration ${declaration.identifier} ${role} value ${entry} is not valid for base-type ${declaration.baseType}.`,
153
+ path: declaration.source?.path,
154
+ source: declaration.source,
155
+ });
156
+ }
157
+ }
158
+
159
+ function declarationValueEntries(value: QtiValue): string[] {
160
+ if (value === null) return [];
161
+ if (Array.isArray(value)) return value.map(String);
162
+ if (typeof value === "object") return Object.values(value).flatMap(declarationValueEntries);
163
+ return [String(value)];
164
+ }
165
+
166
+ function isValidDeclarationBaseValue(value: string, baseType: QtiBaseType): boolean {
167
+ switch (baseType) {
168
+ case "integer":
169
+ return isInteger(value);
170
+ case "float":
171
+ return isFiniteNumber(value);
172
+ case "boolean":
173
+ return value === "true" || value === "false";
174
+ case "point":
175
+ return isPoint(value);
176
+ case "pair":
177
+ case "directedPair":
178
+ return isPair(value);
179
+ case "identifier":
180
+ return value.trim().length > 0 && !/\s/.test(value);
181
+ case "string":
182
+ case "duration":
183
+ case "file":
184
+ case "uri":
185
+ return true;
186
+ }
187
+ }
188
+
189
+ function validateMapping(declaration: QtiResponseDeclaration, diagnostics: QtiDiagnostic[]): void {
190
+ const mapping = declaration.mapping;
191
+ if (!mapping) return;
192
+ if (!Number.isFinite(mapping.defaultValue)) {
193
+ diagnostics.push({
194
+ code: "mapping.defaultValue",
195
+ severity: "error",
196
+ message: `Response declaration ${declaration.identifier} mapping requires numeric default-value.`,
197
+ path: declaration.source?.path,
198
+ source: declaration.source,
199
+ });
200
+ }
201
+ validateMappingBounds(declaration, mapping.attributes, mapping.source, "mapping", diagnostics);
202
+
203
+ for (const entry of mapping.entries) {
204
+ validateMapEntry(declaration, entry, diagnostics);
205
+ }
206
+ }
207
+
208
+ function validateMapEntry(
209
+ declaration: QtiResponseDeclaration,
210
+ entry: NonNullable<QtiResponseDeclaration["mapping"]>["entries"][number],
211
+ diagnostics: QtiDiagnostic[],
212
+ ): void {
213
+ if (!entry.attributes["map-key"]) {
214
+ diagnostics.push({
215
+ code: "mapEntry.mapKey.required",
216
+ severity: "error",
217
+ message: `Response declaration ${declaration.identifier} map entry requires map-key.`,
218
+ path: entry.source?.path,
219
+ source: entry.source,
220
+ });
221
+ }
222
+
223
+ const mappedValue = entry.attributes["mapped-value"];
224
+ if (mappedValue === undefined) {
225
+ diagnostics.push({
226
+ code: "mapEntry.mappedValue.required",
227
+ severity: "error",
228
+ message: `Response declaration ${declaration.identifier} map entry requires mapped-value.`,
229
+ path: entry.source?.path,
230
+ source: entry.source,
231
+ });
232
+ } else if (!isFiniteNumber(mappedValue)) {
233
+ diagnostics.push({
234
+ code: "mapEntry.mappedValue",
235
+ severity: "error",
236
+ message: `Response declaration ${declaration.identifier} map entry requires numeric mapped-value.`,
237
+ path: entry.source?.path,
238
+ source: entry.source,
239
+ });
240
+ }
241
+ }
242
+
243
+ function validateAreaMapping(
244
+ declaration: QtiResponseDeclaration,
245
+ diagnostics: QtiDiagnostic[],
246
+ ): void {
247
+ const areaMapping = declaration.areaMapping;
248
+ if (!areaMapping) return;
249
+ if (!Number.isFinite(areaMapping.defaultValue)) {
250
+ diagnostics.push({
251
+ code: "areaMapping.defaultValue",
252
+ severity: "error",
253
+ message: `Response declaration ${declaration.identifier} area mapping requires numeric default-value.`,
254
+ path: declaration.source?.path,
255
+ source: declaration.source,
256
+ });
257
+ }
258
+ validateMappingBounds(
259
+ declaration,
260
+ areaMapping.attributes,
261
+ areaMapping.source,
262
+ "areaMapping",
263
+ diagnostics,
264
+ );
265
+
266
+ for (const entry of areaMapping.entries) {
267
+ validateAreaMapEntry(declaration, entry, diagnostics);
268
+ }
269
+ }
270
+
271
+ function validateMappingBounds(
272
+ declaration: QtiResponseDeclaration,
273
+ attributes: Record<string, string>,
274
+ source: QtiDiagnostic["source"],
275
+ codePrefix: "mapping" | "areaMapping",
276
+ diagnostics: QtiDiagnostic[],
277
+ ): void {
278
+ const lower = attributes["lower-bound"];
279
+ const upper = attributes["upper-bound"];
280
+ if (lower !== undefined && !isFiniteNumber(lower)) {
281
+ diagnostics.push({
282
+ code: `${codePrefix}.lowerBound`,
283
+ severity: "error",
284
+ message: `Response declaration ${declaration.identifier} ${codePrefix} requires numeric lower-bound.`,
285
+ path: source?.path ?? declaration.source?.path,
286
+ source: source ?? declaration.source,
287
+ });
288
+ }
289
+ if (upper !== undefined && !isFiniteNumber(upper)) {
290
+ diagnostics.push({
291
+ code: `${codePrefix}.upperBound`,
292
+ severity: "error",
293
+ message: `Response declaration ${declaration.identifier} ${codePrefix} requires numeric upper-bound.`,
294
+ path: source?.path ?? declaration.source?.path,
295
+ source: source ?? declaration.source,
296
+ });
297
+ }
298
+ if (
299
+ lower !== undefined &&
300
+ upper !== undefined &&
301
+ isFiniteNumber(lower) &&
302
+ isFiniteNumber(upper) &&
303
+ Number(lower) > Number(upper)
304
+ ) {
305
+ diagnostics.push({
306
+ code: `${codePrefix}.bounds`,
307
+ severity: "error",
308
+ message: `Response declaration ${declaration.identifier} ${codePrefix} requires lower-bound to be less than or equal to upper-bound.`,
309
+ path: source?.path ?? declaration.source?.path,
310
+ source: source ?? declaration.source,
311
+ });
312
+ }
313
+ }
314
+
315
+ function validateAreaMapEntry(
316
+ declaration: QtiResponseDeclaration,
317
+ entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
318
+ diagnostics: QtiDiagnostic[],
319
+ ): void {
320
+ const shape = entry.attributes.shape;
321
+ const coords = entry.attributes.coords;
322
+ const mappedValue = entry.attributes["mapped-value"];
323
+
324
+ if (!shape) {
325
+ diagnostics.push({
326
+ code: "areaMapEntry.shape.required",
327
+ severity: "error",
328
+ message: `Response declaration ${declaration.identifier} area map entry requires shape.`,
329
+ path: entry.source?.path,
330
+ source: entry.source,
331
+ });
332
+ } else if (!isAreaShape(shape)) {
333
+ diagnostics.push({
334
+ code: "areaMapEntry.shape",
335
+ severity: "error",
336
+ message: `Response declaration ${declaration.identifier} area map entry has unsupported shape ${shape}.`,
337
+ path: entry.source?.path,
338
+ source: entry.source,
339
+ });
340
+ }
341
+
342
+ if (!coords) {
343
+ diagnostics.push({
344
+ code: "areaMapEntry.coords.required",
345
+ severity: "error",
346
+ message: `Response declaration ${declaration.identifier} area map entry requires coords.`,
347
+ path: entry.source?.path,
348
+ source: entry.source,
349
+ });
350
+ } else if (!isNumericCsv(coords)) {
351
+ diagnostics.push({
352
+ code: "areaMapEntry.coords",
353
+ severity: "error",
354
+ message: `Response declaration ${declaration.identifier} area map entry requires comma-separated numeric coords.`,
355
+ path: entry.source?.path,
356
+ source: entry.source,
357
+ });
358
+ } else if (
359
+ shape &&
360
+ isAreaShape(shape) &&
361
+ !hasValidShapeCoordinateCount(shape, numericCsv(coords))
362
+ ) {
363
+ diagnostics.push({
364
+ code: "areaMapEntry.coords.shape",
365
+ severity: "error",
366
+ message: `Response declaration ${declaration.identifier} area map entry shape ${shape} has invalid coords arity.`,
367
+ path: entry.source?.path,
368
+ source: entry.source,
369
+ });
370
+ }
371
+
372
+ if (mappedValue === undefined) {
373
+ diagnostics.push({
374
+ code: "areaMapEntry.mappedValue.required",
375
+ severity: "error",
376
+ message: `Response declaration ${declaration.identifier} area map entry requires mapped-value.`,
377
+ path: entry.source?.path,
378
+ source: entry.source,
379
+ });
380
+ } else if (!isFiniteNumber(mappedValue)) {
381
+ diagnostics.push({
382
+ code: "areaMapEntry.mappedValue",
383
+ severity: "error",
384
+ message: `Response declaration ${declaration.identifier} area map entry requires numeric mapped-value.`,
385
+ path: entry.source?.path,
386
+ source: entry.source,
387
+ });
388
+ }
389
+ }
390
+
391
+ function validateDeclarationRequiredAttributes(
392
+ declaration: QtiResponseDeclaration | QtiOutcomeDeclaration | QtiTemplateDeclaration,
393
+ diagnostics: QtiDiagnostic[],
394
+ ): void {
395
+ if (!declaration.attributes.cardinality) {
396
+ diagnostics.push({
397
+ code: "declaration.cardinality.required",
398
+ severity: "error",
399
+ message: `${declaration.kind} declaration ${declaration.identifier || "(missing identifier)"} requires cardinality.`,
400
+ path: declaration.source?.path,
401
+ source: declaration.source,
402
+ });
403
+ } else if (!isCardinality(declaration.attributes.cardinality)) {
404
+ diagnostics.push({
405
+ code: "declaration.cardinality",
406
+ severity: "error",
407
+ message: `${declaration.kind} declaration ${declaration.identifier || "(missing identifier)"} has unsupported cardinality ${declaration.attributes.cardinality}.`,
408
+ path: declaration.source?.path,
409
+ source: declaration.source,
410
+ });
411
+ }
412
+
413
+ if (declaration.cardinality === "record") return;
414
+
415
+ if (!declaration.attributes["base-type"]) {
416
+ diagnostics.push({
417
+ code: "declaration.baseType.required",
418
+ severity: "error",
419
+ message: `${declaration.kind} declaration ${declaration.identifier || "(missing identifier)"} requires base-type unless cardinality is record.`,
420
+ path: declaration.source?.path,
421
+ source: declaration.source,
422
+ });
423
+ } else if (!isBaseType(declaration.attributes["base-type"])) {
424
+ diagnostics.push({
425
+ code: "declaration.baseType",
426
+ severity: "error",
427
+ message: `${declaration.kind} declaration ${declaration.identifier || "(missing identifier)"} has unsupported base-type ${declaration.attributes["base-type"]}.`,
428
+ path: declaration.source?.path,
429
+ source: declaration.source,
430
+ });
431
+ }
432
+ }
433
+
434
+ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
435
+ for (const outcome of item.outcomeDeclarations) {
436
+ const lookupTable = outcome.lookupTable;
437
+ if (!lookupTable) continue;
438
+ if (outcome.cardinality !== "single") {
439
+ diagnostics.push({
440
+ code: "lookupTable.outcome.cardinality",
441
+ severity: "error",
442
+ message: `Outcome declaration ${outcome.identifier} lookup table requires single cardinality.`,
443
+ path: lookupTable.source?.path,
444
+ source: lookupTable.source,
445
+ });
446
+ }
447
+ if (lookupTable.entries.length === 0) {
448
+ diagnostics.push({
449
+ code: "lookupTable.entries.required",
450
+ severity: "error",
451
+ message: `${lookupTable.type} lookup table requires at least one entry.`,
452
+ path: lookupTable.source?.path,
453
+ source: lookupTable.source,
454
+ });
455
+ }
456
+ for (const entry of lookupTable.entries) {
457
+ if (!isFiniteNumber(entry.attributes["source-value"] ?? "")) {
458
+ diagnostics.push({
459
+ code: "lookupTable.entry.sourceValue",
460
+ severity: "error",
461
+ message: "Lookup table entry requires numeric source-value.",
462
+ path: entry.source?.path,
463
+ source: entry.source,
464
+ });
465
+ }
466
+ if (entry.attributes["target-value"] === undefined) {
467
+ diagnostics.push({
468
+ code: "lookupTable.entry.targetValue",
469
+ severity: "error",
470
+ message: "Lookup table entry requires target-value.",
471
+ path: entry.source?.path,
472
+ source: entry.source,
473
+ });
474
+ }
475
+ if (
476
+ lookupTable.type === "match" &&
477
+ entry.attributes["source-value"] !== undefined &&
478
+ !isInteger(entry.attributes["source-value"])
479
+ ) {
480
+ diagnostics.push({
481
+ code: "lookupTable.match.sourceValue",
482
+ severity: "error",
483
+ message: "qti-match-table-entry source-value must be an integer.",
484
+ path: entry.source?.path,
485
+ source: entry.source,
486
+ });
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ function validateStylesheets(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
493
+ for (const stylesheet of item.stylesheets) {
494
+ if (stylesheet.href.trim().length > 0) continue;
495
+ diagnostics.push({
496
+ code: "stylesheet.href.required",
497
+ severity: "error",
498
+ message: "qti-stylesheet requires a non-empty href attribute.",
499
+ path: stylesheet.source?.path,
500
+ source: stylesheet.source,
501
+ });
502
+ }
503
+ }
504
+
505
+ function validateCatalogInfo(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
506
+ const catalogInfo = item.catalogInfo;
507
+ if (!catalogInfo) {
508
+ for (const reference of item.catalogReferences) {
509
+ diagnostics.push({
510
+ code: "catalog.idref.reference",
511
+ severity: "error",
512
+ message: `data-catalog-idref references missing qti-catalog id ${reference.idref}.`,
513
+ path: reference.source?.path,
514
+ source: reference.source,
515
+ });
516
+ }
517
+ return;
518
+ }
519
+
520
+ const catalogIds = new Set<string>();
521
+ for (const catalog of catalogInfo.catalogs) {
522
+ requireIdentifier("qti-catalog", catalog.id, diagnostics, catalog.source);
523
+ if (catalog.id && catalogIds.has(catalog.id)) {
524
+ diagnostics.push({
525
+ code: "catalog.id.duplicate",
526
+ severity: "error",
527
+ message: `Duplicate qti-catalog id ${catalog.id}.`,
528
+ path: catalog.source?.path,
529
+ source: catalog.source,
530
+ });
531
+ }
532
+ catalogIds.add(catalog.id);
533
+
534
+ if (catalog.cards.length === 0) {
535
+ diagnostics.push({
536
+ code: "catalog.card.required",
537
+ severity: "error",
538
+ message: `qti-catalog ${catalog.id || "(missing id)"} requires at least one qti-card.`,
539
+ path: catalog.source?.path,
540
+ source: catalog.source,
541
+ });
542
+ }
543
+
544
+ const supports = new Set<string>();
545
+ for (const card of catalog.cards) {
546
+ validateCatalogCard(card, diagnostics);
547
+ if (card.support && supports.has(card.support)) {
548
+ diagnostics.push({
549
+ code: "catalog.card.support.duplicate",
550
+ severity: "error",
551
+ message: `qti-catalog ${catalog.id || "(missing id)"} contains duplicate card support ${card.support}.`,
552
+ path: card.source?.path,
553
+ source: card.source,
554
+ });
555
+ }
556
+ supports.add(card.support);
557
+ }
558
+ }
559
+
560
+ for (const reference of item.catalogReferences) {
561
+ if (catalogIds.has(reference.idref)) continue;
562
+ diagnostics.push({
563
+ code: "catalog.idref.reference",
564
+ severity: "error",
565
+ message: `data-catalog-idref references missing qti-catalog id ${reference.idref}.`,
566
+ path: reference.source?.path,
567
+ source: reference.source,
568
+ });
569
+ }
570
+ }
571
+
572
+ function validateCatalogCard(
573
+ card: QtiCatalogCard | QtiCatalogCardEntry,
574
+ diagnostics: QtiDiagnostic[],
575
+ ): void {
576
+ if ("support" in card && !card.support) {
577
+ diagnostics.push({
578
+ code: "catalog.card.support.required",
579
+ severity: "error",
580
+ message: "qti-card requires a non-empty support attribute.",
581
+ path: card.source?.path,
582
+ source: card.source,
583
+ });
584
+ }
585
+
586
+ const entries = "entries" in card ? card.entries : [];
587
+ const hasContent = Boolean(card.htmlContent) || card.fileHrefs.length > 0 || entries.length > 0;
588
+ if (!hasContent) {
589
+ diagnostics.push({
590
+ code: "catalog.card.content.required",
591
+ severity: "error",
592
+ message: "qti-card or qti-card-entry requires qti-html-content or qti-file-href content.",
593
+ path: card.source?.path,
594
+ source: card.source,
595
+ });
596
+ }
597
+
598
+ if (card.htmlContent) {
599
+ validateCatalogHtmlContent(card.htmlContent.children, diagnostics);
600
+ }
601
+ for (const fileHref of card.fileHrefs) {
602
+ if (fileHref.href.length > 0) continue;
603
+ diagnostics.push({
604
+ code: "catalog.fileHref.required",
605
+ severity: "error",
606
+ message: "qti-file-href requires a non-empty file reference.",
607
+ path: fileHref.source?.path,
608
+ source: fileHref.source,
609
+ });
610
+ }
611
+ for (const entry of entries) {
612
+ validateCatalogCard(entry, diagnostics);
613
+ }
614
+ }
615
+
616
+ function validateCatalogHtmlContent(nodes: QtiContentNode[], diagnostics: QtiDiagnostic[]): void {
617
+ for (const node of nodes) {
618
+ if (node.kind === "element") {
619
+ if (node.qtiName.startsWith("qti-")) {
620
+ diagnostics.push({
621
+ code: "catalog.htmlContent.qtiElement",
622
+ severity: "error",
623
+ message: "qti-html-content must not contain QTI-specific elements.",
624
+ path: node.source?.path,
625
+ source: node.source,
626
+ });
627
+ }
628
+ validateCatalogHtmlContent(node.children, diagnostics);
629
+ }
630
+ }
631
+ }
632
+
633
+ function validateProcessingReferences(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
634
+ const responses = new Set(item.responseDeclarations.map((declaration) => declaration.identifier));
635
+ const outcomes = new Set(item.outcomeDeclarations.map((declaration) => declaration.identifier));
636
+ outcomes.add(BUILT_IN_COMPLETION_STATUS);
637
+ const templates = new Set(item.templateDeclarations.map((declaration) => declaration.identifier));
638
+ const variables = new Set([...responses, ...outcomes, ...templates]);
639
+
640
+ for (const rule of item.templateProcessing?.rules ?? []) {
641
+ validateTemplateRule(rule, responses, outcomes, templates, variables, diagnostics);
642
+ }
643
+
644
+ for (const rule of item.responseProcessing?.rules ?? []) {
645
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
646
+ }
647
+ }
648
+
649
+ function validateResponseCondition(
650
+ condition: QtiResponseCondition,
651
+ outcomes: Set<string>,
652
+ responses: Set<string>,
653
+ variables: Set<string>,
654
+ diagnostics: QtiDiagnostic[],
655
+ ): void {
656
+ validateExpressionReferences(condition.ifExpression, responses, variables, diagnostics);
657
+ for (const rule of condition.thenRules) {
658
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
659
+ }
660
+ for (const branch of condition.elseIfs) {
661
+ validateExpressionReferences(branch.expression, responses, variables, diagnostics);
662
+ for (const rule of branch.rules) {
663
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
664
+ }
665
+ }
666
+ for (const rule of condition.elseRules) {
667
+ validateResponseRule(rule, outcomes, responses, variables, diagnostics);
668
+ }
669
+ }
670
+
671
+ function validateTemplateRule(
672
+ rule: QtiTemplateRule,
673
+ responses: Set<string>,
674
+ outcomes: Set<string>,
675
+ templates: Set<string>,
676
+ variables: Set<string>,
677
+ diagnostics: QtiDiagnostic[],
678
+ ): void {
679
+ if (rule.type === "exitTemplate") return;
680
+ if (rule.type === "templateConstraint") {
681
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
682
+ return;
683
+ }
684
+
685
+ if (rule.type === "templateCondition") {
686
+ validateExpressionReferences(rule.ifExpression, responses, variables, diagnostics);
687
+ for (const branchRule of rule.thenRules) {
688
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
689
+ }
690
+ for (const branch of rule.elseIfs) {
691
+ validateExpressionReferences(branch.expression, responses, variables, diagnostics);
692
+ for (const branchRule of branch.rules) {
693
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
694
+ }
695
+ }
696
+ for (const branchRule of rule.elseRules) {
697
+ validateTemplateRule(branchRule, responses, outcomes, templates, variables, diagnostics);
698
+ }
699
+ return;
700
+ }
701
+
702
+ if (rule.type === "setTemplateValue") {
703
+ validateProcessingIdentifier(
704
+ rule.identifier,
705
+ "processing.templateTarget",
706
+ rule.source,
707
+ diagnostics,
708
+ );
709
+ if (rule.identifier && !templates.has(rule.identifier)) {
710
+ diagnostics.push({
711
+ code: "processing.templateTarget.reference",
712
+ severity: "error",
713
+ message: `qti-set-template-value references missing template declaration ${rule.identifier}.`,
714
+ path: rule.source?.path,
715
+ source: rule.source,
716
+ });
717
+ }
718
+ }
719
+
720
+ if (rule.type === "setDefaultValue") {
721
+ validateProcessingIdentifier(
722
+ rule.identifier,
723
+ "processing.defaultTarget",
724
+ rule.source,
725
+ diagnostics,
726
+ );
727
+ if (rule.identifier && !responses.has(rule.identifier) && !outcomes.has(rule.identifier)) {
728
+ diagnostics.push({
729
+ code: "processing.defaultTarget.reference",
730
+ severity: "error",
731
+ message: `qti-set-default-value references missing response or outcome declaration ${rule.identifier}.`,
732
+ path: rule.source?.path,
733
+ source: rule.source,
734
+ });
735
+ }
736
+ }
737
+
738
+ if (rule.type === "setCorrectResponse") {
739
+ validateProcessingIdentifier(
740
+ rule.identifier,
741
+ "processing.correctResponse",
742
+ rule.source,
743
+ diagnostics,
744
+ );
745
+ if (rule.identifier && !responses.has(rule.identifier)) {
746
+ diagnostics.push({
747
+ code: "processing.correctResponse.reference",
748
+ severity: "error",
749
+ message: `qti-set-correct-response references missing response declaration ${rule.identifier}.`,
750
+ path: rule.source?.path,
751
+ source: rule.source,
752
+ });
753
+ }
754
+ }
755
+
756
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
757
+ }
758
+
759
+ function validateResponseRule(
760
+ rule: QtiResponseRule,
761
+ outcomes: Set<string>,
762
+ responses: Set<string>,
763
+ variables: Set<string>,
764
+ diagnostics: QtiDiagnostic[],
765
+ ): void {
766
+ if (rule.type === "exitResponse") return;
767
+ if (rule.type === "responseCondition") {
768
+ validateResponseCondition(rule.condition, outcomes, responses, variables, diagnostics);
769
+ return;
770
+ }
771
+ if (rule.type === "responseProcessingFragment") {
772
+ for (const childRule of rule.rules) {
773
+ validateResponseRule(childRule, outcomes, responses, variables, diagnostics);
774
+ }
775
+ return;
776
+ }
777
+ if (rule.type === "lookupOutcomeValue") {
778
+ validateLookupOutcomeRule(rule, outcomes, responses, variables, diagnostics);
779
+ return;
780
+ }
781
+ validateSetOutcomeRule(rule, outcomes, responses, variables, diagnostics);
782
+ }
783
+
784
+ function validateLookupOutcomeRule(
785
+ rule: Extract<QtiResponseRule, { type: "lookupOutcomeValue" }>,
786
+ outcomes: Set<string>,
787
+ responses: Set<string>,
788
+ variables: Set<string>,
789
+ diagnostics: QtiDiagnostic[],
790
+ ): void {
791
+ validateProcessingIdentifier(
792
+ rule.identifier,
793
+ "processing.lookupOutcomeTarget",
794
+ rule.source,
795
+ diagnostics,
796
+ );
797
+ if (rule.identifier && !outcomes.has(rule.identifier)) {
798
+ diagnostics.push({
799
+ code: "processing.lookupOutcomeTarget.reference",
800
+ severity: "error",
801
+ message: `qti-lookup-outcome-value references missing outcome declaration ${rule.identifier}.`,
802
+ path: rule.source?.path,
803
+ source: rule.source,
804
+ });
805
+ }
806
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
807
+ }
808
+
809
+ function validateSetOutcomeRule(
810
+ rule: QtiSetOutcomeValue,
811
+ outcomes: Set<string>,
812
+ responses: Set<string>,
813
+ variables: Set<string>,
814
+ diagnostics: QtiDiagnostic[],
815
+ ): void {
816
+ validateProcessingIdentifier(
817
+ rule.identifier,
818
+ "processing.outcomeTarget",
819
+ rule.source,
820
+ diagnostics,
821
+ );
822
+ if (rule.identifier && !outcomes.has(rule.identifier)) {
823
+ diagnostics.push({
824
+ code: "processing.outcomeTarget.reference",
825
+ severity: "error",
826
+ message: `qti-set-outcome-value references missing outcome declaration ${rule.identifier}.`,
827
+ path: rule.source?.path,
828
+ source: rule.source,
829
+ });
830
+ }
831
+ validateExpressionReferences(rule.expression, responses, variables, diagnostics);
832
+ }
833
+
834
+ function validateExpressionReferences(
835
+ expression: QtiProcessingExpression | undefined,
836
+ responses: Set<string>,
837
+ variables: Set<string>,
838
+ diagnostics: QtiDiagnostic[],
839
+ ): void {
840
+ if (!expression) return;
841
+
842
+ if (expression.type === "variable" || expression.type === "isNull") {
843
+ validateProcessingIdentifier(
844
+ expression.identifier,
845
+ "processing.variable",
846
+ expression.source,
847
+ diagnostics,
848
+ );
849
+ if (expression.identifier && !variables.has(expression.identifier)) {
850
+ diagnostics.push({
851
+ code: "processing.variable.reference",
852
+ severity: "error",
853
+ message: `Processing expression references missing variable ${expression.identifier}.`,
854
+ path: expression.source?.path,
855
+ source: expression.source,
856
+ });
857
+ }
858
+ }
859
+
860
+ if (expression.type === "matchCorrect") {
861
+ validateProcessingIdentifier(
862
+ expression.identifier,
863
+ "processing.response",
864
+ expression.source,
865
+ diagnostics,
866
+ );
867
+ if (expression.identifier && !responses.has(expression.identifier)) {
868
+ diagnostics.push({
869
+ code: "processing.response.reference",
870
+ severity: "error",
871
+ message: `Processing expression references missing response declaration ${expression.identifier}.`,
872
+ path: expression.source?.path,
873
+ source: expression.source,
874
+ });
875
+ }
876
+ validateProcessingIdentifier(
877
+ expression.correctIdentifier,
878
+ "processing.correct",
879
+ expression.source,
880
+ diagnostics,
881
+ );
882
+ if (expression.correctIdentifier && !responses.has(expression.correctIdentifier)) {
883
+ diagnostics.push({
884
+ code: "processing.correct.reference",
885
+ severity: "error",
886
+ message: `Processing expression references missing correct response declaration ${expression.correctIdentifier}.`,
887
+ path: expression.source?.path,
888
+ source: expression.source,
889
+ });
890
+ }
891
+ }
892
+
893
+ if (expression.type === "mapResponse" || expression.type === "mapResponsePoint") {
894
+ validateProcessingIdentifier(
895
+ expression.identifier,
896
+ "processing.response",
897
+ expression.source,
898
+ diagnostics,
899
+ );
900
+ if (expression.identifier && !responses.has(expression.identifier)) {
901
+ diagnostics.push({
902
+ code: "processing.response.reference",
903
+ severity: "error",
904
+ message: `Processing expression references missing response declaration ${expression.identifier}.`,
905
+ path: expression.source?.path,
906
+ source: expression.source,
907
+ });
908
+ }
909
+ }
910
+
911
+ if (expression.type === "correct") {
912
+ validateProcessingIdentifier(
913
+ expression.identifier,
914
+ "processing.correct",
915
+ expression.source,
916
+ diagnostics,
917
+ );
918
+ if (expression.identifier && !responses.has(expression.identifier)) {
919
+ diagnostics.push({
920
+ code: "processing.correct.reference",
921
+ severity: "error",
922
+ message: `Processing expression references missing correct response declaration ${expression.identifier}.`,
923
+ path: expression.source?.path,
924
+ source: expression.source,
925
+ });
926
+ }
927
+ }
928
+
929
+ if (expression.type === "default") {
930
+ validateProcessingIdentifier(
931
+ expression.identifier,
932
+ "processing.variable",
933
+ expression.source,
934
+ diagnostics,
935
+ );
936
+ if (expression.identifier && !variables.has(expression.identifier)) {
937
+ diagnostics.push({
938
+ code: "processing.variable.reference",
939
+ severity: "error",
940
+ message: `Processing expression references missing variable ${expression.identifier}.`,
941
+ path: expression.source?.path,
942
+ source: expression.source,
943
+ });
944
+ }
945
+ }
946
+
947
+ if (expression.type === "randomInteger") {
948
+ validateRandomIntegerExpression(expression, diagnostics);
949
+ }
950
+
951
+ if (expression.type === "randomFloat") {
952
+ validateRandomFloatExpression(expression, diagnostics);
953
+ }
954
+
955
+ if (expression.type === "baseValue") {
956
+ validateBaseValueExpression(expression, diagnostics);
957
+ }
958
+
959
+ if (expression.type === "equalRounded") {
960
+ validateRounding(
961
+ "qti-equal-rounded",
962
+ expression.roundingMode,
963
+ expression.figures,
964
+ diagnostics,
965
+ expression.source,
966
+ );
967
+ }
968
+
969
+ if (expression.type === "mathConstant" && !mathConstantNames.has(expression.name)) {
970
+ diagnostics.push({
971
+ code: "processing.mathConstant.name",
972
+ severity: "error",
973
+ message: `qti-math-constant has unsupported name ${expression.name}.`,
974
+ path: expression.source?.path,
975
+ source: expression.source,
976
+ });
977
+ }
978
+
979
+ if (expression.type === "mathOperator" && !mathOperatorNames.has(expression.name)) {
980
+ diagnostics.push({
981
+ code: "processing.mathOperator.name",
982
+ severity: "error",
983
+ message: `qti-math-operator has unsupported name ${expression.name}.`,
984
+ path: expression.source?.path,
985
+ source: expression.source,
986
+ });
987
+ }
988
+
989
+ if (expression.type === "statsOperator" && !statsOperatorNames.has(expression.name)) {
990
+ diagnostics.push({
991
+ code: "processing.statsOperator.name",
992
+ severity: "error",
993
+ message: `qti-stats-operator has unsupported name ${expression.name}.`,
994
+ path: expression.source?.path,
995
+ source: expression.source,
996
+ });
997
+ }
998
+
999
+ if (expression.type === "repeat") {
1000
+ validateRepeatExpression(expression, variables, diagnostics);
1001
+ }
1002
+
1003
+ if (expression.type === "inside") {
1004
+ validateInsideExpression(expression, diagnostics);
1005
+ }
1006
+
1007
+ if (expression.type === "fieldValue") {
1008
+ validateProcessingIdentifier(
1009
+ expression.fieldIdentifier,
1010
+ "processing.fieldValue.fieldIdentifier",
1011
+ expression.source,
1012
+ diagnostics,
1013
+ );
1014
+ }
1015
+
1016
+ for (const child of expressionChildren(expression)) {
1017
+ validateExpressionReferences(child, responses, variables, diagnostics);
1018
+ }
1019
+ }
1020
+
1021
+ const mathConstantNames = new Set(["pi", "e"]);
1022
+ const mathOperatorNames = new Set([
1023
+ "abs",
1024
+ "acos",
1025
+ "acot",
1026
+ "acsc",
1027
+ "asec",
1028
+ "asin",
1029
+ "atan",
1030
+ "atan2",
1031
+ "ceil",
1032
+ "cos",
1033
+ "cosh",
1034
+ "cot",
1035
+ "coth",
1036
+ "csc",
1037
+ "csch",
1038
+ "exp",
1039
+ "floor",
1040
+ "ln",
1041
+ "log",
1042
+ "sec",
1043
+ "sech",
1044
+ "signum",
1045
+ "sin",
1046
+ "sinh",
1047
+ "tan",
1048
+ "tanh",
1049
+ "toDegrees",
1050
+ "toRadians",
1051
+ ]);
1052
+ const statsOperatorNames = new Set(["mean", "sampleVariance", "sampleSD", "popVariance", "popSD"]);
1053
+
1054
+ function validateRounding(
1055
+ qtiName: string,
1056
+ roundingMode: string,
1057
+ figures: number,
1058
+ diagnostics: QtiDiagnostic[],
1059
+ source: QtiDiagnostic["source"],
1060
+ ): void {
1061
+ if (roundingMode !== "decimalPlaces" && roundingMode !== "significantFigures") {
1062
+ diagnostics.push({
1063
+ code: "processing.roundingMode",
1064
+ severity: "error",
1065
+ message: `${qtiName} requires rounding-mode decimalPlaces or significantFigures.`,
1066
+ path: source?.path,
1067
+ source,
1068
+ });
1069
+ }
1070
+ const validFigures =
1071
+ Number.isInteger(figures) && (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0);
1072
+ if (!validFigures) {
1073
+ diagnostics.push({
1074
+ code: "processing.roundingFigures",
1075
+ severity: "error",
1076
+ message: `${qtiName} requires valid figures for its rounding mode.`,
1077
+ path: source?.path,
1078
+ source,
1079
+ });
1080
+ }
1081
+ }
1082
+
1083
+ function validateRepeatExpression(
1084
+ expression: Extract<QtiProcessingExpression, { type: "repeat" }>,
1085
+ variables: Set<string>,
1086
+ diagnostics: QtiDiagnostic[],
1087
+ ): void {
1088
+ if (isInteger(expression.numberRepeats)) return;
1089
+ validateProcessingIdentifier(
1090
+ expression.numberRepeats,
1091
+ "processing.repeat.numberRepeats",
1092
+ expression.source,
1093
+ diagnostics,
1094
+ );
1095
+ if (expression.numberRepeats && !variables.has(expression.numberRepeats)) {
1096
+ diagnostics.push({
1097
+ code: "processing.repeat.numberRepeats.reference",
1098
+ severity: "error",
1099
+ message: `qti-repeat references missing template or outcome variable ${expression.numberRepeats}.`,
1100
+ path: expression.source?.path,
1101
+ source: expression.source,
1102
+ });
1103
+ }
1104
+ }
1105
+
1106
+ function validateInsideExpression(
1107
+ expression: Extract<QtiProcessingExpression, { type: "inside" }>,
1108
+ diagnostics: QtiDiagnostic[],
1109
+ ): void {
1110
+ const rawShape = expression.attributes.shape;
1111
+ if (rawShape === undefined) {
1112
+ diagnostics.push({
1113
+ code: "processing.inside.shape.required",
1114
+ severity: "error",
1115
+ message: "qti-inside requires shape.",
1116
+ path: expression.source?.path,
1117
+ source: expression.source,
1118
+ });
1119
+ } else if (
1120
+ rawShape !== "circle" &&
1121
+ rawShape !== "rect" &&
1122
+ rawShape !== "poly" &&
1123
+ rawShape !== "default"
1124
+ ) {
1125
+ diagnostics.push({
1126
+ code: "processing.inside.shape",
1127
+ severity: "error",
1128
+ message: `qti-inside has unsupported shape ${rawShape}.`,
1129
+ path: expression.source?.path,
1130
+ source: expression.source,
1131
+ });
1132
+ }
1133
+
1134
+ const expectedCoordCount =
1135
+ rawShape === "circle" ? 3 : rawShape === "rect" ? 4 : rawShape === "default" ? 0 : undefined;
1136
+ if (expectedCoordCount !== undefined && expression.coords.length !== expectedCoordCount) {
1137
+ diagnostics.push({
1138
+ code: "processing.inside.coords",
1139
+ severity: "error",
1140
+ message: `qti-inside shape ${rawShape} requires ${expectedCoordCount} coordinates.`,
1141
+ path: expression.source?.path,
1142
+ source: expression.source,
1143
+ });
1144
+ }
1145
+ if (rawShape === "poly" && (expression.coords.length < 6 || expression.coords.length % 2 !== 0)) {
1146
+ diagnostics.push({
1147
+ code: "processing.inside.coords",
1148
+ severity: "error",
1149
+ message: "qti-inside poly requires an even number of at least 6 coordinates.",
1150
+ path: expression.source?.path,
1151
+ source: expression.source,
1152
+ });
1153
+ }
1154
+ }
1155
+
1156
+ function validateBaseValueExpression(
1157
+ expression: Extract<QtiProcessingExpression, { type: "baseValue" }>,
1158
+ diagnostics: QtiDiagnostic[],
1159
+ ): void {
1160
+ if (!expression.baseType) {
1161
+ diagnostics.push({
1162
+ code: "processing.baseValue.baseType.required",
1163
+ severity: "error",
1164
+ message: "qti-base-value requires base-type.",
1165
+ path: expression.source?.path,
1166
+ source: expression.source,
1167
+ });
1168
+ return;
1169
+ }
1170
+ if (!isBaseType(expression.baseType)) {
1171
+ diagnostics.push({
1172
+ code: "processing.baseValue.baseType",
1173
+ severity: "error",
1174
+ message: `qti-base-value has unsupported base-type ${expression.baseType}.`,
1175
+ path: expression.source?.path,
1176
+ source: expression.source,
1177
+ });
1178
+ return;
1179
+ }
1180
+ const value = expression.rawValue ?? "";
1181
+ if (
1182
+ (expression.baseType === "integer" && !isInteger(value)) ||
1183
+ (expression.baseType === "float" && !isFiniteNumber(value))
1184
+ ) {
1185
+ diagnostics.push({
1186
+ code: "processing.baseValue.numeric",
1187
+ severity: "error",
1188
+ message: `qti-base-value requires ${expression.baseType} content, got ${value}.`,
1189
+ path: expression.source?.path,
1190
+ source: expression.source,
1191
+ });
1192
+ }
1193
+ if (expression.baseType === "boolean" && value !== "true" && value !== "false") {
1194
+ diagnostics.push({
1195
+ code: "processing.baseValue.boolean",
1196
+ severity: "error",
1197
+ message: `qti-base-value requires boolean content, got ${value}.`,
1198
+ path: expression.source?.path,
1199
+ source: expression.source,
1200
+ });
1201
+ }
1202
+ }
1203
+
1204
+ function validateRandomIntegerExpression(
1205
+ expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
1206
+ diagnostics: QtiDiagnostic[],
1207
+ ): void {
1208
+ validateRandomIntegerAttribute(expression, "min", diagnostics);
1209
+ validateRandomIntegerAttribute(expression, "max", diagnostics);
1210
+
1211
+ if (expression.attributes.step !== undefined) {
1212
+ validateRandomIntegerAttribute(expression, "step", diagnostics);
1213
+ if (isInteger(expression.attributes.step) && Number(expression.attributes.step) <= 0) {
1214
+ diagnostics.push({
1215
+ code: "processing.randomInteger.step",
1216
+ severity: "error",
1217
+ message: "qti-random-integer requires step to be greater than 0.",
1218
+ path: expression.source?.path,
1219
+ source: expression.source,
1220
+ });
1221
+ }
1222
+ }
1223
+
1224
+ const min = expression.attributes.min;
1225
+ const max = expression.attributes.max;
1226
+ if (
1227
+ min !== undefined &&
1228
+ max !== undefined &&
1229
+ isInteger(min) &&
1230
+ isInteger(max) &&
1231
+ Number(min) > Number(max)
1232
+ ) {
1233
+ diagnostics.push({
1234
+ code: "processing.randomInteger.bounds",
1235
+ severity: "error",
1236
+ message: "qti-random-integer requires min to be less than or equal to max.",
1237
+ path: expression.source?.path,
1238
+ source: expression.source,
1239
+ });
1240
+ }
1241
+ }
1242
+
1243
+ function validateRandomIntegerAttribute(
1244
+ expression: Extract<QtiProcessingExpression, { type: "randomInteger" }>,
1245
+ attribute: "min" | "max" | "step",
1246
+ diagnostics: QtiDiagnostic[],
1247
+ ): void {
1248
+ const value = expression.attributes[attribute];
1249
+ if (value === undefined) {
1250
+ diagnostics.push({
1251
+ code: "processing.randomInteger.attribute",
1252
+ severity: "error",
1253
+ message: `qti-random-integer requires ${attribute}.`,
1254
+ path: expression.source?.path,
1255
+ source: expression.source,
1256
+ });
1257
+ return;
1258
+ }
1259
+ if (isInteger(value)) return;
1260
+ diagnostics.push({
1261
+ code: "processing.randomInteger.integer",
1262
+ severity: "error",
1263
+ message: `qti-random-integer requires integer ${attribute}, got ${value}.`,
1264
+ path: expression.source?.path,
1265
+ source: expression.source,
1266
+ });
1267
+ }
1268
+
1269
+ function validateRandomFloatExpression(
1270
+ expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
1271
+ diagnostics: QtiDiagnostic[],
1272
+ ): void {
1273
+ validateRandomFloatAttribute(expression, "max", diagnostics);
1274
+ if (expression.attributes.min !== undefined)
1275
+ validateRandomFloatAttribute(expression, "min", diagnostics);
1276
+
1277
+ const min = expression.attributes.min ?? "0";
1278
+ const max = expression.attributes.max;
1279
+ if (
1280
+ max !== undefined &&
1281
+ isFiniteNumber(min) &&
1282
+ isFiniteNumber(max) &&
1283
+ Number(min) > Number(max)
1284
+ ) {
1285
+ diagnostics.push({
1286
+ code: "processing.randomFloat.bounds",
1287
+ severity: "error",
1288
+ message: "qti-random-float requires min to be less than or equal to max.",
1289
+ path: expression.source?.path,
1290
+ source: expression.source,
1291
+ });
1292
+ }
1293
+ }
1294
+
1295
+ function validateRandomFloatAttribute(
1296
+ expression: Extract<QtiProcessingExpression, { type: "randomFloat" }>,
1297
+ attribute: "min" | "max",
1298
+ diagnostics: QtiDiagnostic[],
1299
+ ): void {
1300
+ const value = expression.attributes[attribute];
1301
+ if (value === undefined) {
1302
+ diagnostics.push({
1303
+ code: "processing.randomFloat.attribute",
1304
+ severity: "error",
1305
+ message: `qti-random-float requires ${attribute}.`,
1306
+ path: expression.source?.path,
1307
+ source: expression.source,
1308
+ });
1309
+ return;
1310
+ }
1311
+ if (isFiniteNumber(value)) return;
1312
+ diagnostics.push({
1313
+ code: "processing.randomFloat.numeric",
1314
+ severity: "error",
1315
+ message: `qti-random-float requires numeric ${attribute}, got ${value}.`,
1316
+ path: expression.source?.path,
1317
+ source: expression.source,
1318
+ });
1319
+ }
1320
+
1321
+ function validateProcessingIdentifier(
1322
+ identifier: string,
1323
+ code: string,
1324
+ source: QtiDiagnostic["source"],
1325
+ diagnostics: QtiDiagnostic[],
1326
+ ): void {
1327
+ if (identifier.trim().length > 0) return;
1328
+ diagnostics.push({
1329
+ code,
1330
+ severity: "error",
1331
+ message: "Processing rule requires a non-empty identifier.",
1332
+ path: source?.path,
1333
+ source,
1334
+ });
1335
+ }
1336
+
1337
+ function expressionChildren(expression: QtiProcessingExpression): QtiProcessingExpression[] {
1338
+ if (expression.type === "random") {
1339
+ return expression.values;
1340
+ }
1341
+ if (
1342
+ expression.type === "multiple" ||
1343
+ expression.type === "ordered" ||
1344
+ expression.type === "sum" ||
1345
+ expression.type === "product" ||
1346
+ expression.type === "min" ||
1347
+ expression.type === "max" ||
1348
+ expression.type === "and" ||
1349
+ expression.type === "anyN" ||
1350
+ expression.type === "or" ||
1351
+ expression.type === "gcd" ||
1352
+ expression.type === "lcm" ||
1353
+ expression.type === "mathOperator" ||
1354
+ expression.type === "repeat" ||
1355
+ expression.type === "customOperator"
1356
+ ) {
1357
+ return expression.expressions;
1358
+ }
1359
+ if (
1360
+ expression.type === "subtract" ||
1361
+ expression.type === "divide" ||
1362
+ expression.type === "power" ||
1363
+ expression.type === "integerDivide" ||
1364
+ expression.type === "integerModulus" ||
1365
+ expression.type === "match" ||
1366
+ expression.type === "equal" ||
1367
+ expression.type === "equalRounded" ||
1368
+ expression.type === "numericCompare" ||
1369
+ expression.type === "durationCompare"
1370
+ ) {
1371
+ return [expression.left, expression.right];
1372
+ }
1373
+ if (
1374
+ expression.type === "not" ||
1375
+ expression.type === "round" ||
1376
+ expression.type === "roundTo" ||
1377
+ expression.type === "truncate" ||
1378
+ expression.type === "integerToFloat" ||
1379
+ expression.type === "index" ||
1380
+ expression.type === "containerSize" ||
1381
+ expression.type === "patternMatch" ||
1382
+ expression.type === "fieldValue" ||
1383
+ expression.type === "inside"
1384
+ ) {
1385
+ return [expression.expression];
1386
+ }
1387
+ if (expression.type === "stringMatch" || expression.type === "substring") {
1388
+ return [expression.left, expression.right];
1389
+ }
1390
+ if (expression.type === "member") {
1391
+ return [expression.value, expression.collection];
1392
+ }
1393
+ if (expression.type === "delete") {
1394
+ return [expression.value, expression.collection];
1395
+ }
1396
+ if (expression.type === "contains") {
1397
+ return [expression.collection, expression.values];
1398
+ }
1399
+ if (expression.type === "statsOperator") {
1400
+ return [expression.expression];
1401
+ }
1402
+ return [];
1403
+ }
1404
+
1405
+ function validateModalFeedback(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
1406
+ const outcomeIdentifiers = new Set(
1407
+ item.outcomeDeclarations.map((declaration) => declaration.identifier),
1408
+ );
1409
+ const seen = new Set<string>();
1410
+ for (const feedback of item.modalFeedback) {
1411
+ requireIdentifier("qti-modal-feedback", feedback.identifier, diagnostics, feedback.source);
1412
+ const key = `${feedback.outcomeIdentifier}\n${feedback.identifier}`;
1413
+ if (seen.has(key)) {
1414
+ diagnostics.push({
1415
+ code: "feedback.identifier.duplicate",
1416
+ severity: "error",
1417
+ message: `Duplicate modal feedback ${feedback.identifier} for outcome ${feedback.outcomeIdentifier}.`,
1418
+ path: feedback.source?.path,
1419
+ source: feedback.source,
1420
+ });
1421
+ }
1422
+ seen.add(key);
1423
+ if (!outcomeIdentifiers.has(feedback.outcomeIdentifier)) {
1424
+ diagnostics.push({
1425
+ code: "feedback.outcomeIdentifier.reference",
1426
+ severity: "error",
1427
+ message: `qti-modal-feedback ${feedback.identifier} references missing outcome declaration ${feedback.outcomeIdentifier}.`,
1428
+ path: feedback.source?.path,
1429
+ source: feedback.source,
1430
+ });
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
1436
+ const responseDeclarations = new Map(
1437
+ item.responseDeclarations.map((declaration) => [declaration.identifier, declaration]),
1438
+ );
1439
+ const responseIdentifiers = new Set(responseDeclarations.keys());
1440
+ for (const interaction of item.interactions) {
1441
+ validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics);
1442
+ validateInteractionResponseShape(interaction, diagnostics);
1443
+ validateInteractionChoices(interaction, diagnostics);
1444
+ validateInteractionChildren(interaction, diagnostics);
1445
+ validateInteractionRequiredAttributes(interaction, diagnostics);
1446
+ validateInteractionLimitAttributes(interaction, diagnostics);
1447
+ validateCorrectResponseReferences(
1448
+ interaction,
1449
+ interaction.responseIdentifier
1450
+ ? responseDeclarations.get(interaction.responseIdentifier)
1451
+ : undefined,
1452
+ diagnostics,
1453
+ );
1454
+ validateMappingReferences(
1455
+ interaction,
1456
+ interaction.responseIdentifier
1457
+ ? responseDeclarations.get(interaction.responseIdentifier)
1458
+ : undefined,
1459
+ diagnostics,
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ function validateInteractionResponseReference(
1465
+ interaction: QtiInteraction,
1466
+ responseIdentifiers: Set<string>,
1467
+ diagnostics: QtiDiagnostic[],
1468
+ ): void {
1469
+ if (!interaction.responseIdentifier) {
1470
+ if (interaction.type !== "endAttempt") {
1471
+ diagnostics.push({
1472
+ code: "interaction.responseIdentifier",
1473
+ severity: "error",
1474
+ message: `${interaction.qtiName} is missing response-identifier.`,
1475
+ path: interaction.source?.path,
1476
+ source: interaction.source,
1477
+ });
1478
+ }
1479
+ return;
1480
+ }
1481
+
1482
+ if (!responseIdentifiers.has(interaction.responseIdentifier)) {
1483
+ diagnostics.push({
1484
+ code: "interaction.responseIdentifier.reference",
1485
+ severity: "error",
1486
+ message: `${interaction.qtiName} references missing response declaration ${interaction.responseIdentifier}.`,
1487
+ path: interaction.source?.path,
1488
+ source: interaction.source,
1489
+ });
1490
+ }
1491
+ }
1492
+
1493
+ function validateInteractionResponseShape(
1494
+ interaction: QtiInteraction,
1495
+ diagnostics: QtiDiagnostic[],
1496
+ ): void {
1497
+ const expected = expectedResponseShape(interaction);
1498
+ if (!expected) return;
1499
+
1500
+ if (
1501
+ interaction.responseCardinality &&
1502
+ !expected.cardinalities.includes(interaction.responseCardinality)
1503
+ ) {
1504
+ diagnostics.push({
1505
+ code: "interaction.cardinality",
1506
+ severity: "error",
1507
+ message: `${interaction.qtiName} expects ${expected.cardinalities.join(" or ")} cardinality, got ${interaction.responseCardinality}.`,
1508
+ path: interaction.source?.path,
1509
+ source: interaction.source,
1510
+ });
1511
+ }
1512
+
1513
+ if (interaction.responseBaseType && !expected.baseTypes.includes(interaction.responseBaseType)) {
1514
+ diagnostics.push({
1515
+ code: "interaction.baseType",
1516
+ severity: "error",
1517
+ message: `${interaction.qtiName} expects ${expected.baseTypes.join(" or ")} base type, got ${interaction.responseBaseType}.`,
1518
+ path: interaction.source?.path,
1519
+ source: interaction.source,
1520
+ });
1521
+ }
1522
+ }
1523
+
1524
+ function validateInteractionChoices(
1525
+ interaction: QtiInteraction,
1526
+ diagnostics: QtiDiagnostic[],
1527
+ ): void {
1528
+ const identifiers = new Set<string>();
1529
+ for (const choice of interaction.choices) {
1530
+ requireIdentifier(choice.qtiName, choice.attributes.identifier, diagnostics, choice.source);
1531
+ if (!choice.identifier) continue;
1532
+ if (identifiers.has(choice.identifier)) {
1533
+ diagnostics.push({
1534
+ code: "choice.identifier.duplicate",
1535
+ severity: "error",
1536
+ message: `${interaction.qtiName} has duplicate choice identifier ${choice.identifier}.`,
1537
+ path: choice.source?.path ?? interaction.source?.path,
1538
+ source: choice.source ?? interaction.source,
1539
+ });
1540
+ }
1541
+ identifiers.add(choice.identifier);
1542
+ }
1543
+
1544
+ if (
1545
+ needsChoices(interaction) &&
1546
+ interaction.choices.filter((choice) => choice.role !== "gap").length === 0
1547
+ ) {
1548
+ diagnostics.push({
1549
+ code: "interaction.choices.required",
1550
+ severity: "error",
1551
+ message: `${interaction.qtiName} requires at least one choice.`,
1552
+ path: interaction.source?.path,
1553
+ source: interaction.source,
1554
+ });
1555
+ }
1556
+
1557
+ for (const choice of interaction.choices) {
1558
+ validateChoiceLimitAttributes(choice, diagnostics);
1559
+ }
1560
+ }
1561
+
1562
+ function validateCorrectResponseReferences(
1563
+ interaction: QtiInteraction,
1564
+ declaration: QtiResponseDeclaration | undefined,
1565
+ diagnostics: QtiDiagnostic[],
1566
+ ): void {
1567
+ if (!declaration || declaration.correctResponse === null) return;
1568
+ if (
1569
+ declaration.baseType !== "identifier" &&
1570
+ declaration.baseType !== "pair" &&
1571
+ declaration.baseType !== "directedPair"
1572
+ ) {
1573
+ return;
1574
+ }
1575
+
1576
+ const identifiers = new Set(
1577
+ interaction.choices
1578
+ .map((choice) => choice.identifier)
1579
+ .filter((identifier) => identifier.length > 0),
1580
+ );
1581
+ if (identifiers.size === 0) return;
1582
+
1583
+ for (const value of responseValues(declaration.correctResponse)) {
1584
+ if (declaration.baseType === "identifier") {
1585
+ if (identifiers.has(value)) continue;
1586
+ invalidCorrectResponseReference(interaction, declaration, value, diagnostics);
1587
+ continue;
1588
+ }
1589
+
1590
+ const parts = value.trim().split(/\s+/);
1591
+ if (parts.length !== 2 || parts.some((part) => !identifiers.has(part))) {
1592
+ invalidCorrectResponseReference(interaction, declaration, value, diagnostics);
1593
+ }
1594
+ }
1595
+ }
1596
+
1597
+ function responseValues(value: QtiResponseDeclaration["correctResponse"]): string[] {
1598
+ if (Array.isArray(value)) return value.map(String);
1599
+ return [String(value)];
1600
+ }
1601
+
1602
+ function invalidCorrectResponseReference(
1603
+ interaction: QtiInteraction,
1604
+ declaration: QtiResponseDeclaration,
1605
+ value: string,
1606
+ diagnostics: QtiDiagnostic[],
1607
+ ): void {
1608
+ diagnostics.push({
1609
+ code: "response.correctResponse.reference",
1610
+ severity: "error",
1611
+ message: `Response declaration ${declaration.identifier} correct response ${value} does not reference choices in ${interaction.qtiName}.`,
1612
+ path: declaration.source?.path,
1613
+ source: declaration.source,
1614
+ });
1615
+ }
1616
+
1617
+ function validateMappingReferences(
1618
+ interaction: QtiInteraction,
1619
+ declaration: QtiResponseDeclaration | undefined,
1620
+ diagnostics: QtiDiagnostic[],
1621
+ ): void {
1622
+ if (!declaration?.mapping) return;
1623
+ if (
1624
+ declaration.baseType !== "identifier" &&
1625
+ declaration.baseType !== "pair" &&
1626
+ declaration.baseType !== "directedPair"
1627
+ ) {
1628
+ return;
1629
+ }
1630
+
1631
+ const identifiers = new Set(
1632
+ interaction.choices
1633
+ .map((choice) => choice.identifier)
1634
+ .filter((identifier) => identifier.length > 0),
1635
+ );
1636
+ if (identifiers.size === 0) return;
1637
+
1638
+ for (const entry of declaration.mapping.entries) {
1639
+ const mapKey = entry.mapKey;
1640
+ if (!mapKey) continue;
1641
+ if (declaration.baseType === "identifier") {
1642
+ if (identifiers.has(mapKey)) continue;
1643
+ invalidMappingReference(interaction, declaration, entry, diagnostics);
1644
+ continue;
1645
+ }
1646
+
1647
+ const parts = mapKey.trim().split(/\s+/);
1648
+ if (parts.length !== 2 || parts.some((part) => !identifiers.has(part))) {
1649
+ invalidMappingReference(interaction, declaration, entry, diagnostics);
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ function invalidMappingReference(
1655
+ interaction: QtiInteraction,
1656
+ declaration: QtiResponseDeclaration,
1657
+ entry: NonNullable<QtiResponseDeclaration["mapping"]>["entries"][number],
1658
+ diagnostics: QtiDiagnostic[],
1659
+ ): void {
1660
+ diagnostics.push({
1661
+ code: "mapping.mapKey.reference",
1662
+ severity: "error",
1663
+ message: `Response declaration ${declaration.identifier} map-key ${entry.mapKey ?? ""} does not reference choices in ${interaction.qtiName}.`,
1664
+ path: entry.source?.path ?? declaration.source?.path,
1665
+ source: entry.source ?? declaration.source,
1666
+ });
1667
+ }
1668
+
1669
+ function validateInteractionChildren(
1670
+ interaction: QtiInteraction,
1671
+ diagnostics: QtiDiagnostic[],
1672
+ ): void {
1673
+ const allowed = allowedInteractionChildren(interaction);
1674
+ if (!allowed) return;
1675
+
1676
+ for (const child of interaction.childElements) {
1677
+ if (allowed.has(child.qtiName)) continue;
1678
+ diagnostics.push({
1679
+ code: "interaction.child.unsupported",
1680
+ severity: "error",
1681
+ message: `${interaction.qtiName} does not allow ${child.qtiName} as a direct child.`,
1682
+ path: child.source?.path ?? interaction.source?.path,
1683
+ source: child.source ?? interaction.source,
1684
+ });
1685
+ }
1686
+ }
1687
+
1688
+ function validateInteractionRequiredAttributes(
1689
+ interaction: QtiInteraction,
1690
+ diagnostics: QtiDiagnostic[],
1691
+ ): void {
1692
+ if (requiresObject(interaction) && !hasRequiredObjectAsset(interaction)) {
1693
+ diagnostics.push({
1694
+ code: "interaction.object.required",
1695
+ severity: "error",
1696
+ message:
1697
+ interaction.type === "drawing"
1698
+ ? `${interaction.qtiName} requires an object, img, or picture canvas with a data/src attribute.`
1699
+ : `${interaction.qtiName} requires an object, img, audio, or video child with a data/src attribute or media sources.`,
1700
+ path: interaction.source?.path,
1701
+ source: interaction.source,
1702
+ });
1703
+ }
1704
+
1705
+ if (interaction.type === "portableCustom") {
1706
+ requireInteractionAttribute(
1707
+ interaction,
1708
+ "custom-interaction-type-identifier",
1709
+ "interaction.portableCustom.typeIdentifier",
1710
+ diagnostics,
1711
+ );
1712
+ requireInteractionAttribute(
1713
+ interaction,
1714
+ "module",
1715
+ "interaction.portableCustom.module",
1716
+ diagnostics,
1717
+ );
1718
+ }
1719
+
1720
+ if (interaction.type === "slider") {
1721
+ const lower = interaction.attributes["lower-bound"];
1722
+ const upper = interaction.attributes["upper-bound"];
1723
+ requireInteractionAttribute(
1724
+ interaction,
1725
+ "lower-bound",
1726
+ "interaction.slider.lowerBound",
1727
+ diagnostics,
1728
+ );
1729
+ requireInteractionAttribute(
1730
+ interaction,
1731
+ "upper-bound",
1732
+ "interaction.slider.upperBound",
1733
+ diagnostics,
1734
+ );
1735
+ if (lower !== undefined && !isFiniteNumber(lower)) {
1736
+ invalidNumber(interaction, "lower-bound", lower, diagnostics);
1737
+ }
1738
+ if (upper !== undefined && !isFiniteNumber(upper)) {
1739
+ invalidNumber(interaction, "upper-bound", upper, diagnostics);
1740
+ }
1741
+ if (
1742
+ lower !== undefined &&
1743
+ upper !== undefined &&
1744
+ isFiniteNumber(lower) &&
1745
+ isFiniteNumber(upper)
1746
+ ) {
1747
+ if (Number(lower) >= Number(upper)) {
1748
+ diagnostics.push({
1749
+ code: "interaction.slider.bounds",
1750
+ severity: "error",
1751
+ message: `${interaction.qtiName} requires lower-bound to be less than upper-bound.`,
1752
+ path: interaction.source?.path,
1753
+ source: interaction.source,
1754
+ });
1755
+ }
1756
+ }
1757
+ const step = interaction.attributes.step;
1758
+ if (step !== undefined && (!isFiniteNumber(step) || Number(step) <= 0)) {
1759
+ invalidNumber(interaction, "step", step, diagnostics);
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ function requiresObject(interaction: QtiInteraction): boolean {
1765
+ return (
1766
+ interaction.type === "graphicOrder" ||
1767
+ interaction.type === "graphicAssociate" ||
1768
+ interaction.type === "graphicGapMatch" ||
1769
+ interaction.type === "hotspot" ||
1770
+ interaction.type === "selectPoint" ||
1771
+ interaction.type === "positionObject" ||
1772
+ interaction.type === "media" ||
1773
+ interaction.type === "drawing"
1774
+ );
1775
+ }
1776
+
1777
+ function hasRequiredObjectAsset(interaction: QtiInteraction): boolean {
1778
+ if (interaction.type === "media") {
1779
+ return Boolean(
1780
+ interaction.object?.data || interaction.object?.sources.some((source) => Boolean(source.src)),
1781
+ );
1782
+ }
1783
+ if (interaction.type === "drawing") return Boolean(interaction.object?.data);
1784
+ return Boolean(interaction.object?.data);
1785
+ }
1786
+
1787
+ function requireInteractionAttribute(
1788
+ interaction: QtiInteraction,
1789
+ attribute: string,
1790
+ code: string,
1791
+ diagnostics: QtiDiagnostic[],
1792
+ ): void {
1793
+ if (interaction.attributes[attribute]) return;
1794
+ diagnostics.push({
1795
+ code,
1796
+ severity: "error",
1797
+ message: `${interaction.qtiName} requires ${attribute}.`,
1798
+ path: interaction.source?.path,
1799
+ source: interaction.source,
1800
+ });
1801
+ }
1802
+
1803
+ function invalidNumber(
1804
+ interaction: QtiInteraction,
1805
+ attribute: string,
1806
+ value: string,
1807
+ diagnostics: QtiDiagnostic[],
1808
+ ): void {
1809
+ diagnostics.push({
1810
+ code: "interaction.numericAttribute",
1811
+ severity: "error",
1812
+ message: `${interaction.qtiName} requires numeric ${attribute}, got ${value}.`,
1813
+ path: interaction.source?.path,
1814
+ source: interaction.source,
1815
+ });
1816
+ }
1817
+
1818
+ function validateInteractionLimitAttributes(
1819
+ interaction: QtiInteraction,
1820
+ diagnostics: QtiDiagnostic[],
1821
+ ): void {
1822
+ validateNonNegativeIntegerAttribute(interaction, "max-choices", diagnostics);
1823
+ validateNonNegativeIntegerAttribute(interaction, "min-choices", diagnostics);
1824
+ validateNonNegativeIntegerAttribute(interaction, "max-associations", diagnostics);
1825
+ validateNonNegativeIntegerAttribute(interaction, "min-associations", diagnostics);
1826
+ validateNonNegativeIntegerAttribute(interaction, "expected-length", diagnostics);
1827
+ validateNonNegativeIntegerAttribute(interaction, "expected-lines", diagnostics);
1828
+
1829
+ validateMinMaxPair(interaction, "min-choices", "max-choices", diagnostics);
1830
+ validateMinMaxPair(interaction, "min-associations", "max-associations", diagnostics);
1831
+ if (interaction.type === "media") {
1832
+ validateNonNegativeIntegerAttribute(interaction, "max-plays", diagnostics);
1833
+ validateNonNegativeIntegerAttribute(interaction, "min-plays", diagnostics);
1834
+ validateBooleanAttribute(interaction, "autostart", diagnostics);
1835
+ validateBooleanAttribute(interaction, "loop", diagnostics);
1836
+ validateMinMaxPair(interaction, "min-plays", "max-plays", diagnostics);
1837
+ }
1838
+ }
1839
+
1840
+ function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
1841
+ if (requiresMatchMax(choice) && !choice.attributes["match-max"]) {
1842
+ diagnostics.push({
1843
+ code: "choice.matchMax.required",
1844
+ severity: "error",
1845
+ message: `${choice.qtiName} ${choice.identifier} requires match-max.`,
1846
+ path: choice.source?.path,
1847
+ source: choice.source,
1848
+ });
1849
+ }
1850
+
1851
+ validateChoiceNonNegativeIntegerAttribute(choice, "match-max", diagnostics);
1852
+ validateChoiceNonNegativeIntegerAttribute(choice, "match-min", diagnostics);
1853
+ validateChoiceMinMaxPair(choice, "match-min", "match-max", diagnostics);
1854
+ validateHotspotGeometry(choice, diagnostics);
1855
+ }
1856
+
1857
+ function requiresMatchMax(choice: QtiChoice): boolean {
1858
+ return (
1859
+ choice.qtiName === "qti-simple-associable-choice" ||
1860
+ choice.qtiName === "qti-associable-hotspot" ||
1861
+ choice.qtiName === "qti-gap-text" ||
1862
+ choice.qtiName === "qti-gap-img"
1863
+ );
1864
+ }
1865
+
1866
+ function validateHotspotGeometry(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
1867
+ if (choice.qtiName !== "qti-hotspot-choice" && choice.qtiName !== "qti-associable-hotspot") {
1868
+ return;
1869
+ }
1870
+
1871
+ const shape = choice.attributes.shape;
1872
+ const coords = choice.attributes.coords;
1873
+ if (!shape) {
1874
+ diagnostics.push({
1875
+ code: "choice.shape.required",
1876
+ severity: "error",
1877
+ message: `${choice.qtiName} ${choice.identifier} requires shape.`,
1878
+ path: choice.source?.path,
1879
+ source: choice.source,
1880
+ });
1881
+ } else if (!isHotspotShape(shape)) {
1882
+ diagnostics.push({
1883
+ code: "choice.shape",
1884
+ severity: "error",
1885
+ message: `${choice.qtiName} ${choice.identifier} has unsupported shape ${shape}.`,
1886
+ path: choice.source?.path,
1887
+ source: choice.source,
1888
+ });
1889
+ }
1890
+
1891
+ if (!coords) {
1892
+ diagnostics.push({
1893
+ code: "choice.coords.required",
1894
+ severity: "error",
1895
+ message: `${choice.qtiName} ${choice.identifier} requires coords.`,
1896
+ path: choice.source?.path,
1897
+ source: choice.source,
1898
+ });
1899
+ return;
1900
+ }
1901
+
1902
+ const values = coords.split(",").map((value) => value.trim());
1903
+ if (values.length === 0 || values.some((value) => value.length === 0 || !isFiniteNumber(value))) {
1904
+ diagnostics.push({
1905
+ code: "choice.coords",
1906
+ severity: "error",
1907
+ message: `${choice.qtiName} ${choice.identifier} requires comma-separated numeric coords.`,
1908
+ path: choice.source?.path,
1909
+ source: choice.source,
1910
+ });
1911
+ return;
1912
+ }
1913
+
1914
+ if (shape && isHotspotShape(shape) && !hasValidShapeCoordinateCount(shape, values.map(Number))) {
1915
+ diagnostics.push({
1916
+ code: "choice.coords.shape",
1917
+ severity: "error",
1918
+ message: `${choice.qtiName} ${choice.identifier} shape ${shape} has invalid coords arity.`,
1919
+ path: choice.source?.path,
1920
+ source: choice.source,
1921
+ });
1922
+ }
1923
+ }
1924
+
1925
+ function isHotspotShape(value: string): boolean {
1926
+ return (
1927
+ value === "circle" ||
1928
+ value === "default" ||
1929
+ value === "ellipse" ||
1930
+ value === "poly" ||
1931
+ value === "rect"
1932
+ );
1933
+ }
1934
+
1935
+ function isAreaShape(value: string): boolean {
1936
+ return value === "circle" || value === "default" || value === "poly" || value === "rect";
1937
+ }
1938
+
1939
+ function isNumericCsv(value: string): boolean {
1940
+ return value
1941
+ .split(",")
1942
+ .map((part) => part.trim())
1943
+ .every((part) => part.length > 0 && isFiniteNumber(part));
1944
+ }
1945
+
1946
+ function numericCsv(value: string): number[] {
1947
+ return value.split(",").map((part) => Number(part.trim()));
1948
+ }
1949
+
1950
+ function hasValidShapeCoordinateCount(shape: string, coords: number[]): boolean {
1951
+ switch (shape) {
1952
+ case "circle":
1953
+ return coords.length === 3;
1954
+ case "ellipse":
1955
+ case "rect":
1956
+ return coords.length === 4;
1957
+ case "poly":
1958
+ return coords.length >= 6 && coords.length % 2 === 0;
1959
+ case "default":
1960
+ return true;
1961
+ default:
1962
+ return false;
1963
+ }
1964
+ }
1965
+
1966
+ function validateNonNegativeIntegerAttribute(
1967
+ interaction: QtiInteraction,
1968
+ attribute: string,
1969
+ diagnostics: QtiDiagnostic[],
1970
+ ): void {
1971
+ const value = interaction.attributes[attribute];
1972
+ if (value === undefined || isNonNegativeInteger(value)) return;
1973
+ diagnostics.push({
1974
+ code: "interaction.integerAttribute",
1975
+ severity: "error",
1976
+ message: `${interaction.qtiName} requires non-negative integer ${attribute}, got ${value}.`,
1977
+ path: interaction.source?.path,
1978
+ source: interaction.source,
1979
+ });
1980
+ }
1981
+
1982
+ function validateBooleanAttribute(
1983
+ interaction: QtiInteraction,
1984
+ attribute: string,
1985
+ diagnostics: QtiDiagnostic[],
1986
+ ): void {
1987
+ const value = interaction.attributes[attribute];
1988
+ if (value === undefined || isBooleanAttribute(value)) return;
1989
+ diagnostics.push({
1990
+ code: "interaction.booleanAttribute",
1991
+ severity: "error",
1992
+ message: `${interaction.qtiName} requires boolean ${attribute}, got ${value}.`,
1993
+ path: interaction.source?.path,
1994
+ source: interaction.source,
1995
+ });
1996
+ }
1997
+
1998
+ function validateChoiceNonNegativeIntegerAttribute(
1999
+ choice: QtiChoice,
2000
+ attribute: string,
2001
+ diagnostics: QtiDiagnostic[],
2002
+ ): void {
2003
+ const value = choice.attributes[attribute];
2004
+ if (value === undefined || isNonNegativeInteger(value)) return;
2005
+ diagnostics.push({
2006
+ code: "choice.integerAttribute",
2007
+ severity: "error",
2008
+ message: `${choice.qtiName} ${choice.identifier} requires non-negative integer ${attribute}, got ${value}.`,
2009
+ path: choice.source?.path,
2010
+ source: choice.source,
2011
+ });
2012
+ }
2013
+
2014
+ function validateChoiceMinMaxPair(
2015
+ choice: QtiChoice,
2016
+ minAttribute: string,
2017
+ maxAttribute: string,
2018
+ diagnostics: QtiDiagnostic[],
2019
+ ): void {
2020
+ const min = choice.attributes[minAttribute];
2021
+ const max = choice.attributes[maxAttribute];
2022
+ if (
2023
+ min === undefined ||
2024
+ max === undefined ||
2025
+ !isNonNegativeInteger(min) ||
2026
+ !isNonNegativeInteger(max) ||
2027
+ max === "0"
2028
+ ) {
2029
+ return;
2030
+ }
2031
+ if (Number(min) <= Number(max)) return;
2032
+ diagnostics.push({
2033
+ code: "choice.minMax",
2034
+ severity: "error",
2035
+ message: `${choice.qtiName} ${choice.identifier} requires ${minAttribute} to be less than or equal to ${maxAttribute}, unless ${maxAttribute} is 0 for unlimited.`,
2036
+ path: choice.source?.path,
2037
+ source: choice.source,
2038
+ });
2039
+ }
2040
+
2041
+ function validateMinMaxPair(
2042
+ interaction: QtiInteraction,
2043
+ minAttribute: string,
2044
+ maxAttribute: string,
2045
+ diagnostics: QtiDiagnostic[],
2046
+ ): void {
2047
+ const min = interaction.attributes[minAttribute];
2048
+ const max = interaction.attributes[maxAttribute];
2049
+ if (
2050
+ min === undefined ||
2051
+ max === undefined ||
2052
+ !isNonNegativeInteger(min) ||
2053
+ !isNonNegativeInteger(max) ||
2054
+ max === "0"
2055
+ ) {
2056
+ return;
2057
+ }
2058
+ if (Number(min) <= Number(max)) return;
2059
+ diagnostics.push({
2060
+ code: "interaction.minMax",
2061
+ severity: "error",
2062
+ message: `${interaction.qtiName} requires ${minAttribute} to be less than or equal to ${maxAttribute}, unless ${maxAttribute} is 0 for unlimited.`,
2063
+ path: interaction.source?.path,
2064
+ source: interaction.source,
2065
+ });
2066
+ }
2067
+
2068
+ function allowedInteractionChildren(interaction: QtiInteraction): Set<string> | undefined {
2069
+ const common = ["qti-prompt"];
2070
+ switch (interaction.type) {
2071
+ case "choice":
2072
+ return setOf(common, ["qti-simple-choice"]);
2073
+ case "order":
2074
+ return setOf(common, ["qti-simple-choice"]);
2075
+ case "associate":
2076
+ return setOf(common, ["qti-simple-match-set", "qti-simple-associable-choice"]);
2077
+ case "match":
2078
+ return setOf(common, ["qti-simple-match-set"]);
2079
+ case "gapMatch":
2080
+ return setOf(common, ["qti-gap-text", "qti-gap-img", ...staticContentNames()]);
2081
+ case "inlineChoice":
2082
+ return setOf(common, ["qti-inline-choice"]);
2083
+ case "hottext":
2084
+ return setOf(common, staticContentNames());
2085
+ case "graphicOrder":
2086
+ return setOf(common, ["object", "qti-hotspot-choice"]);
2087
+ case "graphicAssociate":
2088
+ return setOf(common, ["object", "qti-associable-hotspot"]);
2089
+ case "graphicGapMatch":
2090
+ return setOf(common, [
2091
+ "object",
2092
+ "qti-gap-text",
2093
+ "qti-gap-img",
2094
+ "qti-associable-hotspot",
2095
+ ...staticContentNames(),
2096
+ ]);
2097
+ case "hotspot":
2098
+ return setOf(common, ["object", "qti-hotspot-choice"]);
2099
+ case "positionObject":
2100
+ return setOf(common, ["object", "img", "qti-position-object-stage"]);
2101
+ case "selectPoint":
2102
+ case "media":
2103
+ return setOf(common, ["audio", "video", "object", "img"]);
2104
+ case "drawing":
2105
+ return setOf(common, ["object", "img", "picture"]);
2106
+ case "extendedText":
2107
+ return new Set(common);
2108
+ case "portableCustom":
2109
+ return setOf(common, ["qti-interaction-markup"]);
2110
+ case "slider":
2111
+ case "textEntry":
2112
+ case "upload":
2113
+ case "endAttempt":
2114
+ return new Set(common);
2115
+ case "custom":
2116
+ return undefined;
2117
+ }
2118
+ }
2119
+
2120
+ function staticContentNames(): string[] {
2121
+ return ["p", "div", "span", "ul", "ol", "li", "table", "tbody", "thead", "tr", "td", "th"];
2122
+ }
2123
+
2124
+ function setOf(...items: string[][]): Set<string> {
2125
+ return new Set(items.flat());
2126
+ }
2127
+
2128
+ function isCardinality(value: string): value is QtiCardinality {
2129
+ return value === "single" || value === "multiple" || value === "ordered" || value === "record";
2130
+ }
2131
+
2132
+ function isBaseType(value: string): value is QtiBaseType {
2133
+ return (
2134
+ value === "identifier" ||
2135
+ value === "boolean" ||
2136
+ value === "integer" ||
2137
+ value === "float" ||
2138
+ value === "string" ||
2139
+ value === "point" ||
2140
+ value === "pair" ||
2141
+ value === "directedPair" ||
2142
+ value === "duration" ||
2143
+ value === "file" ||
2144
+ value === "uri"
2145
+ );
2146
+ }
2147
+
2148
+ function isFiniteNumber(value: string): boolean {
2149
+ return Number.isFinite(Number(value));
2150
+ }
2151
+
2152
+ function isInteger(value: string): boolean {
2153
+ return /^-?\d+$/.test(value);
2154
+ }
2155
+
2156
+ function isNonNegativeInteger(value: string): boolean {
2157
+ return /^\d+$/.test(value);
2158
+ }
2159
+
2160
+ function isBooleanAttribute(value: string): boolean {
2161
+ return value === "true" || value === "false" || value === "1" || value === "0";
2162
+ }
2163
+
2164
+ function isPoint(value: string): boolean {
2165
+ const parts = value.trim().split(/\s+/);
2166
+ return parts.length === 2 && parts.every(isFiniteNumber);
2167
+ }
2168
+
2169
+ function isPair(value: string): boolean {
2170
+ const parts = value.trim().split(/\s+/);
2171
+ return parts.length === 2 && parts.every((part) => part.length > 0);
2172
+ }
2173
+
2174
+ function expectedResponseShape(
2175
+ interaction: QtiInteraction,
2176
+ ): { cardinalities: QtiCardinality[]; baseTypes: QtiBaseType[] } | undefined {
2177
+ if (interaction.type === "endAttempt") {
2178
+ return { cardinalities: ["single"], baseTypes: ["boolean"] };
2179
+ }
2180
+ if (interaction.type === "media") return { cardinalities: ["single"], baseTypes: ["integer"] };
2181
+ if (interaction.type === "custom") return undefined;
2182
+ if (interaction.type === "order" || interaction.type === "graphicOrder") {
2183
+ return { cardinalities: ["ordered"], baseTypes: ["identifier"] };
2184
+ }
2185
+ if (interaction.type === "associate" || interaction.type === "graphicAssociate") {
2186
+ return { cardinalities: ["multiple"], baseTypes: ["pair", "directedPair"] };
2187
+ }
2188
+ if (
2189
+ interaction.type === "match" ||
2190
+ interaction.type === "gapMatch" ||
2191
+ interaction.type === "graphicGapMatch"
2192
+ ) {
2193
+ return { cardinalities: ["multiple"], baseTypes: ["directedPair"] };
2194
+ }
2195
+ if (interaction.type === "selectPoint" || interaction.type === "positionObject") {
2196
+ return { cardinalities: ["single", "multiple"], baseTypes: ["point"] };
2197
+ }
2198
+ if (interaction.type === "slider") {
2199
+ return { cardinalities: ["single"], baseTypes: ["integer", "float"] };
2200
+ }
2201
+ if (interaction.type === "upload") {
2202
+ return { cardinalities: ["single"], baseTypes: ["file"] };
2203
+ }
2204
+ if (interaction.type === "textEntry" || interaction.type === "extendedText") {
2205
+ return { cardinalities: ["single"], baseTypes: ["string"] };
2206
+ }
2207
+ if (interaction.type === "drawing") return { cardinalities: ["single"], baseTypes: ["file"] };
2208
+ if (interaction.type === "portableCustom") {
2209
+ return { cardinalities: ["single"], baseTypes: ["string", "file", "uri"] };
2210
+ }
2211
+ return { cardinalities: ["single", "multiple"], baseTypes: ["identifier"] };
2212
+ }
2213
+
2214
+ function needsChoices(interaction: QtiInteraction): boolean {
2215
+ return (
2216
+ interaction.type === "choice" ||
2217
+ interaction.type === "order" ||
2218
+ interaction.type === "associate" ||
2219
+ interaction.type === "match" ||
2220
+ interaction.type === "gapMatch" ||
2221
+ interaction.type === "inlineChoice" ||
2222
+ interaction.type === "hottext" ||
2223
+ interaction.type === "graphicOrder" ||
2224
+ interaction.type === "graphicAssociate" ||
2225
+ interaction.type === "graphicGapMatch" ||
2226
+ interaction.type === "hotspot"
2227
+ );
2228
+ }