@longsightgroup/qti3-core 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +14 -0
  3. package/dist/content-text.d.ts.map +1 -0
  4. package/dist/content-text.js +58 -0
  5. package/dist/content-text.js.map +1 -0
  6. package/dist/index.d.ts +7 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/package-xml.d.ts +12 -0
  11. package/dist/package-xml.d.ts.map +1 -0
  12. package/dist/package-xml.js +17 -0
  13. package/dist/package-xml.js.map +1 -0
  14. package/dist/parser-custom-interactions.d.ts +8 -0
  15. package/dist/parser-custom-interactions.d.ts.map +1 -0
  16. package/dist/parser-custom-interactions.js +178 -0
  17. package/dist/parser-custom-interactions.js.map +1 -0
  18. package/dist/parser-declarations.d.ts +6 -0
  19. package/dist/parser-declarations.d.ts.map +1 -0
  20. package/dist/parser-declarations.js +136 -0
  21. package/dist/parser-declarations.js.map +1 -0
  22. package/dist/parser-item-metadata.d.ts +9 -0
  23. package/dist/parser-item-metadata.d.ts.map +1 -0
  24. package/dist/parser-item-metadata.js +158 -0
  25. package/dist/parser-item-metadata.js.map +1 -0
  26. package/dist/parser-processing.d.ts +5 -0
  27. package/dist/parser-processing.d.ts.map +1 -0
  28. package/dist/parser-processing.js +642 -0
  29. package/dist/parser-processing.js.map +1 -0
  30. package/dist/parser-values.d.ts +8 -0
  31. package/dist/parser-values.d.ts.map +1 -0
  32. package/dist/parser-values.js +49 -0
  33. package/dist/parser-values.js.map +1 -0
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +32 -1086
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-expression-tags.d.ts +56 -0
  38. package/dist/processing-expression-tags.d.ts.map +1 -0
  39. package/dist/processing-expression-tags.js +52 -0
  40. package/dist/processing-expression-tags.js.map +1 -0
  41. package/dist/processing-rules.d.ts +3 -0
  42. package/dist/processing-rules.d.ts.map +1 -0
  43. package/dist/processing-rules.js +10 -0
  44. package/dist/processing-rules.js.map +1 -0
  45. package/dist/serializer-processing-expressions.d.ts +8 -0
  46. package/dist/serializer-processing-expressions.d.ts.map +1 -0
  47. package/dist/serializer-processing-expressions.js +198 -0
  48. package/dist/serializer-processing-expressions.js.map +1 -0
  49. package/dist/serializer-processing-xml.d.ts +15 -0
  50. package/dist/serializer-processing-xml.d.ts.map +1 -0
  51. package/dist/serializer-processing-xml.js +46 -0
  52. package/dist/serializer-processing-xml.js.map +1 -0
  53. package/dist/serializer-processing.d.ts +8 -0
  54. package/dist/serializer-processing.d.ts.map +1 -0
  55. package/dist/serializer-processing.js +106 -0
  56. package/dist/serializer-processing.js.map +1 -0
  57. package/dist/shared-vocabulary-authoring.d.ts +50 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +302 -0
  60. package/dist/shared-vocabulary-authoring.js.map +1 -0
  61. package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
  62. package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
  63. package/dist/shared-vocabulary-interaction-sets.js +23 -0
  64. package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
  65. package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
  66. package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
  67. package/dist/shared-vocabulary-interaction-validation.js +99 -0
  68. package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
  69. package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
  70. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
  71. package/dist/shared-vocabulary-registry-validation.js +147 -0
  72. package/dist/shared-vocabulary-registry-validation.js.map +1 -0
  73. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  74. package/dist/shared-vocabulary-support.js +96 -56
  75. package/dist/shared-vocabulary-support.js.map +1 -1
  76. package/dist/shared-vocabulary.d.ts +6 -0
  77. package/dist/shared-vocabulary.d.ts.map +1 -1
  78. package/dist/shared-vocabulary.js +12 -0
  79. package/dist/shared-vocabulary.js.map +1 -1
  80. package/dist/support.d.ts +4 -1
  81. package/dist/support.d.ts.map +1 -1
  82. package/dist/support.js +159 -10
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +38 -7
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/validation.d.ts.map +1 -1
  87. package/dist/validation.js +29 -250
  88. package/dist/validation.js.map +1 -1
  89. package/dist/xml.d.ts +10 -0
  90. package/dist/xml.d.ts.map +1 -1
  91. package/dist/xml.js +321 -249
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +80 -0
  95. package/src/index.ts +67 -0
  96. package/src/interaction-test-fixtures.ts +44 -0
  97. package/src/package-xml.ts +30 -0
  98. package/src/parser-custom-interactions.ts +309 -0
  99. package/src/parser-declarations.ts +174 -0
  100. package/src/parser-item-metadata.ts +194 -0
  101. package/src/parser-processing.ts +701 -0
  102. package/src/parser-values.ts +61 -0
  103. package/src/parser.ts +59 -1230
  104. package/src/processing-expression-tags.ts +65 -0
  105. package/src/processing-rules.ts +11 -0
  106. package/src/serializer-processing-expressions.ts +456 -0
  107. package/src/serializer-processing-xml.ts +67 -0
  108. package/src/serializer-processing.fixtures.ts +469 -0
  109. package/src/serializer-processing.ts +196 -0
  110. package/src/shared-vocabulary-authoring.ts +409 -0
  111. package/src/shared-vocabulary-interaction-sets.ts +28 -0
  112. package/src/shared-vocabulary-interaction-validation.ts +143 -0
  113. package/src/shared-vocabulary-registry-validation.ts +207 -0
  114. package/src/shared-vocabulary-support.ts +127 -101
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +174 -14
  117. package/src/types.ts +48 -7
  118. package/src/validation.ts +33 -341
  119. package/src/xml.ts +378 -255
