@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,469 @@
1
+ import type { QtiProcessingExpression } from "./types.js";
2
+
3
+ const baseOne = {
4
+ type: "baseValue",
5
+ baseType: "integer",
6
+ value: 1,
7
+ } satisfies QtiProcessingExpression;
8
+ const baseTwo = {
9
+ type: "baseValue",
10
+ baseType: "integer",
11
+ value: 2,
12
+ } satisfies QtiProcessingExpression;
13
+ const variableScore = { type: "variable", identifier: "SCORE" } satisfies QtiProcessingExpression;
14
+ const variableResponse = {
15
+ type: "variable",
16
+ identifier: "RESPONSE",
17
+ } satisfies QtiProcessingExpression;
18
+
19
+ export type ExpressionCoverage = {
20
+ [K in QtiProcessingExpression["type"]]: {
21
+ expression: Extract<QtiProcessingExpression, { type: K }>;
22
+ xml: string;
23
+ };
24
+ };
25
+
26
+ export const expressionCoverage = {
27
+ baseValue: {
28
+ expression: baseOne,
29
+ xml: ' <qti-base-value base-type="integer">1</qti-base-value>',
30
+ },
31
+ null: { expression: { type: "null" }, xml: " <qti-null/>" },
32
+ isNull: {
33
+ expression: { type: "isNull", identifier: "RESPONSE" },
34
+ xml: [
35
+ " <qti-is-null>",
36
+ ' <qti-variable identifier="RESPONSE"/>',
37
+ " </qti-is-null>",
38
+ ].join("\n"),
39
+ },
40
+ matchCorrect: {
41
+ expression: {
42
+ type: "matchCorrect",
43
+ identifier: "RESPONSE",
44
+ correctIdentifier: "RESPONSE",
45
+ },
46
+ xml: [
47
+ " <qti-match>",
48
+ ' <qti-variable identifier="RESPONSE"/>',
49
+ ' <qti-correct identifier="RESPONSE"/>',
50
+ " </qti-match>",
51
+ ].join("\n"),
52
+ },
53
+ match: {
54
+ expression: { type: "match", left: variableResponse, right: baseOne },
55
+ xml: [
56
+ " <qti-match>",
57
+ ' <qti-variable identifier="RESPONSE"/>',
58
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
59
+ " </qti-match>",
60
+ ].join("\n"),
61
+ },
62
+ correct: {
63
+ expression: { type: "correct", identifier: "RESPONSE" },
64
+ xml: ' <qti-correct identifier="RESPONSE"/>',
65
+ },
66
+ default: {
67
+ expression: { type: "default", identifier: "SCORE" },
68
+ xml: ' <qti-default identifier="SCORE"/>',
69
+ },
70
+ mapResponse: {
71
+ expression: { type: "mapResponse", identifier: "RESPONSE" },
72
+ xml: ' <qti-map-response identifier="RESPONSE"/>',
73
+ },
74
+ mapResponsePoint: {
75
+ expression: { type: "mapResponsePoint", identifier: "RESPONSE" },
76
+ xml: ' <qti-map-response-point identifier="RESPONSE"/>',
77
+ },
78
+ variable: {
79
+ expression: variableResponse,
80
+ xml: ' <qti-variable identifier="RESPONSE"/>',
81
+ },
82
+ randomInteger: {
83
+ expression: { type: "randomInteger", min: 1, max: 10, step: 1, attributes: {} },
84
+ xml: ' <qti-random-integer min="1" max="10" step="1"/>',
85
+ },
86
+ randomFloat: {
87
+ expression: { type: "randomFloat", min: 0, max: 1, attributes: {} },
88
+ xml: ' <qti-random-float min="0" max="1"/>',
89
+ },
90
+ random: {
91
+ expression: { type: "random", values: [baseOne, baseTwo] },
92
+ xml: [
93
+ " <qti-random>",
94
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
95
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
96
+ " </qti-random>",
97
+ ].join("\n"),
98
+ },
99
+ multiple: {
100
+ expression: { type: "multiple", expressions: [baseOne, baseTwo] },
101
+ xml: [
102
+ " <qti-multiple>",
103
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
104
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
105
+ " </qti-multiple>",
106
+ ].join("\n"),
107
+ },
108
+ ordered: {
109
+ expression: { type: "ordered", expressions: [baseOne, baseTwo] },
110
+ xml: [
111
+ " <qti-ordered>",
112
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
113
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
114
+ " </qti-ordered>",
115
+ ].join("\n"),
116
+ },
117
+ index: {
118
+ expression: { type: "index", expression: variableResponse, n: "1" },
119
+ xml: [
120
+ ' <qti-index n="1">',
121
+ ' <qti-variable identifier="RESPONSE"/>',
122
+ " </qti-index>",
123
+ ].join("\n"),
124
+ },
125
+ containerSize: {
126
+ expression: { type: "containerSize", expression: variableResponse },
127
+ xml: [
128
+ " <qti-container-size>",
129
+ ' <qti-variable identifier="RESPONSE"/>',
130
+ " </qti-container-size>",
131
+ ].join("\n"),
132
+ },
133
+ sum: {
134
+ expression: { type: "sum", expressions: [baseOne, baseTwo] },
135
+ xml: [
136
+ " <qti-sum>",
137
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
138
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
139
+ " </qti-sum>",
140
+ ].join("\n"),
141
+ },
142
+ product: {
143
+ expression: { type: "product", expressions: [baseOne, baseTwo] },
144
+ xml: [
145
+ " <qti-product>",
146
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
147
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
148
+ " </qti-product>",
149
+ ].join("\n"),
150
+ },
151
+ min: {
152
+ expression: { type: "min", expressions: [baseOne, baseTwo] },
153
+ xml: [
154
+ " <qti-min>",
155
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
156
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
157
+ " </qti-min>",
158
+ ].join("\n"),
159
+ },
160
+ max: {
161
+ expression: { type: "max", expressions: [baseOne, baseTwo] },
162
+ xml: [
163
+ " <qti-max>",
164
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
165
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
166
+ " </qti-max>",
167
+ ].join("\n"),
168
+ },
169
+ subtract: {
170
+ expression: { type: "subtract", left: baseTwo, right: baseOne },
171
+ xml: [
172
+ " <qti-subtract>",
173
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
174
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
175
+ " </qti-subtract>",
176
+ ].join("\n"),
177
+ },
178
+ divide: {
179
+ expression: { type: "divide", left: baseTwo, right: baseOne },
180
+ xml: [
181
+ " <qti-divide>",
182
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
183
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
184
+ " </qti-divide>",
185
+ ].join("\n"),
186
+ },
187
+ power: {
188
+ expression: { type: "power", left: baseTwo, right: baseOne },
189
+ xml: [
190
+ " <qti-power>",
191
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
192
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
193
+ " </qti-power>",
194
+ ].join("\n"),
195
+ },
196
+ integerDivide: {
197
+ expression: { type: "integerDivide", left: baseTwo, right: baseOne },
198
+ xml: [
199
+ " <qti-integer-divide>",
200
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
201
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
202
+ " </qti-integer-divide>",
203
+ ].join("\n"),
204
+ },
205
+ integerModulus: {
206
+ expression: { type: "integerModulus", left: baseTwo, right: baseOne },
207
+ xml: [
208
+ " <qti-integer-modulus>",
209
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
210
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
211
+ " </qti-integer-modulus>",
212
+ ].join("\n"),
213
+ },
214
+ round: {
215
+ expression: { type: "round", expression: baseOne },
216
+ xml: [
217
+ " <qti-round>",
218
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
219
+ " </qti-round>",
220
+ ].join("\n"),
221
+ },
222
+ roundTo: {
223
+ expression: {
224
+ type: "roundTo",
225
+ expression: baseOne,
226
+ roundingMode: "decimalPlaces",
227
+ figures: 1,
228
+ },
229
+ xml: [
230
+ ' <qti-round-to rounding-mode="decimalPlaces" figures="1">',
231
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
232
+ " </qti-round-to>",
233
+ ].join("\n"),
234
+ },
235
+ truncate: {
236
+ expression: { type: "truncate", expression: baseOne },
237
+ xml: [
238
+ " <qti-truncate>",
239
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
240
+ " </qti-truncate>",
241
+ ].join("\n"),
242
+ },
243
+ integerToFloat: {
244
+ expression: { type: "integerToFloat", expression: baseOne },
245
+ xml: [
246
+ " <qti-integer-to-float>",
247
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
248
+ " </qti-integer-to-float>",
249
+ ].join("\n"),
250
+ },
251
+ and: {
252
+ expression: { type: "and", expressions: [variableScore] },
253
+ xml: [" <qti-and>", ' <qti-variable identifier="SCORE"/>', " </qti-and>"].join("\n"),
254
+ },
255
+ anyN: {
256
+ expression: { type: "anyN", min: "1", max: "2", expressions: [variableScore] },
257
+ xml: [
258
+ ' <qti-any-n min="1" max="2">',
259
+ ' <qti-variable identifier="SCORE"/>',
260
+ " </qti-any-n>",
261
+ ].join("\n"),
262
+ },
263
+ or: {
264
+ expression: { type: "or", expressions: [variableScore] },
265
+ xml: [" <qti-or>", ' <qti-variable identifier="SCORE"/>', " </qti-or>"].join("\n"),
266
+ },
267
+ not: {
268
+ expression: { type: "not", expression: variableScore },
269
+ xml: [" <qti-not>", ' <qti-variable identifier="SCORE"/>', " </qti-not>"].join("\n"),
270
+ },
271
+ equal: {
272
+ expression: { type: "equal", left: baseOne, right: baseOne },
273
+ xml: [
274
+ " <qti-equal>",
275
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
276
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
277
+ " </qti-equal>",
278
+ ].join("\n"),
279
+ },
280
+ equalRounded: {
281
+ expression: {
282
+ type: "equalRounded",
283
+ left: baseOne,
284
+ right: baseOne,
285
+ roundingMode: "decimalPlaces",
286
+ figures: 1,
287
+ },
288
+ xml: [
289
+ ' <qti-equal-rounded rounding-mode="decimalPlaces" figures="1">',
290
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
291
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
292
+ " </qti-equal-rounded>",
293
+ ].join("\n"),
294
+ },
295
+ numericCompare: {
296
+ expression: { type: "numericCompare", operator: "gte", left: variableScore, right: baseOne },
297
+ xml: [
298
+ " <qti-gte>",
299
+ ' <qti-variable identifier="SCORE"/>',
300
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
301
+ " </qti-gte>",
302
+ ].join("\n"),
303
+ },
304
+ durationCompare: {
305
+ expression: { type: "durationCompare", operator: "lt", left: variableScore, right: baseOne },
306
+ xml: [
307
+ " <qti-duration-lt>",
308
+ ' <qti-variable identifier="SCORE"/>',
309
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
310
+ " </qti-duration-lt>",
311
+ ].join("\n"),
312
+ },
313
+ stringMatch: {
314
+ expression: {
315
+ type: "stringMatch",
316
+ left: variableResponse,
317
+ right: { type: "baseValue", baseType: "string", value: "A" },
318
+ caseSensitive: false,
319
+ substring: true,
320
+ },
321
+ xml: [
322
+ ' <qti-string-match case-sensitive="false" substring="true">',
323
+ ' <qti-variable identifier="RESPONSE"/>',
324
+ ' <qti-base-value base-type="string">A</qti-base-value>',
325
+ " </qti-string-match>",
326
+ ].join("\n"),
327
+ },
328
+ substring: {
329
+ expression: {
330
+ type: "substring",
331
+ left: variableResponse,
332
+ right: { type: "baseValue", baseType: "string", value: "A" },
333
+ caseSensitive: true,
334
+ },
335
+ xml: [
336
+ ' <qti-substring case-sensitive="true">',
337
+ ' <qti-variable identifier="RESPONSE"/>',
338
+ ' <qti-base-value base-type="string">A</qti-base-value>',
339
+ " </qti-substring>",
340
+ ].join("\n"),
341
+ },
342
+ patternMatch: {
343
+ expression: { type: "patternMatch", expression: variableResponse, pattern: "[A-Z]+" },
344
+ xml: [
345
+ ' <qti-pattern-match pattern="[A-Z]+">',
346
+ ' <qti-variable identifier="RESPONSE"/>',
347
+ " </qti-pattern-match>",
348
+ ].join("\n"),
349
+ },
350
+ fieldValue: {
351
+ expression: { type: "fieldValue", fieldIdentifier: "candidate", expression: variableResponse },
352
+ xml: [
353
+ ' <qti-field-value field-identifier="candidate">',
354
+ ' <qti-variable identifier="RESPONSE"/>',
355
+ " </qti-field-value>",
356
+ ].join("\n"),
357
+ },
358
+ member: {
359
+ expression: { type: "member", value: baseOne, collection: variableResponse },
360
+ xml: [
361
+ " <qti-member>",
362
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
363
+ ' <qti-variable identifier="RESPONSE"/>',
364
+ " </qti-member>",
365
+ ].join("\n"),
366
+ },
367
+ delete: {
368
+ expression: { type: "delete", value: baseOne, collection: variableResponse },
369
+ xml: [
370
+ " <qti-delete>",
371
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
372
+ ' <qti-variable identifier="RESPONSE"/>',
373
+ " </qti-delete>",
374
+ ].join("\n"),
375
+ },
376
+ contains: {
377
+ expression: { type: "contains", collection: variableResponse, values: baseOne },
378
+ xml: [
379
+ " <qti-contains>",
380
+ ' <qti-variable identifier="RESPONSE"/>',
381
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
382
+ " </qti-contains>",
383
+ ].join("\n"),
384
+ },
385
+ gcd: {
386
+ expression: { type: "gcd", expressions: [baseOne, baseTwo] },
387
+ xml: [
388
+ " <qti-gcd>",
389
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
390
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
391
+ " </qti-gcd>",
392
+ ].join("\n"),
393
+ },
394
+ inside: {
395
+ expression: {
396
+ type: "inside",
397
+ shape: "circle",
398
+ coords: [10, 20, 5],
399
+ attributes: { coords: "0,0,0", shape: "rect", tolerance: "2" },
400
+ expression: variableResponse,
401
+ },
402
+ xml: [
403
+ ' <qti-inside shape="rect" coords="0,0,0" tolerance="2">',
404
+ ' <qti-variable identifier="RESPONSE"/>',
405
+ " </qti-inside>",
406
+ ].join("\n"),
407
+ },
408
+ lcm: {
409
+ expression: { type: "lcm", expressions: [baseOne, baseTwo] },
410
+ xml: [
411
+ " <qti-lcm>",
412
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
413
+ ' <qti-base-value base-type="integer">2</qti-base-value>',
414
+ " </qti-lcm>",
415
+ ].join("\n"),
416
+ },
417
+ mathConstant: {
418
+ expression: { type: "mathConstant", name: "pi" },
419
+ xml: ' <qti-math-constant name="pi"/>',
420
+ },
421
+ mathOperator: {
422
+ expression: { type: "mathOperator", name: "sin", expressions: [baseOne] },
423
+ xml: [
424
+ ' <qti-math-operator name="sin">',
425
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
426
+ " </qti-math-operator>",
427
+ ].join("\n"),
428
+ },
429
+ repeat: {
430
+ expression: { type: "repeat", numberRepeats: "2", expressions: [baseOne] },
431
+ xml: [
432
+ ' <qti-repeat number-repeats="2">',
433
+ ' <qti-base-value base-type="integer">1</qti-base-value>',
434
+ " </qti-repeat>",
435
+ ].join("\n"),
436
+ },
437
+ statsOperator: {
438
+ expression: { type: "statsOperator", name: "mean", expression: variableResponse },
439
+ xml: [
440
+ ' <qti-stats-operator name="mean">',
441
+ ' <qti-variable identifier="RESPONSE"/>',
442
+ " </qti-stats-operator>",
443
+ ].join("\n"),
444
+ },
445
+ customOperator: {
446
+ expression: {
447
+ type: "customOperator",
448
+ definition: "https://example.invalid/operator?a=1&b=2",
449
+ className: "demo",
450
+ attributes: { "data-extra": `"quoted"` },
451
+ expressions: [{ type: "null" }],
452
+ },
453
+ xml: [
454
+ ' <qti-custom-operator class="demo" data-extra="&quot;quoted&quot;" definition="https://example.invalid/operator?a=1&amp;b=2">',
455
+ " <qti-null/>",
456
+ " </qti-custom-operator>",
457
+ ].join("\n"),
458
+ },
459
+ } satisfies ExpressionCoverage;
460
+
461
+ export function expressionCoverageXml(expressionType: QtiProcessingExpression["type"]): string {
462
+ return [
463
+ "<qti-response-processing>",
464
+ ' <qti-set-outcome-value identifier="SCORE">',
465
+ expressionCoverage[expressionType].xml,
466
+ " </qti-set-outcome-value>",
467
+ "</qti-response-processing>",
468
+ ].join("\n");
469
+ }
@@ -0,0 +1,196 @@
1
+ import { responseConditionsFromRules } from "./processing-rules.js";
2
+ import { serializeExpression } from "./serializer-processing-expressions.js";
3
+ import {
4
+ addSerializerDiagnostic,
5
+ renderElement,
6
+ type SerializationContext,
7
+ type XmlAttribute,
8
+ } from "./serializer-processing-xml.js";
9
+ import type {
10
+ QtiDiagnostic,
11
+ QtiResponseBranch,
12
+ QtiResponseCondition,
13
+ QtiResponseProcessing,
14
+ QtiResponseRule,
15
+ } from "./types.js";
16
+
17
+ export interface QtiSerializeResponseProcessingResult {
18
+ ok: boolean;
19
+ xml?: string | undefined;
20
+ diagnostics: QtiDiagnostic[];
21
+ }
22
+
23
+ export function serializeResponseProcessing(
24
+ processing: QtiResponseProcessing,
25
+ ): QtiSerializeResponseProcessingResult {
26
+ const context: SerializationContext = { diagnostics: [] };
27
+ const rules = responseProcessingRules(processing, context);
28
+
29
+ const attrs: XmlAttribute[] = [];
30
+ if (processing.template !== undefined) attrs.push(["template", processing.template]);
31
+
32
+ const body = rules.flatMap((rule) => serializeResponseRule(rule, context, 1));
33
+ const hasErrors = context.diagnostics.some((diagnostic) => diagnostic.severity === "error");
34
+
35
+ if (hasErrors) {
36
+ return { ok: false, diagnostics: context.diagnostics };
37
+ }
38
+
39
+ return {
40
+ ok: true,
41
+ xml: renderElement("qti-response-processing", attrs, body, 0).join("\n"),
42
+ diagnostics: context.diagnostics,
43
+ };
44
+ }
45
+
46
+ function responseProcessingRules(
47
+ processing: QtiResponseProcessing,
48
+ context: SerializationContext,
49
+ ): QtiResponseRule[] {
50
+ if (processing.conditions.length === 0) return processing.rules;
51
+
52
+ const flattened = responseConditionsFromRules(processing.rules);
53
+ if (sameConditionReferences(flattened, processing.conditions)) return processing.rules;
54
+
55
+ if (processing.rules.length === 0) {
56
+ return processing.conditions.map((condition) => ({ type: "responseCondition", condition }));
57
+ }
58
+
59
+ addSerializerDiagnostic(context, "responseProcessing.serialize.conditions", {
60
+ message:
61
+ "QtiResponseProcessing.conditions must be represented as responseCondition rules when rules are present.",
62
+ });
63
+ return processing.rules;
64
+ }
65
+
66
+ function sameConditionReferences(
67
+ left: QtiResponseCondition[],
68
+ right: QtiResponseCondition[],
69
+ ): boolean {
70
+ if (left.length !== right.length) return false;
71
+ return left.every((condition, index) => condition === right[index]);
72
+ }
73
+
74
+ function serializeResponseRule(
75
+ rule: QtiResponseRule,
76
+ context: SerializationContext,
77
+ indent: number,
78
+ ): string[] {
79
+ switch (rule.type) {
80
+ case "setOutcomeValue": {
81
+ const expressionXml = serializeExpression(rule.expression, context, indent + 1);
82
+ if (!requireRuleIdentifier(rule.identifier, "qti-set-outcome-value", rule.source, context)) {
83
+ return [];
84
+ }
85
+ return renderElement(
86
+ "qti-set-outcome-value",
87
+ [["identifier", rule.identifier]],
88
+ expressionXml,
89
+ indent,
90
+ );
91
+ }
92
+ case "lookupOutcomeValue": {
93
+ const expressionXml = serializeExpression(rule.expression, context, indent + 1);
94
+ if (
95
+ !requireRuleIdentifier(rule.identifier, "qti-lookup-outcome-value", rule.source, context)
96
+ ) {
97
+ return [];
98
+ }
99
+ return renderElement(
100
+ "qti-lookup-outcome-value",
101
+ [["identifier", rule.identifier]],
102
+ expressionXml,
103
+ indent,
104
+ );
105
+ }
106
+ case "exitResponse":
107
+ return renderElement("qti-exit-response", [], [], indent);
108
+ case "responseCondition":
109
+ return serializeResponseCondition(rule.condition, context, indent);
110
+ case "responseProcessingFragment":
111
+ return renderElement(
112
+ "qti-response-processing-fragment",
113
+ [],
114
+ rule.rules.flatMap((child) => serializeResponseRule(child, context, indent + 1)),
115
+ indent,
116
+ );
117
+ }
118
+ }
119
+
120
+ function serializeResponseCondition(
121
+ condition: QtiResponseCondition,
122
+ context: SerializationContext,
123
+ indent: number,
124
+ ): string[] {
125
+ if (condition.ifExpression === undefined) {
126
+ addSerializerDiagnostic(context, "responseProcessing.serialize.missingExpression", {
127
+ message: "qti-response-condition requires an if expression.",
128
+ });
129
+ return [];
130
+ }
131
+
132
+ const children: string[] = [
133
+ ...renderElement(
134
+ "qti-response-if",
135
+ [],
136
+ [
137
+ ...serializeExpression(condition.ifExpression, context, indent + 2),
138
+ ...condition.thenRules.flatMap((rule) => serializeResponseRule(rule, context, indent + 2)),
139
+ ],
140
+ indent + 1,
141
+ ),
142
+ ];
143
+
144
+ for (const branch of condition.elseIfs) {
145
+ children.push(...serializeResponseBranch(branch, context, indent + 1));
146
+ }
147
+
148
+ if (condition.elseRules.length > 0) {
149
+ children.push(
150
+ ...renderElement(
151
+ "qti-response-else",
152
+ [],
153
+ condition.elseRules.flatMap((rule) => serializeResponseRule(rule, context, indent + 2)),
154
+ indent + 1,
155
+ ),
156
+ );
157
+ }
158
+
159
+ return renderElement("qti-response-condition", [], children, indent);
160
+ }
161
+
162
+ function serializeResponseBranch(
163
+ branch: QtiResponseBranch,
164
+ context: SerializationContext,
165
+ indent: number,
166
+ ): string[] {
167
+ if (branch.expression === undefined) {
168
+ addSerializerDiagnostic(context, "responseProcessing.serialize.missingExpression", {
169
+ message: "qti-response-else-if requires an expression.",
170
+ });
171
+ return [];
172
+ }
173
+ return renderElement(
174
+ "qti-response-else-if",
175
+ [],
176
+ [
177
+ ...serializeExpression(branch.expression, context, indent + 1),
178
+ ...branch.rules.flatMap((rule) => serializeResponseRule(rule, context, indent + 1)),
179
+ ],
180
+ indent,
181
+ );
182
+ }
183
+
184
+ function requireRuleIdentifier(
185
+ identifier: string,
186
+ tagName: string,
187
+ source: QtiResponseRule["source"],
188
+ context: SerializationContext,
189
+ ): boolean {
190
+ if (identifier !== "") return true;
191
+ addSerializerDiagnostic(context, "responseProcessing.serialize.invalidAttribute", {
192
+ message: `${tagName} requires a non-empty identifier.`,
193
+ source,
194
+ });
195
+ return false;
196
+ }