@longsightgroup/qti3-core 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +7 -1
  3. package/dist/content-text.d.ts.map +1 -1
  4. package/dist/content-text.js +14 -0
  5. package/dist/content-text.js.map +1 -1
  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 +15 -1083
  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 +52 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +327 -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 +160 -67
  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 +130 -0
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +32 -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 -272
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +20 -1
  95. package/src/index.ts +68 -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 +36 -1227
  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 +438 -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 +210 -0
  114. package/src/shared-vocabulary-support.ts +200 -127
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +144 -0
  117. package/src/types.ts +42 -7
  118. package/src/validation.ts +33 -341
  119. package/src/xml.ts +376 -276
@@ -0,0 +1,701 @@
1
+ import { coerceValue, parseCoords, parseShape } from "./parser-values.js";
2
+ import { responseConditionsFromRules } from "./processing-rules.js";
3
+ import type {
4
+ QtiLookupOutcomeValue,
5
+ QtiProcessingExpression,
6
+ QtiResponseCondition,
7
+ QtiResponseProcessing,
8
+ QtiResponseRule,
9
+ QtiSetOutcomeValue,
10
+ QtiTemplateProcessing,
11
+ QtiTemplateRule,
12
+ } from "./types.js";
13
+ import { childElements, textContent, type XmlNode } from "./xml.js";
14
+
15
+ export function parseResponseProcessing(
16
+ node: XmlNode | undefined,
17
+ ): QtiResponseProcessing | undefined {
18
+ if (!node) return undefined;
19
+ const rules = parseResponseRules(node);
20
+ return {
21
+ template: node.attributes.template,
22
+ rules,
23
+ conditions: responseConditionsFromRules(rules),
24
+ };
25
+ }
26
+
27
+ export function parseTemplateProcessing(
28
+ node: XmlNode | undefined,
29
+ ): QtiTemplateProcessing | undefined {
30
+ if (!node) return undefined;
31
+ return {
32
+ rules: parseTemplateRules(node),
33
+ };
34
+ }
35
+
36
+ function parseTemplateRules(node: XmlNode): QtiTemplateRule[] {
37
+ return childElements(node)
38
+ .map(parseTemplateRule)
39
+ .filter((rule): rule is QtiTemplateRule => rule !== undefined);
40
+ }
41
+
42
+ function parseTemplateRule(node: XmlNode): QtiTemplateRule | undefined {
43
+ if (node.localName === "qti-set-template-value") {
44
+ return {
45
+ type: "setTemplateValue",
46
+ identifier: node.attributes.identifier ?? "",
47
+ expression: parseFirstExpression(node) ?? {
48
+ type: "baseValue",
49
+ value: null,
50
+ source: node.source,
51
+ },
52
+ source: node.source,
53
+ };
54
+ }
55
+
56
+ if (node.localName === "qti-set-default-value") {
57
+ return {
58
+ type: "setDefaultValue",
59
+ identifier: node.attributes.identifier ?? "",
60
+ expression: parseFirstExpression(node) ?? {
61
+ type: "baseValue",
62
+ value: null,
63
+ source: node.source,
64
+ },
65
+ source: node.source,
66
+ };
67
+ }
68
+
69
+ if (node.localName === "qti-set-correct-response") {
70
+ return {
71
+ type: "setCorrectResponse",
72
+ identifier: node.attributes.identifier ?? "",
73
+ expression: parseFirstExpression(node) ?? {
74
+ type: "baseValue",
75
+ value: null,
76
+ source: node.source,
77
+ },
78
+ source: node.source,
79
+ };
80
+ }
81
+
82
+ if (node.localName === "qti-template-condition") {
83
+ const templateIf = childElements(node, "qti-template-if")[0];
84
+ const templateElse = childElements(node, "qti-template-else")[0];
85
+ return {
86
+ type: "templateCondition",
87
+ ifExpression: templateIf ? parseFirstExpression(templateIf) : undefined,
88
+ thenRules: templateIf ? parseTemplateRules(templateIf) : [],
89
+ elseIfs: childElements(node, "qti-template-else-if").map((branch) => ({
90
+ expression: parseFirstExpression(branch),
91
+ rules: parseTemplateRules(branch),
92
+ })),
93
+ elseRules: templateElse ? parseTemplateRules(templateElse) : [],
94
+ source: node.source,
95
+ };
96
+ }
97
+
98
+ if (node.localName === "qti-exit-template") {
99
+ return {
100
+ type: "exitTemplate",
101
+ source: node.source,
102
+ };
103
+ }
104
+
105
+ if (node.localName === "qti-template-constraint") {
106
+ return {
107
+ type: "templateConstraint",
108
+ expression: parseFirstExpression(node) ?? {
109
+ type: "baseValue",
110
+ value: null,
111
+ source: node.source,
112
+ },
113
+ source: node.source,
114
+ };
115
+ }
116
+
117
+ return undefined;
118
+ }
119
+
120
+ function parseResponseCondition(node: XmlNode): QtiResponseCondition {
121
+ const responseIf = childElements(node, "qti-response-if")[0];
122
+ const responseElse = childElements(node, "qti-response-else")[0];
123
+ return {
124
+ ifExpression: responseIf ? parseFirstExpression(responseIf) : undefined,
125
+ thenRules: responseIf ? parseResponseRules(responseIf) : [],
126
+ elseIfs: childElements(node, "qti-response-else-if").map((branch) => ({
127
+ expression: parseFirstExpression(branch),
128
+ rules: parseResponseRules(branch),
129
+ })),
130
+ elseRules: responseElse ? parseResponseRules(responseElse) : [],
131
+ };
132
+ }
133
+
134
+ function parseResponseRules(node: XmlNode): QtiResponseRule[] {
135
+ return childElements(node)
136
+ .map(parseResponseRule)
137
+ .filter((rule): rule is QtiResponseRule => rule !== undefined);
138
+ }
139
+
140
+ function parseResponseRule(node: XmlNode): QtiResponseRule | undefined {
141
+ if (node.localName === "qti-response-condition") {
142
+ return {
143
+ type: "responseCondition",
144
+ condition: parseResponseCondition(node),
145
+ source: node.source,
146
+ };
147
+ }
148
+ if (node.localName === "qti-set-outcome-value") return parseSetOutcomeValue(node);
149
+ if (node.localName === "qti-lookup-outcome-value") return parseLookupOutcomeValue(node);
150
+ if (node.localName === "qti-exit-response") {
151
+ return { type: "exitResponse", source: node.source };
152
+ }
153
+ if (node.localName === "qti-response-processing-fragment") {
154
+ return {
155
+ type: "responseProcessingFragment",
156
+ rules: parseResponseRules(node),
157
+ source: node.source,
158
+ };
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ function parseLookupOutcomeValue(node: XmlNode): QtiLookupOutcomeValue {
164
+ return {
165
+ type: "lookupOutcomeValue",
166
+ identifier: node.attributes.identifier ?? "",
167
+ expression: parseFirstExpression(node) ?? {
168
+ type: "baseValue",
169
+ value: null,
170
+ source: node.source,
171
+ },
172
+ source: node.source,
173
+ };
174
+ }
175
+
176
+ function parseSetOutcomeValue(setNode: XmlNode): QtiSetOutcomeValue {
177
+ return {
178
+ type: "setOutcomeValue",
179
+ identifier: setNode.attributes.identifier ?? "",
180
+ expression: parseFirstExpression(setNode) ?? {
181
+ type: "baseValue",
182
+ value: null,
183
+ source: setNode.source,
184
+ },
185
+ source: setNode.source,
186
+ };
187
+ }
188
+
189
+ function parseFirstExpression(node: XmlNode): QtiProcessingExpression | undefined {
190
+ for (const child of node.children) {
191
+ const expression = parseExpression(child);
192
+ if (expression) return expression;
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ function parseExpression(node: XmlNode): QtiProcessingExpression | undefined {
198
+ if (node.localName === "qti-base-value") {
199
+ const rawValue = textContent(node);
200
+ return {
201
+ type: "baseValue",
202
+ value: coerceValue(rawValue, node.attributes["base-type"]),
203
+ rawValue,
204
+ baseType: node.attributes["base-type"],
205
+ source: node.source,
206
+ };
207
+ }
208
+
209
+ if (node.localName === "qti-null") {
210
+ return { type: "null", source: node.source };
211
+ }
212
+
213
+ if (node.localName === "qti-is-null") {
214
+ const variable = childElements(node, "qti-variable")[0];
215
+ return {
216
+ type: "isNull",
217
+ identifier: variable?.attributes.identifier ?? "",
218
+ source: node.source,
219
+ };
220
+ }
221
+
222
+ if (node.localName === "qti-map-response") {
223
+ return {
224
+ type: "mapResponse",
225
+ identifier: node.attributes.identifier ?? "",
226
+ source: node.source,
227
+ };
228
+ }
229
+
230
+ if (node.localName === "qti-map-response-point") {
231
+ return {
232
+ type: "mapResponsePoint",
233
+ identifier: node.attributes.identifier ?? "",
234
+ source: node.source,
235
+ };
236
+ }
237
+
238
+ if (node.localName === "qti-correct") {
239
+ return {
240
+ type: "correct",
241
+ identifier: node.attributes.identifier ?? "",
242
+ source: node.source,
243
+ };
244
+ }
245
+
246
+ if (node.localName === "qti-default") {
247
+ return {
248
+ type: "default",
249
+ identifier: node.attributes.identifier ?? "",
250
+ source: node.source,
251
+ };
252
+ }
253
+
254
+ if (node.localName === "qti-variable") {
255
+ return { type: "variable", identifier: node.attributes.identifier ?? "", source: node.source };
256
+ }
257
+
258
+ if (node.localName === "qti-random-integer") {
259
+ return {
260
+ type: "randomInteger",
261
+ min: Number(node.attributes.min ?? 0),
262
+ max: Number(node.attributes.max ?? 0),
263
+ step: Number(node.attributes.step ?? 1),
264
+ attributes: node.attributes,
265
+ source: node.source,
266
+ };
267
+ }
268
+
269
+ if (node.localName === "qti-random-float") {
270
+ return {
271
+ type: "randomFloat",
272
+ min: Number(node.attributes.min ?? 0),
273
+ max: Number(node.attributes.max ?? 0),
274
+ attributes: node.attributes,
275
+ source: node.source,
276
+ };
277
+ }
278
+
279
+ if (node.localName === "qti-random") {
280
+ const multiple = childElements(node, "qti-multiple")[0];
281
+ return {
282
+ type: "random",
283
+ values: childElements(multiple ?? node)
284
+ .map(parseExpression)
285
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
286
+ source: node.source,
287
+ };
288
+ }
289
+
290
+ if (node.localName === "qti-multiple") {
291
+ return {
292
+ type: "multiple",
293
+ expressions: childElements(node)
294
+ .map(parseExpression)
295
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
296
+ source: node.source,
297
+ };
298
+ }
299
+
300
+ if (node.localName === "qti-ordered") {
301
+ return {
302
+ type: "ordered",
303
+ expressions: childElements(node)
304
+ .map(parseExpression)
305
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
306
+ source: node.source,
307
+ };
308
+ }
309
+
310
+ if (node.localName === "qti-index") {
311
+ const expression = parseFirstExpression(node);
312
+ if (expression) {
313
+ return { type: "index", expression, n: node.attributes.n ?? "", source: node.source };
314
+ }
315
+ }
316
+
317
+ if (node.localName === "qti-container-size") {
318
+ const expression = parseFirstExpression(node);
319
+ if (expression) return { type: "containerSize", expression, source: node.source };
320
+ }
321
+
322
+ if (node.localName === "qti-sum") {
323
+ return {
324
+ type: "sum",
325
+ expressions: childElements(node)
326
+ .map(parseExpression)
327
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
328
+ source: node.source,
329
+ };
330
+ }
331
+
332
+ if (node.localName === "qti-product") {
333
+ return {
334
+ type: "product",
335
+ expressions: childElements(node)
336
+ .map(parseExpression)
337
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
338
+ source: node.source,
339
+ };
340
+ }
341
+
342
+ if (node.localName === "qti-min") {
343
+ return {
344
+ type: "min",
345
+ expressions: childElements(node)
346
+ .map(parseExpression)
347
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
348
+ source: node.source,
349
+ };
350
+ }
351
+
352
+ if (node.localName === "qti-max") {
353
+ return {
354
+ type: "max",
355
+ expressions: childElements(node)
356
+ .map(parseExpression)
357
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
358
+ source: node.source,
359
+ };
360
+ }
361
+
362
+ if (node.localName === "qti-subtract") {
363
+ const expressions = childElements(node)
364
+ .map(parseExpression)
365
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
366
+ const [left, right] = expressions;
367
+ if (left && right) return { type: "subtract", left, right, source: node.source };
368
+ }
369
+
370
+ if (node.localName === "qti-divide") {
371
+ const expressions = childElements(node)
372
+ .map(parseExpression)
373
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
374
+ const [left, right] = expressions;
375
+ if (left && right) return { type: "divide", left, right, source: node.source };
376
+ }
377
+
378
+ if (node.localName === "qti-power") {
379
+ const expressions = childElements(node)
380
+ .map(parseExpression)
381
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
382
+ const [left, right] = expressions;
383
+ if (left && right) return { type: "power", left, right, source: node.source };
384
+ }
385
+
386
+ if (node.localName === "qti-integer-divide") {
387
+ const expressions = childElements(node)
388
+ .map(parseExpression)
389
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
390
+ const [left, right] = expressions;
391
+ if (left && right) return { type: "integerDivide", left, right, source: node.source };
392
+ }
393
+
394
+ if (node.localName === "qti-integer-modulus") {
395
+ const expressions = childElements(node)
396
+ .map(parseExpression)
397
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
398
+ const [left, right] = expressions;
399
+ if (left && right) return { type: "integerModulus", left, right, source: node.source };
400
+ }
401
+
402
+ if (node.localName === "qti-round") {
403
+ const expression = parseFirstExpression(node);
404
+ if (expression) return { type: "round", expression, source: node.source };
405
+ }
406
+
407
+ if (node.localName === "qti-round-to") {
408
+ const expression = parseFirstExpression(node);
409
+ const roundingMode = node.attributes["rounding-mode"];
410
+ const figures = Number(node.attributes.figures ?? 0);
411
+ if (
412
+ expression &&
413
+ (roundingMode === "decimalPlaces" || roundingMode === "significantFigures") &&
414
+ Number.isInteger(figures) &&
415
+ (roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0)
416
+ ) {
417
+ return { type: "roundTo", expression, roundingMode, figures, source: node.source };
418
+ }
419
+ }
420
+
421
+ if (node.localName === "qti-truncate") {
422
+ const expression = parseFirstExpression(node);
423
+ if (expression) return { type: "truncate", expression, source: node.source };
424
+ }
425
+
426
+ if (node.localName === "qti-integer-to-float") {
427
+ const expression = parseFirstExpression(node);
428
+ if (expression) return { type: "integerToFloat", expression, source: node.source };
429
+ }
430
+
431
+ if (node.localName === "qti-and") {
432
+ return {
433
+ type: "and",
434
+ expressions: childElements(node)
435
+ .map(parseExpression)
436
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
437
+ source: node.source,
438
+ };
439
+ }
440
+
441
+ if (node.localName === "qti-any-n") {
442
+ return {
443
+ type: "anyN",
444
+ min: node.attributes.min ?? "",
445
+ max: node.attributes.max ?? "",
446
+ expressions: childElements(node)
447
+ .map(parseExpression)
448
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
449
+ source: node.source,
450
+ };
451
+ }
452
+
453
+ if (node.localName === "qti-or") {
454
+ return {
455
+ type: "or",
456
+ expressions: childElements(node)
457
+ .map(parseExpression)
458
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
459
+ source: node.source,
460
+ };
461
+ }
462
+
463
+ if (node.localName === "qti-not") {
464
+ const expression = parseFirstExpression(node);
465
+ if (expression) return { type: "not", expression, source: node.source };
466
+ }
467
+
468
+ if (node.localName === "qti-equal") {
469
+ const [left, right] = childElements(node)
470
+ .map(parseExpression)
471
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
472
+ if (left && right) return { type: "equal", left, right, source: node.source };
473
+ }
474
+
475
+ if (node.localName === "qti-equal-rounded") {
476
+ const [left, right] = childElements(node)
477
+ .map(parseExpression)
478
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
479
+ const roundingMode = node.attributes["rounding-mode"] ?? "";
480
+ const figures = Number(node.attributes.figures ?? 0);
481
+ if (left && right) {
482
+ return { type: "equalRounded", left, right, roundingMode, figures, source: node.source };
483
+ }
484
+ }
485
+
486
+ const numericCompareOperator = numericCompareOperatorFor(node.localName);
487
+ if (numericCompareOperator) {
488
+ const [left, right] = childElements(node)
489
+ .map(parseExpression)
490
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
491
+ if (left && right) {
492
+ return {
493
+ type: "numericCompare",
494
+ operator: numericCompareOperator,
495
+ left,
496
+ right,
497
+ source: node.source,
498
+ };
499
+ }
500
+ }
501
+
502
+ const durationCompareOperator = durationCompareOperatorFor(node.localName);
503
+ if (durationCompareOperator) {
504
+ const [left, right] = childElements(node)
505
+ .map(parseExpression)
506
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
507
+ if (left && right) {
508
+ return {
509
+ type: "durationCompare",
510
+ operator: durationCompareOperator,
511
+ left,
512
+ right,
513
+ source: node.source,
514
+ };
515
+ }
516
+ }
517
+
518
+ if (node.localName === "qti-string-match") {
519
+ const [left, right] = childElements(node)
520
+ .map(parseExpression)
521
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
522
+ if (left && right) {
523
+ return {
524
+ type: "stringMatch",
525
+ left,
526
+ right,
527
+ caseSensitive: node.attributes["case-sensitive"] !== "false",
528
+ substring: node.attributes.substring === "true",
529
+ source: node.source,
530
+ };
531
+ }
532
+ }
533
+
534
+ if (node.localName === "qti-substring") {
535
+ const [left, right] = childElements(node)
536
+ .map(parseExpression)
537
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
538
+ if (left && right) {
539
+ return {
540
+ type: "substring",
541
+ left,
542
+ right,
543
+ caseSensitive: node.attributes["case-sensitive"] !== "false",
544
+ source: node.source,
545
+ };
546
+ }
547
+ }
548
+
549
+ if (node.localName === "qti-pattern-match") {
550
+ const expression = parseFirstExpression(node);
551
+ if (expression) {
552
+ return {
553
+ type: "patternMatch",
554
+ expression,
555
+ pattern: node.attributes.pattern ?? "",
556
+ source: node.source,
557
+ };
558
+ }
559
+ }
560
+
561
+ if (node.localName === "qti-field-value") {
562
+ const expression = parseFirstExpression(node);
563
+ if (expression) {
564
+ return {
565
+ type: "fieldValue",
566
+ fieldIdentifier: node.attributes["field-identifier"] ?? "",
567
+ expression,
568
+ source: node.source,
569
+ };
570
+ }
571
+ }
572
+
573
+ if (node.localName === "qti-member") {
574
+ const [value, collection] = childElements(node)
575
+ .map(parseExpression)
576
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
577
+ if (value && collection) return { type: "member", value, collection, source: node.source };
578
+ }
579
+
580
+ if (node.localName === "qti-delete") {
581
+ const [value, collection] = childElements(node)
582
+ .map(parseExpression)
583
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
584
+ if (value && collection) return { type: "delete", value, collection, source: node.source };
585
+ }
586
+
587
+ if (node.localName === "qti-contains") {
588
+ const [collection, values] = childElements(node)
589
+ .map(parseExpression)
590
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
591
+ if (collection && values) return { type: "contains", collection, values, source: node.source };
592
+ }
593
+
594
+ if (node.localName === "qti-gcd" || node.localName === "qti-lcm") {
595
+ return {
596
+ type: node.localName === "qti-gcd" ? "gcd" : "lcm",
597
+ expressions: childElements(node)
598
+ .map(parseExpression)
599
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
600
+ source: node.source,
601
+ };
602
+ }
603
+
604
+ if (node.localName === "qti-inside") {
605
+ const expression = parseFirstExpression(node);
606
+ if (expression) {
607
+ return {
608
+ type: "inside",
609
+ expression,
610
+ shape: parseShape(node.attributes.shape),
611
+ coords: parseCoords(node.attributes.coords),
612
+ attributes: node.attributes,
613
+ source: node.source,
614
+ };
615
+ }
616
+ }
617
+
618
+ if (node.localName === "qti-math-constant") {
619
+ return { type: "mathConstant", name: node.attributes.name ?? "", source: node.source };
620
+ }
621
+
622
+ if (node.localName === "qti-math-operator") {
623
+ return {
624
+ type: "mathOperator",
625
+ name: node.attributes.name ?? "",
626
+ expressions: childElements(node)
627
+ .map(parseExpression)
628
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
629
+ source: node.source,
630
+ };
631
+ }
632
+
633
+ if (node.localName === "qti-repeat") {
634
+ return {
635
+ type: "repeat",
636
+ numberRepeats: node.attributes["number-repeats"] ?? "",
637
+ expressions: childElements(node)
638
+ .map(parseExpression)
639
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
640
+ source: node.source,
641
+ };
642
+ }
643
+
644
+ if (node.localName === "qti-stats-operator") {
645
+ const expression = parseFirstExpression(node);
646
+ if (expression) {
647
+ return {
648
+ type: "statsOperator",
649
+ name: node.attributes.name ?? "",
650
+ expression,
651
+ source: node.source,
652
+ };
653
+ }
654
+ }
655
+
656
+ if (node.localName === "qti-custom-operator") {
657
+ return {
658
+ type: "customOperator",
659
+ definition: node.attributes.definition,
660
+ className: node.attributes.class,
661
+ attributes: node.attributes,
662
+ expressions: childElements(node)
663
+ .map(parseExpression)
664
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined),
665
+ source: node.source,
666
+ };
667
+ }
668
+
669
+ if (node.localName === "qti-match") {
670
+ const variable = childElements(node, "qti-variable")[0];
671
+ const correct = childElements(node, "qti-correct")[0];
672
+ if (variable && correct) {
673
+ return {
674
+ type: "matchCorrect",
675
+ identifier: variable?.attributes.identifier ?? "",
676
+ correctIdentifier: correct?.attributes.identifier ?? "",
677
+ source: node.source,
678
+ };
679
+ }
680
+ const [left, right] = childElements(node)
681
+ .map(parseExpression)
682
+ .filter((expression): expression is QtiProcessingExpression => expression !== undefined);
683
+ if (left && right) return { type: "match", left, right, source: node.source };
684
+ }
685
+
686
+ return undefined;
687
+ }
688
+
689
+ function numericCompareOperatorFor(localName: string): "lt" | "lte" | "gt" | "gte" | undefined {
690
+ if (localName === "qti-lt") return "lt";
691
+ if (localName === "qti-lte") return "lte";
692
+ if (localName === "qti-gt") return "gt";
693
+ if (localName === "qti-gte") return "gte";
694
+ return undefined;
695
+ }
696
+
697
+ function durationCompareOperatorFor(localName: string): "lt" | "gte" | undefined {
698
+ if (localName === "qti-duration-lt") return "lt";
699
+ if (localName === "qti-duration-gte") return "gte";
700
+ return undefined;
701
+ }