package/src/parser.ts CHANGED
@@ -1,14 +1,28 @@
1
- import { getInteractionSupport, interactionNameToType, processingSupport } from "./support.js";
1
+ import { appendContentTextNode, flatTextFromContent } from "./content-text.js";
2
+ import {
3
+ parseOutcomeDeclaration,
4
+ parseResponseDeclaration,
5
+ parseTemplateDeclaration,
6
+ } from "./parser-declarations.js";
7
+ import {
8
+ firstChildElement,
9
+ parseCatalogInfo,
10
+ parseCatalogReferences,
11
+ parseCompanionMaterialsInfo,
12
+ parseModalFeedback,
13
+ parseStylesheet,
14
+ } from "./parser-item-metadata.js";
15
+ import { parseXmlBoolean } from "./parser-values.js";
16
+ import { parseInteractionCustomPayload } from "./parser-custom-interactions.js";
17
+ import { parseResponseProcessing, parseTemplateProcessing } from "./parser-processing.js";
18
+ import {
19
+ interactionNameToType,
20
+ interactionRegistryDiagnostics,
21
+ interactionRegistryStatus,
22
+ processingSupport,
23
+ } from "./support.js";
2
24
  import type {
3
25
  QtiAssessmentItem,
4
- QtiBaseType,
5
- QtiCatalogCard,
6
- QtiCatalogCardEntry,
7
- QtiCatalogFileHref,
8
- QtiCatalogHtmlContent,
9
- QtiCatalogInfo,
10
- QtiCatalogReference,
11
- QtiCardinality,
12
26
  QtiChoice,
13
27
  QtiChoiceRole,
14
28
  QtiContentNode,
@@ -16,30 +30,10 @@ import type {
16
30
  QtiDocument,
17
31
  QtiInteraction,
18
32
  QtiInteractionType,
19
- QtiLookupOutcomeValue,
20
- QtiLookupTable,
21
33
  QtiMediaSource,
22
- QtiModalFeedback,
23
34
  QtiObjectAsset,
24
- QtiOutcomeDeclaration,
25
35
  QtiParseResult,
26
- QtiProcessingExpression,
27
- QtiPortableCustomDefinition,
28
- QtiPortableCustomInteractionModule,
29
- QtiPortableCustomInteractionModules,
30
- QtiPortableCustomVariableBinding,
31
- QtiRecordValue,
32
- QtiResponseCondition,
33
36
  QtiResponseDeclaration,
34
- QtiResponseProcessing,
35
- QtiResponseRule,
36
- QtiSetOutcomeValue,
37
- QtiScalarValue,
38
- QtiStylesheet,
39
- QtiTemplateDeclaration,
40
- QtiTemplateProcessing,
41
- QtiTemplateRule,
42
- QtiValue,
43
37
  } from "./types.js";
44
38
  import { validateAssessmentItem } from "./validation.js";
45
39
  import { childElements, descendants, parseXmlTree, textContent, type XmlNode } from "./xml.js";
@@ -147,7 +141,13 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
147
141
  );
148
142
  }
149
143
  const modalFeedback = childElements(node, "qti-modal-feedback").map(parseModalFeedback);
150
- const catalogInfo = parseCatalogInfo(childElements(node, "qti-catalog-info")[0]);
144
+ const catalogInfo = parseCatalogInfo(
145
+ firstChildElement(node, "qti-catalog-info", diagnostics, "item.child.duplicate"),
146
+ );
147
+ const companionMaterials = parseCompanionMaterialsInfo(
148
+ firstChildElement(node, "qti-companion-materials-info", diagnostics, "item.child.duplicate"),
149
+ diagnostics,
150
+ );
151
151
  const catalogReferences = itemBody ? parseCatalogReferences(itemBody) : [];
152
152
  const stylesheets = childElements(node, "qti-stylesheet").map(parseStylesheet);
153
153
  const prompt = itemBody ? childElements(itemBody, "qti-prompt")[0] : undefined;
@@ -169,6 +169,7 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
169
169
  interactions,
170
170
  modalFeedback,
171
171
  catalogInfo,
172
+ companionMaterials,
172
173
  catalogReferences,
173
174
  stylesheets,
174
175
  body,
@@ -213,43 +214,10 @@ function diagnoseProcessingElements(
213
214
  }
214
215
  }
215
216
 
216
- function parseStylesheet(node: XmlNode): QtiStylesheet {
217
- return {
218
- href: node.attributes.href ?? "",
219
- type: node.attributes.type,
220
- media: node.attributes.media,
221
- title: node.attributes.title,
222
- attributes: node.attributes,
223
- source: node.source,
224
- };
225
- }
226
-
227
- function parseCatalogReferences(node: XmlNode): QtiCatalogReference[] {
228
- const references = [
229
- ...(node.attributes["data-catalog-idref"] ? [node] : []),
230
- ...descendants(node, (child) => Boolean(child.attributes["data-catalog-idref"])),
231
- ];
232
- return references.map((reference) => ({
233
- idref: reference.attributes["data-catalog-idref"] ?? "",
234
- source: reference.source,
235
- }));
236
- }
237
-
238
217
  function isInteractionElement(node: XmlNode): boolean {
239
218
  return interactionNameToType.has(node.localName) || /^qti-.+-interaction$/.test(node.localName);
240
219
  }
241
220
 
242
- function parseModalFeedback(node: XmlNode): QtiModalFeedback {
243
- const showHide = node.attributes["show-hide"] === "hide" ? "hide" : "show";
244
- return {
245
- identifier: node.attributes.identifier ?? "",
246
- outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
247
- showHide,
248
- text: textContent(node),
249
- source: node.source,
250
- };
251
- }
252
-
253
221
  function parseContentChildren(
254
222
  node: XmlNode,
255
223
  diagnostics: QtiDiagnostic[],
@@ -259,7 +227,7 @@ function parseContentChildren(
259
227
  const content: QtiContentNode[] = [];
260
228
  for (const entry of node.content) {
261
229
  if (typeof entry === "string") {
262
- if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
230
+ appendContentTextNode(content, entry, node.source);
263
231
  continue;
264
232
  }
265
233
  const parsed = parseContentNode(entry, diagnostics, responseDeclarationMap, interactions);
@@ -318,125 +286,6 @@ function parseContentNode(
318
286
  };
319
287
  }
320
288
 
321
- function parseCatalogInfo(node: XmlNode | undefined): QtiCatalogInfo | undefined {
322
- if (!node) return undefined;
323
- return {
324
- catalogs: childElements(node, "qti-catalog").map((catalog) => ({
325
- id: catalog.attributes.id ?? "",
326
- attributes: catalog.attributes,
327
- cards: childElements(catalog, "qti-card").map(parseCatalogCard),
328
- source: catalog.source,
329
- })),
330
- source: node.source,
331
- };
332
- }
333
-
334
- function parseCatalogCard(node: XmlNode): QtiCatalogCard {
335
- return {
336
- support: node.attributes.support ?? "",
337
- htmlContent: parseCatalogHtmlContent(childElements(node, "qti-html-content")[0]),
338
- fileHrefs: childElements(node, "qti-file-href").map(parseCatalogFileHref),
339
- entries: childElements(node, "qti-card-entry").map(parseCatalogCardEntry),
340
- attributes: node.attributes,
341
- source: node.source,
342
- };
343
- }
344
-
345
- function parseCatalogCardEntry(node: XmlNode): QtiCatalogCardEntry {
346
- return {
347
- language: node.attributes["xml:lang"] ?? node.attributes.lang,
348
- default: node.attributes.default === "true",
349
- htmlContent: parseCatalogHtmlContent(childElements(node, "qti-html-content")[0]),
350
- fileHrefs: childElements(node, "qti-file-href").map(parseCatalogFileHref),
351
- attributes: node.attributes,
352
- source: node.source,
353
- };
354
- }
355
-
356
- function parseCatalogHtmlContent(node: XmlNode | undefined): QtiCatalogHtmlContent | undefined {
357
- if (!node) return undefined;
358
- return {
359
- text: textContent(node),
360
- children: parseCatalogHtmlChildren(node),
361
- attributes: node.attributes,
362
- source: node.source,
363
- };
364
- }
365
-
366
- function parseCatalogHtmlChildren(node: XmlNode): QtiContentNode[] {
367
- const content: QtiContentNode[] = [];
368
- for (const entry of node.content) {
369
- if (typeof entry === "string") {
370
- if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
371
- continue;
372
- }
373
- content.push({
374
- kind: "element",
375
- qtiName: entry.localName,
376
- attributes: entry.attributes,
377
- children: parseCatalogHtmlChildren(entry),
378
- source: entry.source,
379
- });
380
- }
381
- return content;
382
- }
383
-
384
- function parseCatalogFileHref(node: XmlNode): QtiCatalogFileHref {
385
- return {
386
- href: textContent(node).trim(),
387
- mimeType: node.attributes["mime-type"],
388
- attributes: node.attributes,
389
- source: node.source,
390
- };
391
- }
392
-
393
- function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration {
394
- const cardinality = parseCardinality(node.attributes.cardinality);
395
- const baseType = node.attributes["base-type"] as QtiResponseDeclaration["baseType"];
396
- return {
397
- kind: "response",
398
- identifier: node.attributes.identifier ?? "",
399
- cardinality,
400
- baseType,
401
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
402
- correctResponse: normalizeValueForCardinality(
403
- parseVariableValue(childElements(node, "qti-correct-response")[0], baseType),
404
- cardinality,
405
- ),
406
- mapping: parseMapping(childElements(node, "qti-mapping")[0]),
407
- areaMapping: parseAreaMapping(childElements(node, "qti-area-mapping")[0]),
408
- attributes: node.attributes,
409
- source: node.source,
410
- };
411
- }
412
-
413
- function parseOutcomeDeclaration(node: XmlNode): QtiOutcomeDeclaration {
414
- const baseType = node.attributes["base-type"] as QtiOutcomeDeclaration["baseType"];
415
- return {
416
- kind: "outcome",
417
- identifier: node.attributes.identifier ?? "",
418
- cardinality: parseCardinality(node.attributes.cardinality),
419
- baseType,
420
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
421
- lookupTable: parseLookupTable(node, baseType),
422
- attributes: node.attributes,
423
- source: node.source,
424
- };
425
- }
426
-
427
- function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration {
428
- const baseType = node.attributes["base-type"] as QtiTemplateDeclaration["baseType"];
429
- return {
430
- kind: "template",
431
- identifier: node.attributes.identifier ?? "",
432
- cardinality: parseCardinality(node.attributes.cardinality),
433
- baseType,
434
- defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
435
- attributes: node.attributes,
436
- source: node.source,
437
- };
438
- }
439
-
440
289
  function parseInteraction(
441
290
  node: XmlNode,
442
291
  diagnostics: QtiDiagnostic[],
@@ -448,25 +297,8 @@ function parseInteraction(
448
297
  ? responseDeclarationMap.get(responseIdentifier)
449
298
  : undefined;
450
299
  const prompt = childElements(node, "qti-prompt")[0];
451
- if (!interactionType) {
452
- diagnostics.push({
453
- code: "interaction.unsupported",
454
- severity: "warning",
455
- message: `${node.localName} is not currently in the support registry.`,
456
- path: node.source.path,
457
- source: node.source,
458
- });
459
- }
460
- const support = getInteractionSupport(node.localName);
461
- if (support?.support === "deprecated") {
462
- diagnostics.push({
463
- code: "interaction.deprecated",
464
- severity: "warning",
465
- message: `${node.localName} is deprecated. ${support.notes ?? ""}`.trim(),
466
- path: node.source.path,
467
- source: node.source,
468
- });
469
- }
300
+ const registryStatus = interactionRegistryStatus(node.localName);
301
+ diagnostics.push(...interactionRegistryDiagnostics(node.localName, node.source));
470
302
 
471
303
  const objectNode =
472
304
  interactionType === "positionObject"
@@ -480,13 +312,23 @@ function parseInteraction(
480
312
  (child) => child.localName === "object" || child.localName === "img",
481
313
  )[0];
482
314
 
315
+ const promptContent = prompt
316
+ ? parseContentChildren(prompt, diagnostics, responseDeclarationMap, [])
317
+ : undefined;
318
+
483
319
  return {
484
320
  type: interactionType ?? "custom",
321
+ registryStatus,
485
322
  qtiName: node.localName,
486
323
  responseIdentifier,
487
324
  responseCardinality: responseDeclaration?.cardinality,
488
325
  responseBaseType: responseDeclaration?.baseType,
489
- prompt: prompt ? textContent(prompt) : undefined,
326
+ prompt: promptContent
327
+ ? flatTextFromContent(promptContent, { excludeAnnotations: true }) || undefined
328
+ : prompt
329
+ ? textContent(prompt)
330
+ : undefined,
331
+ promptContent: promptContent && promptContent.length > 0 ? promptContent : undefined,
490
332
  promptAttributes: prompt?.attributes,
491
333
  promptSource: prompt?.source,
492
334
  contextText: inlineInteractionContext(node, interactionType),
@@ -495,11 +337,8 @@ function parseInteraction(
495
337
  interactionType === "positionObject"
496
338
  ? parseObjectAsset(positionObjectStageObject(node))
497
339
  : undefined,
498
- portableCustom:
499
- interactionType === "portableCustom"
500
- ? parsePortableCustomDefinition(node, diagnostics)
501
- : undefined,
502
- choices: parseChoices(node),
340
+ ...parseInteractionCustomPayload(node, interactionType, diagnostics, isInteractionElement),
341
+ choices: parseChoices(node, diagnostics, responseDeclarationMap),
503
342
  hottextSegments: interactionType === "hottext" ? parseHottextSegments(node) : undefined,
504
343
  gapMatchSegments:
505
344
  interactionType === "gapMatch" || interactionType === "graphicGapMatch"
@@ -515,172 +354,6 @@ function parseInteraction(
515
354
  };
516
355
  }
517
356
 
518
- function parsePortableCustomDefinition(
519
- node: XmlNode,
520
- diagnostics: QtiDiagnostic[],
521
- ): QtiPortableCustomDefinition {
522
- const markup = firstChildElement(node, "qti-interaction-markup", diagnostics);
523
- const modules = firstChildElement(node, "qti-interaction-modules", diagnostics);
524
- return {
525
- responseIdentifier: node.attributes["response-identifier"],
526
- customInteractionTypeIdentifier: node.attributes["custom-interaction-type-identifier"],
527
- module: node.attributes.module,
528
- interactionModules: modules ? parsePortableCustomInteractionModules(modules) : undefined,
529
- interactionMarkup: markup ? parsePortableCustomMarkupChildren(markup, diagnostics) : [],
530
- interactionMarkupRaw: markup ? serializeXmlContent(markup) : undefined,
531
- templateVariables: childElements(node, "qti-template-variable").map((variable) =>
532
- parsePortableCustomVariableBinding(variable, "template"),
533
- ),
534
- contextVariables: childElements(node, "qti-context-variable").map((variable) =>
535
- parsePortableCustomVariableBinding(variable, "context"),
536
- ),
537
- stylesheets: childElements(node, "qti-stylesheet").map(parseStylesheet),
538
- catalogInfo: parseCatalogInfo(childElements(node, "qti-catalog-info")[0]),
539
- dataAttributes: Object.fromEntries(
540
- Object.entries(node.attributes).filter(([name]) => name.startsWith("data-")),
541
- ),
542
- attributes: node.attributes,
543
- source: node.source,
544
- };
545
- }
546
-
547
- function firstChildElement(
548
- node: XmlNode,
549
- name: string,
550
- diagnostics: QtiDiagnostic[],
551
- ): XmlNode | undefined {
552
- const matches = childElements(node, name);
553
- if (matches.length > 1) {
554
- diagnostics.push({
555
- code: "interaction.portableCustom.child.duplicate",
556
- severity: "error",
557
- message: `${node.localName} allows at most one ${name} child.`,
558
- path: matches[1]?.source?.path ?? node.source?.path,
559
- source: matches[1]?.source ?? node.source,
560
- });
561
- }
562
- return matches[0];
563
- }
564
-
565
- function parsePortableCustomMarkupChildren(
566
- node: XmlNode,
567
- diagnostics: QtiDiagnostic[],
568
- ): QtiContentNode[] {
569
- const content: QtiContentNode[] = [];
570
- for (const entry of node.content) {
571
- if (typeof entry === "string") {
572
- if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
573
- continue;
574
- }
575
- content.push(parsePortableCustomMarkupNode(entry, diagnostics));
576
- }
577
- return content;
578
- }
579
-
580
- function parsePortableCustomMarkupNode(
581
- node: XmlNode,
582
- diagnostics: QtiDiagnostic[],
583
- ): QtiContentNode {
584
- if (isInteractionElement(node)) {
585
- diagnostics.push({
586
- code: "interaction.portableCustom.markupInteraction",
587
- severity: "error",
588
- message: `qti-interaction-markup must not contain nested QTI interaction ${node.localName}.`,
589
- path: node.source?.path,
590
- source: node.source,
591
- });
592
- }
593
-
594
- if (node.localName === "qti-printed-variable") {
595
- return {
596
- kind: "printedVariable",
597
- identifier: node.attributes.identifier ?? "",
598
- format: node.attributes.format,
599
- attributes: node.attributes,
600
- source: node.source,
601
- };
602
- }
603
-
604
- if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
605
- return {
606
- kind: "feedback",
607
- feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
608
- identifier: node.attributes.identifier ?? "",
609
- outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
610
- showHide: node.attributes["show-hide"] === "hide" ? "hide" : "show",
611
- attributes: node.attributes,
612
- children: parsePortableCustomMarkupChildren(node, diagnostics),
613
- source: node.source,
614
- };
615
- }
616
-
617
- return {
618
- kind: "element",
619
- qtiName: node.localName,
620
- attributes: node.attributes,
621
- children: parsePortableCustomMarkupChildren(node, diagnostics),
622
- source: node.source,
623
- };
624
- }
625
-
626
- function parsePortableCustomInteractionModules(node: XmlNode): QtiPortableCustomInteractionModules {
627
- return {
628
- primaryConfiguration: node.attributes["primary-configuration"],
629
- secondaryConfiguration:
630
- node.attributes["secondary-configuration"] ?? node.attributes["fallback-configuration"],
631
- modules: childElements(node, "qti-interaction-module").map(
632
- parsePortableCustomInteractionModule,
633
- ),
634
- attributes: node.attributes,
635
- source: node.source,
636
- };
637
- }
638
-
639
- function parsePortableCustomInteractionModule(node: XmlNode): QtiPortableCustomInteractionModule {
640
- return {
641
- id: node.attributes.id,
642
- primaryPath: node.attributes["primary-path"],
643
- fallbackPath: node.attributes["fallback-path"],
644
- attributes: node.attributes,
645
- source: node.source,
646
- };
647
- }
648
-
649
- function parsePortableCustomVariableBinding(
650
- node: XmlNode,
651
- kind: QtiPortableCustomVariableBinding["kind"],
652
- ): QtiPortableCustomVariableBinding {
653
- return {
654
- kind,
655
- identifier: node.attributes.identifier ?? node.attributes["template-identifier"],
656
- variableIdentifier: node.attributes["variable-identifier"],
657
- attributes: node.attributes,
658
- source: node.source,
659
- };
660
- }
661
-
662
- function serializeXmlContent(node: XmlNode): string {
663
- return node.content
664
- .map((entry) => (typeof entry === "string" ? escapeXmlText(entry) : serializeXmlNode(entry)))
665
- .join("");
666
- }
667
-
668
- function serializeXmlNode(node: XmlNode): string {
669
- const attributes = Object.entries(node.attributes)
670
- .map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`)
671
- .join("");
672
- if (node.content.length === 0) return `<${node.name}${attributes}/>`;
673
- return `<${node.name}${attributes}>${serializeXmlContent(node)}</${node.name}>`;
674
- }
675
-
676
- function escapeXmlText(value: string): string {
677
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
678
- }
679
-
680
- function escapeXmlAttribute(value: string): string {
681
- return escapeXmlText(value).replaceAll('"', "&quot;");
682
- }
683
-
684
357
  function positionObjectInteractionObject(node: XmlNode): XmlNode | undefined {
685
358
  return childElements(node).find(
686
359
  (child) => child.localName === "object" || child.localName === "img",
@@ -965,7 +638,11 @@ function assetTypeFromData(data: string | undefined): string | undefined {
965
638
  return undefined;
966
639
  }
967
640
 
968
- function parseChoices(node: XmlNode): QtiChoice[] {
641
+ function parseChoices(
642
+ node: XmlNode,
643
+ diagnostics: QtiDiagnostic[],
644
+ responseDeclarationMap: Map<string, QtiResponseDeclaration>,
645
+ ): QtiChoice[] {
969
646
  const choiceNames = new Set([
970
647
  "qti-simple-choice",
971
648
  "qti-simple-associable-choice",
@@ -981,14 +658,20 @@ function parseChoices(node: XmlNode): QtiChoice[] {
981
658
  return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
982
659
  const identifier = choice.attributes.identifier ?? "";
983
660
  const asset = parseChoiceAsset(choice);
661
+ const content = parseContentChildren(choice, diagnostics, responseDeclarationMap, []);
662
+ const flatChoiceText =
663
+ content.length > 0
664
+ ? flatTextFromContent(content, { excludeAnnotations: true })
665
+ : textContent(choice);
984
666
  return {
985
667
  identifier,
986
668
  text:
987
- textContent(choice) ||
669
+ flatChoiceText ||
988
670
  choice.attributes["object-label"] ||
989
671
  asset?.text ||
990
672
  identifier ||
991
673
  `Choice ${index + 1}`,
674
+ content: content.length > 0 ? content : undefined,
992
675
  asset,
993
676
  role: choiceRole(choice),
994
677
  qtiName: choice.localName,
@@ -1039,857 +722,3 @@ function matchSetIndex(node: XmlNode): number {
1039
722
  node.parent?.children.filter((sibling) => sibling.localName === "qti-simple-match-set") ?? [];
1040
723
  return siblings.indexOf(node);
1041
724
  }
1042
-
1043
- function parseVariableValue(
1044
- node: XmlNode | undefined,
1045
- baseType: QtiBaseType | undefined,
1046
- ): QtiValue {
1047
- if (!node) return null;
1048
- const valueNodes = childElements(node, "qti-value");
1049
- const entries = valueNodes.map((valueNode) => ({
1050
- fieldIdentifier: valueNode.attributes["field-identifier"],
1051
- value: coerceValue(textContent(valueNode), valueNode.attributes["base-type"] ?? baseType),
1052
- }));
1053
- const recordEntries = entries.filter(
1054
- (entry): entry is { fieldIdentifier: string; value: QtiScalarValue } =>
1055
- Boolean(entry.fieldIdentifier),
1056
- );
1057
- if (recordEntries.length > 0) {
1058
- return Object.fromEntries(recordEntries.map((entry) => [entry.fieldIdentifier, entry.value]));
1059
- }
1060
- if (entries.length === 0) {
1061
- const text = textContent(node);
1062
- return text.length > 0 ? coerceValue(text, baseType) : null;
1063
- }
1064
- if (entries.length === 1) return entries[0]?.value ?? null;
1065
- return entries.map((entry) => entry.value);
1066
- }
1067
-
1068
- function parseMapping(node: XmlNode | undefined): QtiResponseDeclaration["mapping"] | undefined {
1069
- if (!node) return undefined;
1070
- return {
1071
- defaultValue: Number(node.attributes["default-value"] ?? 0),
1072
- attributes: node.attributes,
1073
- source: node.source,
1074
- entries: childElements(node, "qti-map-entry").map((entry) => ({
1075
- mapKey: entry.attributes["map-key"],
1076
- mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
1077
- attributes: entry.attributes,
1078
- source: entry.source,
1079
- })),
1080
- };
1081
- }
1082
-
1083
- function parseAreaMapping(
1084
- node: XmlNode | undefined,
1085
- ): QtiResponseDeclaration["areaMapping"] | undefined {
1086
- if (!node) return undefined;
1087
- return {
1088
- defaultValue: Number(node.attributes["default-value"] ?? 0),
1089
- attributes: node.attributes,
1090
- source: node.source,
1091
- entries: childElements(node, "qti-area-map-entry").map((entry) => ({
1092
- shape: parseShape(entry.attributes.shape),
1093
- coords: parseCoords(entry.attributes.coords),
1094
- mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
1095
- attributes: entry.attributes,
1096
- source: entry.source,
1097
- })),
1098
- };
1099
- }
1100
-
1101
- function parseLookupTable(
1102
- node: XmlNode,
1103
- baseType: QtiOutcomeDeclaration["baseType"],
1104
- ): QtiLookupTable | undefined {
1105
- const matchTable = childElements(node, "qti-match-table")[0];
1106
- if (matchTable) return parseMatchTable(matchTable, baseType);
1107
- const interpolationTable = childElements(node, "qti-interpolation-table")[0];
1108
- if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType);
1109
- return undefined;
1110
- }
1111
-
1112
- function parseMatchTable(
1113
- node: XmlNode,
1114
- baseType: QtiOutcomeDeclaration["baseType"],
1115
- ): QtiLookupTable {
1116
- return {
1117
- type: "match",
1118
- defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
1119
- attributes: node.attributes,
1120
- source: node.source,
1121
- entries: childElements(node, "qti-match-table-entry").map((entry) => ({
1122
- sourceValue: Number(entry.attributes["source-value"]),
1123
- targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
1124
- attributes: entry.attributes,
1125
- source: entry.source,
1126
- })),
1127
- };
1128
- }
1129
-
1130
- function parseInterpolationTable(
1131
- node: XmlNode,
1132
- baseType: QtiOutcomeDeclaration["baseType"],
1133
- ): QtiLookupTable {
1134
- return {
1135
- type: "interpolation",
1136
- defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
1137
- attributes: node.attributes,
1138
- source: node.source,
1139
- entries: childElements(node, "qti-interpolation-table-entry").map((entry) => ({
1140
- sourceValue: Number(entry.attributes["source-value"]),
1141
- targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
1142
- includeBoundary: entry.attributes["include-boundary"] !== "false",
1143
- attributes: entry.attributes,
1144
- source: entry.source,
1145
- })),
1146
- };
1147
- }
1148
-
1149
- function parseLookupValue(value: string | undefined, baseType: string | undefined): QtiValue {
1150
- return value === undefined ? null : coerceValue(value, baseType);
1151
- }
1152
-
1153
- function parseShape(
1154
- shape: string | undefined,
1155
- ): NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number]["shape"] {
1156
- if (shape === "circle" || shape === "rect" || shape === "poly") return shape;
1157
- return "default";
1158
- }
1159
-
1160
- function parseCoords(value: string | undefined): number[] {
1161
- return (value ?? "")
1162
- .split(",")
1163
- .map((part) => Number(part.trim()))
1164
- .filter((part) => Number.isFinite(part));
1165
- }
1166
-
1167
- function parseResponseProcessing(node: XmlNode | undefined): QtiResponseProcessing | undefined {
1168
- if (!node) return undefined;
1169
- return {
1170
- template: node.attributes.template,
1171
- rules: parseResponseRules(node),
1172
- conditions: responseConditionsFromChildren(node),
1173
- };
1174
- }
1175
-
1176
- function parseTemplateProcessing(node: XmlNode | undefined): QtiTemplateProcessing | undefined {
1177
- if (!node) return undefined;
1178
- return {
1179
- rules: parseTemplateRules(node),
1180
- };
1181
- }
1182
-
1183
- function parseTemplateRules(node: XmlNode): QtiTemplateRule[] {
1184
- return childElements(node)
1185
- .map(parseTemplateRule)
1186
- .filter((rule): rule is QtiTemplateRule => rule !== undefined);
1187
- }
1188
-
1189
- function parseTemplateRule(node: XmlNode): QtiTemplateRule | undefined {
1190
- if (node.localName === "qti-set-template-value") {
1191
- return {
1192
- type: "setTemplateValue",
1193
- identifier: node.attributes.identifier ?? "",
1194
- expression: parseFirstExpression(node) ?? {
1195
- type: "baseValue",
1196
- value: null,
1197
- source: node.source,
1198
- },
1199
- source: node.source,
1200
- };
1201
- }
1202
-
1203
- if (node.localName === "qti-set-default-value") {
1204
- return {
1205
- type: "setDefaultValue",
1206
- identifier: node.attributes.identifier ?? "",
1207
- expression: parseFirstExpression(node) ?? {
1208
- type: "baseValue",
1209
- value: null,
1210
- source: node.source,
1211
- },
1212
- source: node.source,
1213
- };
1214
- }
1215
-
1216
- if (node.localName === "qti-set-correct-response") {
1217
- return {
1218
- type: "setCorrectResponse",
1219
- identifier: node.attributes.identifier ?? "",
1220
- expression: parseFirstExpression(node) ?? {
1221
- type: "baseValue",
1222
- value: null,
1223
- source: node.source,
1224
- },
1225
- source: node.source,
1226
- };
1227
- }
1228
-
1229
- if (node.localName === "qti-template-condition") {
1230
- const templateIf = childElements(node, "qti-template-if")[0];
1231
- const templateElse = childElements(node, "qti-template-else")[0];
1232
- return {
1233
- type: "templateCondition",
1234
- ifExpression: templateIf ? parseFirstExpression(templateIf) : undefined,
1235
- thenRules: templateIf ? parseTemplateRules(templateIf) : [],
1236
- elseIfs: childElements(node, "qti-template-else-if").map((branch) => ({
1237
- expression: parseFirstExpression(branch),
1238
- rules: parseTemplateRules(branch),
1239
- })),
1240
- elseRules: templateElse ? parseTemplateRules(templateElse) : [],
1241
- source: node.source,
1242
- };
1243
- }
1244
-
1245
- if (node.localName === "qti-exit-template") {
1246
- return {
1247
- type: "exitTemplate",
1248
- source: node.source,
1249
- };
1250
- }
1251
-
1252
- if (node.localName === "qti-template-constraint") {
1253
- return {
1254
- type: "templateConstraint",
1255
- expression: parseFirstExpression(node) ?? {
1256
- type: "baseValue",
1257
- value: null,
1258
- source: node.source,
1259
- },
1260
- source: node.source,
1261
- };
1262
- }
1263
-
1264
- return undefined;
1265
- }
1266
-
1267
- function responseConditionsFromChildren(node: XmlNode): QtiResponseCondition[] {
1268
- return childElements(node).flatMap((child) => {
1269
- if (child.localName === "qti-response-condition") return [parseResponseCondition(child)];
1270
- if (child.localName === "qti-response-processing-fragment") {
1271
- return responseConditionsFromChildren(child);
1272
- }
1273
- return [];
1274
- });
1275
- }
1276
-
1277
- function parseResponseCondition(node: XmlNode): QtiResponseCondition {
1278
- const responseIf = childElements(node, "qti-response-if")[0];
1279
- const responseElse = childElements(node, "qti-response-else")[0];
1280
- return {
1281
- ifExpression: responseIf ? parseFirstExpression(responseIf) : undefined,
1282
- thenRules: responseIf ? parseResponseRules(responseIf) : [],
1283
- elseIfs: childElements(node, "qti-response-else-if").map((branch) => ({
1284
- expression: parseFirstExpression(branch),
1285
- rules: parseResponseRules(branch),
1286
- })),
1287
- elseRules: responseElse ? parseResponseRules(responseElse) : [],
1288
- };
1289
- }
1290
-
1291
- function parseResponseRules(node: XmlNode): QtiResponseRule[] {
1292
- return childElements(node)
1293
- .map(parseResponseRule)
1294
- .filter((rule): rule is QtiResponseRule => rule !== undefined);
1295
- }
1296
-
1297
- function parseResponseRule(node: XmlNode): QtiResponseRule | undefined {
1298
- if (node.localName === "qti-response-condition") {
1299
- return {
1300
- type: "responseCondition",
1301
- condition: parseResponseCondition(node),
1302
- source: node.source,
1303
- };
1304
- }
1305
- if (node.localName === "qti-set-outcome-value") return parseSetOutcomeValue(node);
1306
- if (node.localName === "qti-lookup-outcome-value") return parseLookupOutcomeValue(node);
1307
- if (node.localName === "qti-exit-response") {
1308
- return { type: "exitResponse", source: node.source };
1309
- }
1310
- if (node.localName === "qti-response-processing-fragment") {
1311
- return {
1312
- type: "responseProcessingFragment",
1313
- rules: parseResponseRules(node),
1314
- source: node.source,
1315
- };
1316
- }
1317
- return undefined;
1318
- }
1319
-
1320
- function parseLookupOutcomeValue(node: XmlNode): QtiLookupOutcomeValue {
1321
- return {
1322
- type: "lookupOutcomeValue",
1323
- identifier: node.attributes.identifier ?? "",
1324
- expression: parseFirstExpression(node) ?? {
1325
- type: "baseValue",
1326
- value: null,
1327
- source: node.source,
1328
- },
1329
- source: node.source,
1330
- };
1331
- }
1332
-
1333
- function parseSetOutcomeValue(setNode: XmlNode): QtiSetOutcomeValue {
1334
- return {
1335
- type: "setOutcomeValue",
1336
- identifier: setNode.attributes.identifier ?? "",
1337
- expression: parseFirstExpression(setNode) ?? {
1338
- type: "baseValue",
1339
- value: null,
1340
- source: setNode.source,
1341
- },
1342
- source: setNode.source,
1343
- };
1344
- }
1345
-
1346
- function parseFirstExpression(node: XmlNode): QtiProcessingExpression | undefined {
1347
- for (const child of node.children) {
1348
- const expression = parseExpression(child);
1349
- if (expression) return expression;
1350
- }
1351
- return undefined;
1352
- }
1353
-
1354
- function parseExpression(node: XmlNode): QtiProcessingExpression | undefined {
1355
- if (node.localName === "qti-base-value") {
1356
- const rawValue = textContent(node);
1357
- return {
1358
- type: "baseValue",
1359
- value: coerceValue(rawValue, node.attributes["base-type"]),
1360
- rawValue,
1361
- baseType: node.attributes["base-type"],
1362
- source: node.source,
1363
- };
1364
- }
1365
-
1366
- if (node.localName === "qti-null") {
1367
- return { type: "null", source: node.source };
1368
- }
1369
-
1370
- if (node.localName === "qti-is-null") {
1371
- const variable = childElements(node, "qti-variable")[0];
1372
- return {
1373
- type: "isNull",
1374
- identifier: variable?.attributes.identifier ?? "",
1375
- source: node.source,
1376
- };
1377
- }
1378
-
1379
- if (node.localName === "qti-map-response") {
1380
- return {
1381
- type: "mapResponse",
1382
- identifier: node.attributes.identifier ?? "",
1383
- source: node.source,
1384
- };
1385
- }
1386
-
1387
- if (node.localName === "qti-map-response-point") {
1388
- return {
1389
- type: "mapResponsePoint",
1390
- identifier: node.attributes.identifier ?? "",
1391
- source: node.source,
1392
- };
1393
- }
1394
-
1395
- if (node.localName === "qti-correct") {
1396
- return {
1397
- type: "correct",
1398
- identifier: node.attributes.identifier ?? "",
1399
- source: node.source,
1400
- };
1401
- }
1402
-
1403
- if (node.localName === "qti-default") {
1404
- return {
1405
- type: "default",
1406
- identifier: node.attributes.identifier ?? "",
1407
- source: node.source,
1408
- };
1409
- }
1410
-
1411
- if (node.localName === "qti-variable") {
1412
- return { type: "variable", identifier: node.attributes.identifier ?? "", source: node.source };
1413
- }
1414
-
1415
- if (node.localName === "qti-random-integer") {
1416
- return {
1417
- type: "randomInteger",
1418
- min: Number(node.attributes.min ?? 0),
1419
- max: Number(node.attributes.max ?? 0),
1420
- step: Number(node.attributes.step ?? 1),
1421
- attributes: node.attributes,
1422
- source: node.source,
1423
- };
1424
- }
1425
-
1426
- if (node.localName === "qti-random-float") {
1427
- return {
1428
- type: "randomFloat",
1429
- min: Number(node.attributes.min ?? 0),
1430
- max: Number(node.attributes.max ?? 0),
1431
- attributes: node.attributes,
1432
- source: node.source,
1433
- };
1434
- }
1435
-
1436
- if (node.localName === "qti-random") {
1437
- const multiple = childElements(node, "qti-multiple")[0];
1438
- return {
1439
- type: "random",
1440
- values: childElements(multiple ?? node)
1441
- .map(parseExpression)
1442
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1443
- source: node.source,
1444
- };
1445
- }
1446
-
1447
- if (node.localName === "qti-multiple") {
1448
- return {
1449
- type: "multiple",
1450
- expressions: childElements(node)
1451
- .map(parseExpression)
1452
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1453
- source: node.source,
1454
- };
1455
- }
1456
-
1457
- if (node.localName === "qti-ordered") {
1458
- return {
1459
- type: "ordered",
1460
- expressions: childElements(node)
1461
- .map(parseExpression)
1462
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1463
- source: node.source,
1464
- };
1465
- }
1466
-
1467
- if (node.localName === "qti-index") {
1468
- const expression = parseFirstExpression(node);
1469
- if (expression) {
1470
- return { type: "index", expression, n: node.attributes.n ?? "", source: node.source };
1471
- }
1472
- }
1473
-
1474
- if (node.localName === "qti-container-size") {
1475
- const expression = parseFirstExpression(node);
1476
- if (expression) return { type: "containerSize", expression, source: node.source };
1477
- }
1478
-
1479
- if (node.localName === "qti-sum") {
1480
- return {
1481
- type: "sum",
1482
- expressions: childElements(node)
1483
- .map(parseExpression)
1484
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1485
- source: node.source,
1486
- };
1487
- }
1488
-
1489
- if (node.localName === "qti-product") {
1490
- return {
1491
- type: "product",
1492
- expressions: childElements(node)
1493
- .map(parseExpression)
1494
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1495
- source: node.source,
1496
- };
1497
- }
1498
-
1499
- if (node.localName === "qti-min") {
1500
- return {
1501
- type: "min",
1502
- expressions: childElements(node)
1503
- .map(parseExpression)
1504
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1505
- source: node.source,
1506
- };
1507
- }
1508
-
1509
- if (node.localName === "qti-max") {
1510
- return {
1511
- type: "max",
1512
- expressions: childElements(node)
1513
- .map(parseExpression)
1514
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1515
- source: node.source,
1516
- };
1517
- }
1518
-
1519
- if (node.localName === "qti-subtract") {
1520
- const expressions = childElements(node)
1521
- .map(parseExpression)
1522
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1523
- const [left, right] = expressions;
1524
- if (left && right) return { type: "subtract", left, right, source: node.source };
1525
- }
1526
-
1527
- if (node.localName === "qti-divide") {
1528
- const expressions = childElements(node)
1529
- .map(parseExpression)
1530
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1531
- const [left, right] = expressions;
1532
- if (left && right) return { type: "divide", left, right, source: node.source };
1533
- }
1534
-
1535
- if (node.localName === "qti-power") {
1536
- const expressions = childElements(node)
1537
- .map(parseExpression)
1538
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1539
- const [left, right] = expressions;
1540
- if (left && right) return { type: "power", left, right, source: node.source };
1541
- }
1542
-
1543
- if (node.localName === "qti-integer-divide") {
1544
- const expressions = childElements(node)
1545
- .map(parseExpression)
1546
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1547
- const [left, right] = expressions;
1548
- if (left && right) return { type: "integerDivide", left, right, source: node.source };
1549
- }
1550
-
1551
- if (node.localName === "qti-integer-modulus") {
1552
- const expressions = childElements(node)
1553
- .map(parseExpression)
1554
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1555
- const [left, right] = expressions;
1556
- if (left && right) return { type: "integerModulus", left, right, source: node.source };
1557
- }
1558
-
1559
- if (node.localName === "qti-round") {
1560
- const expression = parseFirstExpression(node);
1561
- if (expression) return { type: "round", expression, source: node.source };
1562
- }
1563
-
1564
- if (node.localName === "qti-round-to") {
1565
- const expression = parseFirstExpression(node);
1566
- const roundingMode = node.attributes["rounding-mode"];
1567
- const figures = Number(node.attributes.figures ?? 0);
1568
- if (
1569
- expression &&
1570
- (roundingMode === "decimalPlaces" || roundingMode === "significantFigures") &&
1571
- Number.isInteger(figures) &&
1572
- (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0)
1573
- ) {
1574
- return { type: "roundTo", expression, roundingMode, figures, source: node.source };
1575
- }
1576
- }
1577
-
1578
- if (node.localName === "qti-truncate") {
1579
- const expression = parseFirstExpression(node);
1580
- if (expression) return { type: "truncate", expression, source: node.source };
1581
- }
1582
-
1583
- if (node.localName === "qti-integer-to-float") {
1584
- const expression = parseFirstExpression(node);
1585
- if (expression) return { type: "integerToFloat", expression, source: node.source };
1586
- }
1587
-
1588
- if (node.localName === "qti-and") {
1589
- return {
1590
- type: "and",
1591
- expressions: childElements(node)
1592
- .map(parseExpression)
1593
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1594
- source: node.source,
1595
- };
1596
- }
1597
-
1598
- if (node.localName === "qti-any-n") {
1599
- return {
1600
- type: "anyN",
1601
- min: node.attributes.min ?? "",
1602
- max: node.attributes.max ?? "",
1603
- expressions: childElements(node)
1604
- .map(parseExpression)
1605
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1606
- source: node.source,
1607
- };
1608
- }
1609
-
1610
- if (node.localName === "qti-or") {
1611
- return {
1612
- type: "or",
1613
- expressions: childElements(node)
1614
- .map(parseExpression)
1615
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1616
- source: node.source,
1617
- };
1618
- }
1619
-
1620
- if (node.localName === "qti-not") {
1621
- const expression = parseFirstExpression(node);
1622
- if (expression) return { type: "not", expression, source: node.source };
1623
- }
1624
-
1625
- if (node.localName === "qti-equal") {
1626
- const [left, right] = childElements(node)
1627
- .map(parseExpression)
1628
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1629
- if (left && right) return { type: "equal", left, right, source: node.source };
1630
- }
1631
-
1632
- if (node.localName === "qti-equal-rounded") {
1633
- const [left, right] = childElements(node)
1634
- .map(parseExpression)
1635
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1636
- const roundingMode = node.attributes["rounding-mode"] ?? "";
1637
- const figures = Number(node.attributes.figures ?? 0);
1638
- if (left && right) {
1639
- return { type: "equalRounded", left, right, roundingMode, figures, source: node.source };
1640
- }
1641
- }
1642
-
1643
- const numericCompareOperator = numericCompareOperatorFor(node.localName);
1644
- if (numericCompareOperator) {
1645
- const [left, right] = childElements(node)
1646
- .map(parseExpression)
1647
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1648
- if (left && right) {
1649
- return {
1650
- type: "numericCompare",
1651
- operator: numericCompareOperator,
1652
- left,
1653
- right,
1654
- source: node.source,
1655
- };
1656
- }
1657
- }
1658
-
1659
- const durationCompareOperator = durationCompareOperatorFor(node.localName);
1660
- if (durationCompareOperator) {
1661
- const [left, right] = childElements(node)
1662
- .map(parseExpression)
1663
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1664
- if (left && right) {
1665
- return {
1666
- type: "durationCompare",
1667
- operator: durationCompareOperator,
1668
- left,
1669
- right,
1670
- source: node.source,
1671
- };
1672
- }
1673
- }
1674
-
1675
- if (node.localName === "qti-string-match") {
1676
- const [left, right] = childElements(node)
1677
- .map(parseExpression)
1678
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1679
- if (left && right) {
1680
- return {
1681
- type: "stringMatch",
1682
- left,
1683
- right,
1684
- caseSensitive: node.attributes["case-sensitive"] !== "false",
1685
- substring: node.attributes.substring === "true",
1686
- source: node.source,
1687
- };
1688
- }
1689
- }
1690
-
1691
- if (node.localName === "qti-substring") {
1692
- const [left, right] = childElements(node)
1693
- .map(parseExpression)
1694
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1695
- if (left && right) {
1696
- return {
1697
- type: "substring",
1698
- left,
1699
- right,
1700
- caseSensitive: node.attributes["case-sensitive"] !== "false",
1701
- source: node.source,
1702
- };
1703
- }
1704
- }
1705
-
1706
- if (node.localName === "qti-pattern-match") {
1707
- const expression = parseFirstExpression(node);
1708
- if (expression) {
1709
- return {
1710
- type: "patternMatch",
1711
- expression,
1712
- pattern: node.attributes.pattern ?? "",
1713
- source: node.source,
1714
- };
1715
- }
1716
- }
1717
-
1718
- if (node.localName === "qti-field-value") {
1719
- const expression = parseFirstExpression(node);
1720
- if (expression) {
1721
- return {
1722
- type: "fieldValue",
1723
- fieldIdentifier: node.attributes["field-identifier"] ?? "",
1724
- expression,
1725
- source: node.source,
1726
- };
1727
- }
1728
- }
1729
-
1730
- if (node.localName === "qti-member") {
1731
- const [value, collection] = childElements(node)
1732
- .map(parseExpression)
1733
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1734
- if (value && collection) return { type: "member", value, collection, source: node.source };
1735
- }
1736
-
1737
- if (node.localName === "qti-delete") {
1738
- const [value, collection] = childElements(node)
1739
- .map(parseExpression)
1740
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1741
- if (value && collection) return { type: "delete", value, collection, source: node.source };
1742
- }
1743
-
1744
- if (node.localName === "qti-contains") {
1745
- const [collection, values] = childElements(node)
1746
- .map(parseExpression)
1747
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1748
- if (collection && values) return { type: "contains", collection, values, source: node.source };
1749
- }
1750
-
1751
- if (node.localName === "qti-gcd" || node.localName === "qti-lcm") {
1752
- return {
1753
- type: node.localName === "qti-gcd" ? "gcd" : "lcm",
1754
- expressions: childElements(node)
1755
- .map(parseExpression)
1756
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1757
- source: node.source,
1758
- };
1759
- }
1760
-
1761
- if (node.localName === "qti-inside") {
1762
- const expression = parseFirstExpression(node);
1763
- if (expression) {
1764
- return {
1765
- type: "inside",
1766
- expression,
1767
- shape: parseShape(node.attributes.shape),
1768
- coords: parseCoords(node.attributes.coords),
1769
- attributes: node.attributes,
1770
- source: node.source,
1771
- };
1772
- }
1773
- }
1774
-
1775
- if (node.localName === "qti-math-constant") {
1776
- return { type: "mathConstant", name: node.attributes.name ?? "", source: node.source };
1777
- }
1778
-
1779
- if (node.localName === "qti-math-operator") {
1780
- return {
1781
- type: "mathOperator",
1782
- name: node.attributes.name ?? "",
1783
- expressions: childElements(node)
1784
- .map(parseExpression)
1785
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1786
- source: node.source,
1787
- };
1788
- }
1789
-
1790
- if (node.localName === "qti-repeat") {
1791
- return {
1792
- type: "repeat",
1793
- numberRepeats: node.attributes["number-repeats"] ?? "",
1794
- expressions: childElements(node)
1795
- .map(parseExpression)
1796
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1797
- source: node.source,
1798
- };
1799
- }
1800
-
1801
- if (node.localName === "qti-stats-operator") {
1802
- const expression = parseFirstExpression(node);
1803
- if (expression) {
1804
- return {
1805
- type: "statsOperator",
1806
- name: node.attributes.name ?? "",
1807
- expression,
1808
- source: node.source,
1809
- };
1810
- }
1811
- }
1812
-
1813
- if (node.localName === "qti-custom-operator") {
1814
- return {
1815
- type: "customOperator",
1816
- definition: node.attributes.definition,
1817
- className: node.attributes.class,
1818
- attributes: node.attributes,
1819
- expressions: childElements(node)
1820
- .map(parseExpression)
1821
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
1822
- source: node.source,
1823
- };
1824
- }
1825
-
1826
- if (node.localName === "qti-match") {
1827
- const variable = childElements(node, "qti-variable")[0];
1828
- const correct = childElements(node, "qti-correct")[0];
1829
- if (variable && correct) {
1830
- return {
1831
- type: "matchCorrect",
1832
- identifier: variable?.attributes.identifier ?? "",
1833
- correctIdentifier: correct?.attributes.identifier ?? "",
1834
- source: node.source,
1835
- };
1836
- }
1837
- const [left, right] = childElements(node)
1838
- .map(parseExpression)
1839
- .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
1840
- if (left && right) return { type: "match", left, right, source: node.source };
1841
- }
1842
-
1843
- return undefined;
1844
- }
1845
-
1846
- function numericCompareOperatorFor(localName: string): "lt" | "lte" | "gt" | "gte" | undefined {
1847
- if (localName === "qti-lt") return "lt";
1848
- if (localName === "qti-lte") return "lte";
1849
- if (localName === "qti-gt") return "gt";
1850
- if (localName === "qti-gte") return "gte";
1851
- return undefined;
1852
- }
1853
-
1854
- function durationCompareOperatorFor(localName: string): "lt" | "gte" | undefined {
1855
- if (localName === "qti-duration-lt") return "lt";
1856
- if (localName === "qti-duration-gte") return "gte";
1857
- return undefined;
1858
- }
1859
-
1860
- function coerceValue(value: string, baseType: string | undefined): QtiScalarValue {
1861
- if (baseType === "integer") return Number.parseInt(value, 10);
1862
- if (baseType === "float") return Number.parseFloat(value);
1863
- if (baseType === "boolean") {
1864
- if (value === "true") return true;
1865
- if (value === "false") return false;
1866
- }
1867
- return value;
1868
- }
1869
-
1870
- function parseCardinality(value: string | undefined): QtiCardinality {
1871
- if (value === "multiple" || value === "ordered" || value === "record") return value;
1872
- return "single";
1873
- }
1874
-
1875
- function parseXmlBoolean(value: string | undefined): boolean | undefined {
1876
- if (value === "true" || value === "1") return true;
1877
- if (value === "false" || value === "0") return false;
1878
- return undefined;
1879
- }
1880
-
1881
- function normalizeValueForCardinality(value: QtiValue, cardinality: QtiCardinality): QtiValue {
1882
- if (
1883
- (cardinality === "multiple" || cardinality === "ordered") &&
1884
- value !== null &&
1885
- !Array.isArray(value) &&
1886
- !isRecordValue(value)
1887
- ) {
1888
- return [value];
1889
- }
1890
- return value;
1891
- }
1892
-
1893
- function isRecordValue(value: QtiValue): value is QtiRecordValue {
1894
- return typeof value === "object" && value !== null && !Array.isArray(value);
1895
- }