@conform-ed/qti-xml 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2030 @@
1
+ /**
2
+ * QTI 3 ASI (Assessment, Section & Item) XML serialization — the authoring-system
3
+ * EXPORT direction. Takes the normalized/contracts document shape and emits an
4
+ * instance against the official ASI binding (namespace imsqtiasi_v3p0), the exact
5
+ * inverse of the normalizer in normalize.ts. The export-conformance gate is the model
6
+ * round trip (serialize → parse → normalize → strict contracts schema → deep-equal),
7
+ * proven across the whole vendored corpus in serialize-asi-corpus.local.test.ts.
8
+ *
9
+ * Element/attribute spellings mirror the normalizer's reads exactly (kebab-case in the
10
+ * XSD, e.g. `qti-hottext` not `qti-hot-text`, `base-type`, `response-identifier`). The
11
+ * normalizer is lossy in known ways — element @id, comments, the optional
12
+ * <qti-content-body> wrapper, the item-ref/section-ref distinction for bare refs — and
13
+ * those losses are exactly what this writer need not reproduce: re-normalization drops
14
+ * the same nothing, so model idempotence holds.
15
+ */
16
+
17
+ import { XmlWriter, type AttributeValue } from "./xml-writer";
18
+
19
+ export const asiNamespace = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
20
+ const asiSchemaLocation = `${asiNamespace} https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0_v1p0.xsd`;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Model accessors — the normalized document is a tree of plain objects keyed by
24
+ // `kind`; these read fields without leaking `any`.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ type Node = Record<string, unknown>;
28
+ type Attrs = ReadonlyArray<readonly [string, AttributeValue]>;
29
+
30
+ function asNode(value: unknown): Node {
31
+ return (value ?? {}) as Node;
32
+ }
33
+
34
+ function str(node: Node, key: string): string | undefined {
35
+ const value = node[key];
36
+ return typeof value === "string" ? value : undefined;
37
+ }
38
+
39
+ function scalar(value: unknown): AttributeValue {
40
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
41
+ return value;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function attr(node: Node, key: string): AttributeValue {
47
+ return scalar(node[key]);
48
+ }
49
+
50
+ function list(node: Node, key: string): string | undefined {
51
+ const value = node[key];
52
+ return Array.isArray(value) && value.length ? value.map((entry) => String(entry)).join(" ") : undefined;
53
+ }
54
+
55
+ function nodes(node: Node, key: string): Node[] {
56
+ const value = node[key];
57
+ return Array.isArray(value) ? value.map((entry) => asNode(entry)) : [];
58
+ }
59
+
60
+ function fragments(node: Node, key: string): unknown[] {
61
+ const value = node[key];
62
+ return Array.isArray(value) ? value : [];
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Content fragments and the generic foreign-XML node (HTML flow, MathML, SSML).
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const domainKinds = new Set([
70
+ "prompt",
71
+ "simpleChoice",
72
+ "choiceInteraction",
73
+ "orderInteraction",
74
+ "inlineChoice",
75
+ "inlineChoiceInteraction",
76
+ "textEntryInteraction",
77
+ "extendedTextInteraction",
78
+ "hotText",
79
+ "hotTextInteraction",
80
+ "matchInteraction",
81
+ "simpleAssociableChoice",
82
+ "associateInteraction",
83
+ "gap",
84
+ "gapText",
85
+ "gapImg",
86
+ "gapMatchInteraction",
87
+ "hotspotChoice",
88
+ "associableHotspot",
89
+ "hotspotInteraction",
90
+ "graphicOrderInteraction",
91
+ "graphicAssociateInteraction",
92
+ "graphicGapMatchInteraction",
93
+ "selectPointInteraction",
94
+ "positionObjectStage",
95
+ "positionObjectInteraction",
96
+ "mediaInteraction",
97
+ "uploadInteraction",
98
+ "sliderInteraction",
99
+ "endAttemptInteraction",
100
+ "feedbackInline",
101
+ "feedbackBlock",
102
+ "templateInline",
103
+ "templateBlock",
104
+ "printedVariable",
105
+ "rubricBlock",
106
+ "testRubricBlock",
107
+ "include",
108
+ "portableCustomInteraction",
109
+ "customInteraction",
110
+ "drawingInteraction",
111
+ ]);
112
+
113
+ function writeContent(writer: XmlWriter, content: readonly unknown[], ambient: string): void {
114
+ for (const fragment of content) {
115
+ if (typeof fragment === "string") {
116
+ writer.text(fragment);
117
+ continue;
118
+ }
119
+
120
+ const node = asNode(fragment);
121
+ if (node["kind"] === "xml") {
122
+ writeXmlNode(writer, node, ambient);
123
+ continue;
124
+ }
125
+ if (typeof node["kind"] === "string" && domainKinds.has(node["kind"] as string)) {
126
+ writeDomainNode(writer, node, ambient);
127
+ continue;
128
+ }
129
+ }
130
+ }
131
+
132
+ /** A generic foreign element: HTML flow in the ASI namespace, or MathML/SSML/SVG. */
133
+ function writeXmlNode(writer: XmlWriter, node: Node, ambient: string): void {
134
+ const name = str(node, "name") ?? "span";
135
+ const rawAttributes = (node["attributes"] ?? {}) as Record<string, string>;
136
+ const namespace = str(node, "namespace");
137
+ const attributes: Array<readonly [string, AttributeValue]> = Object.entries(rawAttributes);
138
+
139
+ let childAmbient = ambient;
140
+ if (namespace !== undefined && namespace !== ambient) {
141
+ // Redeclare the default namespace on this element; the normalizer keeps only
142
+ // localName + namespaceUri, so a default-xmlns form round-trips the prefix away.
143
+ attributes.push(["xmlns", namespace]);
144
+ childAmbient = namespace;
145
+ }
146
+
147
+ const children = node["children"];
148
+ if (Array.isArray(children) && children.length) {
149
+ writer.element(name, attributes, () => writeContent(writer, children, childAmbient));
150
+ return;
151
+ }
152
+
153
+ const value = str(node, "value");
154
+ if (value !== undefined) {
155
+ writer.element(name, attributes, value);
156
+ return;
157
+ }
158
+
159
+ writer.element(name, attributes);
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Catalogs (§5.26–5.29) and companion materials (§2.13.1).
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function dataAttributePairs(node: Node): Array<readonly [string, AttributeValue]> {
167
+ const data = node["dataAttributes"];
168
+ if (!data || typeof data !== "object") {
169
+ return [];
170
+ }
171
+ return Object.entries(data as Record<string, unknown>).map(([key, value]) => [`data-${key}`, scalar(value)]);
172
+ }
173
+
174
+ function writeCatalogHtmlContent(writer: XmlWriter, html: Node): void {
175
+ const attributes: Attrs = [["xml:lang", str(html, "xmlLang")], ...dataAttributePairs(html)];
176
+ const content = fragments(html, "content");
177
+ if (content.length) {
178
+ writer.element("qti-html-content", attributes, () => writeContent(writer, content, asiNamespace));
179
+ return;
180
+ }
181
+ writer.element("qti-html-content", attributes);
182
+ }
183
+
184
+ function writeCardContent(writer: XmlWriter, container: Node): void {
185
+ const html = container["htmlContent"];
186
+ if (html) {
187
+ writeCatalogHtmlContent(writer, asNode(html));
188
+ }
189
+ for (const fileHref of nodes(container, "fileHrefs")) {
190
+ writer.element("qti-file-href", [["mime-type", str(fileHref, "mimeType")]], str(fileHref, "href") ?? "");
191
+ }
192
+ }
193
+
194
+ function writeCard(writer: XmlWriter, card: Node): void {
195
+ const attributes: Attrs = [
196
+ ["support", str(card, "support")],
197
+ ["xml:lang", str(card, "xmlLang")],
198
+ ];
199
+ const entries = nodes(card, "cardEntries");
200
+
201
+ writer.element("qti-card", attributes, () => {
202
+ if (entries.length) {
203
+ for (const entry of entries) {
204
+ const entryAttributes: Attrs = [
205
+ ["xml:lang", str(entry, "xmlLang")],
206
+ ["default", attr(entry, "default")],
207
+ ...dataAttributePairs(entry),
208
+ ];
209
+ writer.element("qti-card-entry", entryAttributes, () => writeCardContent(writer, entry));
210
+ }
211
+ return;
212
+ }
213
+ writeCardContent(writer, card);
214
+ });
215
+ }
216
+
217
+ function writeCatalogInfo(writer: XmlWriter, node: Node): void {
218
+ const catalogInfo = node["catalogInfo"];
219
+ if (!catalogInfo) {
220
+ return;
221
+ }
222
+ writer.element("qti-catalog-info", [], () => {
223
+ for (const catalog of nodes(asNode(catalogInfo), "catalogs")) {
224
+ writer.element("qti-catalog", [["id", str(catalog, "id")]], () => {
225
+ for (const card of nodes(catalog, "cards")) {
226
+ writeCard(writer, card);
227
+ }
228
+ });
229
+ }
230
+ });
231
+ }
232
+
233
+ function writeMeasurementValue(writer: XmlWriter, tag: string, value: Node): void {
234
+ writer.element(tag, [["unit", str(value, "unit")]], String(scalar(value["value"]) ?? 0));
235
+ }
236
+
237
+ function writeItemFileInfo(writer: XmlWriter, tag: string, info: Node): void {
238
+ writer.element(
239
+ tag,
240
+ [
241
+ ["mime-type", str(info, "mimeType")],
242
+ ["label", str(info, "label")],
243
+ ],
244
+ () => {
245
+ writer.element("qti-file-href", [], str(info, "fileHref") ?? "");
246
+ const icon = str(info, "resourceIcon");
247
+ if (icon !== undefined) {
248
+ writer.element("qti-resource-icon", [], icon);
249
+ }
250
+ },
251
+ );
252
+ }
253
+
254
+ function writeRuleSystem(writer: XmlWriter, tag: string, system: Node): void {
255
+ writer.element(tag, [], () => {
256
+ writer.element("qti-minimum-length", [], String(scalar(system["minimumLength"]) ?? 0));
257
+ const minor = system["minorIncrement"];
258
+ if (minor) {
259
+ writeMeasurementValue(writer, "qti-minor-increment", asNode(minor));
260
+ }
261
+ writeMeasurementValue(writer, "qti-major-increment", asNode(system["majorIncrement"]));
262
+ });
263
+ }
264
+
265
+ function writeProtractorIncrement(writer: XmlWriter, tag: string, increment: Node): void {
266
+ writer.element(tag, [], () => {
267
+ const minor = increment["minorIncrement"];
268
+ if (minor) {
269
+ writeMeasurementValue(writer, "qti-minor-increment", asNode(minor));
270
+ }
271
+ writeMeasurementValue(writer, "qti-major-increment", asNode(increment["majorIncrement"]));
272
+ });
273
+ }
274
+
275
+ function writeCompanionMaterials(writer: XmlWriter, node: Node): void {
276
+ const info = node["companionMaterialsInfo"];
277
+ if (!info) {
278
+ return;
279
+ }
280
+ const materials = asNode(info);
281
+
282
+ writer.element("qti-companion-materials-info", [], () => {
283
+ for (const calculator of nodes(materials, "calculators")) {
284
+ writer.element("qti-calculator", [], () => {
285
+ writer.element("qti-calculator-type", [], str(calculator, "calculatorType") ?? "");
286
+ writer.element("qti-description", [], str(calculator, "description") ?? "");
287
+ const calculatorInfo = calculator["calculatorInfo"];
288
+ if (calculatorInfo) {
289
+ writeItemFileInfo(writer, "qti-calculator-info", asNode(calculatorInfo));
290
+ }
291
+ });
292
+ }
293
+ for (const rule of nodes(materials, "rules")) {
294
+ writer.element("qti-rule", [], () => {
295
+ writer.element("qti-description", [], str(rule, "description") ?? "");
296
+ if (rule["ruleSystemSi"]) {
297
+ writeRuleSystem(writer, "qti-rule-system-si", asNode(rule["ruleSystemSi"]));
298
+ }
299
+ if (rule["ruleSystemUs"]) {
300
+ writeRuleSystem(writer, "qti-rule-system-us", asNode(rule["ruleSystemUs"]));
301
+ }
302
+ });
303
+ }
304
+ for (const protractor of nodes(materials, "protractors")) {
305
+ writer.element("qti-protractor", [], () => {
306
+ writer.element("qti-description", [], str(protractor, "description") ?? "");
307
+ if (protractor["incrementSi"]) {
308
+ writeProtractorIncrement(writer, "qti-increment-si", asNode(protractor["incrementSi"]));
309
+ }
310
+ if (protractor["incrementUs"]) {
311
+ writeProtractorIncrement(writer, "qti-increment-us", asNode(protractor["incrementUs"]));
312
+ }
313
+ });
314
+ }
315
+ for (const material of nodes(materials, "digitalMaterials")) {
316
+ writeItemFileInfo(writer, "qti-digital-material", material);
317
+ }
318
+ for (const material of fragments(materials, "physicalMaterials")) {
319
+ writer.element("qti-physical-material", [], String(material));
320
+ }
321
+ });
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Expressions (inverse of mapV3Expression).
326
+ // ---------------------------------------------------------------------------
327
+
328
+ const childOnlyExpressionTags = new Map<string, string>([
329
+ ["and", "qti-and"],
330
+ ["contains", "qti-contains"],
331
+ ["containerSize", "qti-container-size"],
332
+ ["delete", "qti-delete"],
333
+ ["divide", "qti-divide"],
334
+ ["durationGte", "qti-duration-gte"],
335
+ ["durationLt", "qti-duration-lt"],
336
+ ["gcd", "qti-gcd"],
337
+ ["gt", "qti-gt"],
338
+ ["gte", "qti-gte"],
339
+ ["integerDivide", "qti-integer-divide"],
340
+ ["integerModulus", "qti-integer-modulus"],
341
+ ["integerToFloat", "qti-integer-to-float"],
342
+ ["isNull", "qti-is-null"],
343
+ ["lcm", "qti-lcm"],
344
+ ["lt", "qti-lt"],
345
+ ["lte", "qti-lte"],
346
+ ["match", "qti-match"],
347
+ ["max", "qti-max"],
348
+ ["member", "qti-member"],
349
+ ["min", "qti-min"],
350
+ ["multiple", "qti-multiple"],
351
+ ["not", "qti-not"],
352
+ ["or", "qti-or"],
353
+ ["ordered", "qti-ordered"],
354
+ ["power", "qti-power"],
355
+ ["product", "qti-product"],
356
+ ["random", "qti-random"],
357
+ ["round", "qti-round"],
358
+ ["subtract", "qti-subtract"],
359
+ ["sum", "qti-sum"],
360
+ ["truncate", "qti-truncate"],
361
+ ]);
362
+
363
+ function writeExpressionChildren(writer: XmlWriter, expression: Node): void {
364
+ for (const child of nodes(expression, "children")) {
365
+ writeExpression(writer, child);
366
+ }
367
+ }
368
+
369
+ function subsetAttributes(expression: Node): Attrs {
370
+ return [
371
+ ["section-identifier", str(expression, "sectionIdentifier")],
372
+ ["include-category", list(expression, "includeCategory")],
373
+ ["exclude-category", list(expression, "excludeCategory")],
374
+ ];
375
+ }
376
+
377
+ function writeExpression(writer: XmlWriter, expression: Node): void {
378
+ const kind = str(expression, "kind") ?? "";
379
+
380
+ const childOnly = childOnlyExpressionTags.get(kind);
381
+ if (childOnly !== undefined) {
382
+ writer.element(childOnly, [], () => writeExpressionChildren(writer, expression));
383
+ return;
384
+ }
385
+
386
+ const withChildren = (tag: string, attributes: Attrs): void => {
387
+ writer.element(tag, attributes, () => writeExpressionChildren(writer, expression));
388
+ };
389
+
390
+ switch (kind) {
391
+ case "null":
392
+ writer.element("qti-null", []);
393
+ return;
394
+ case "baseValue":
395
+ writer.element("qti-base-value", [["base-type", str(expression, "baseType")]], str(expression, "value") ?? "");
396
+ return;
397
+ case "variable":
398
+ writer.element("qti-variable", [
399
+ ["identifier", str(expression, "identifier")],
400
+ ["weight-identifier", str(expression, "weightIdentifier")],
401
+ ]);
402
+ return;
403
+ case "correct":
404
+ writer.element("qti-correct", [["identifier", str(expression, "identifier")]]);
405
+ return;
406
+ case "default":
407
+ writer.element("qti-default", [["identifier", str(expression, "identifier")]]);
408
+ return;
409
+ case "mapResponse":
410
+ writer.element("qti-map-response", [["identifier", str(expression, "identifier")]]);
411
+ return;
412
+ case "mapResponsePoint":
413
+ writer.element("qti-map-response-point", [["identifier", str(expression, "identifier")]]);
414
+ return;
415
+ case "randomInteger":
416
+ writer.element("qti-random-integer", [
417
+ ["min", attr(expression, "min")],
418
+ ["max", attr(expression, "max")],
419
+ ["step", attr(expression, "step")],
420
+ ]);
421
+ return;
422
+ case "randomFloat":
423
+ writer.element("qti-random-float", [
424
+ ["min", attr(expression, "min")],
425
+ ["max", attr(expression, "max")],
426
+ ]);
427
+ return;
428
+ case "mathConstant":
429
+ writer.element("qti-math-constant", [["name", str(expression, "name")]]);
430
+ return;
431
+ case "mathOperator":
432
+ withChildren("qti-math-operator", [["name", str(expression, "name")]]);
433
+ return;
434
+ case "statsOperator":
435
+ withChildren("qti-stats-operator", [["name", str(expression, "name")]]);
436
+ return;
437
+ case "anyN":
438
+ withChildren("qti-any-n", [
439
+ ["min", attr(expression, "min")],
440
+ ["max", attr(expression, "max")],
441
+ ]);
442
+ return;
443
+ case "equal":
444
+ withChildren("qti-equal", [
445
+ ["tolerance-mode", str(expression, "toleranceMode")],
446
+ ["tolerance", list(expression, "tolerance")],
447
+ ["include-lower-bound", attr(expression, "includeLowerBound")],
448
+ ["include-upper-bound", attr(expression, "includeUpperBound")],
449
+ ]);
450
+ return;
451
+ case "equalRounded":
452
+ withChildren("qti-equal-rounded", [
453
+ ["rounding-mode", str(expression, "roundingMode")],
454
+ ["figures", attr(expression, "figures")],
455
+ ]);
456
+ return;
457
+ case "roundTo":
458
+ withChildren("qti-round-to", [
459
+ ["rounding-mode", str(expression, "roundingMode")],
460
+ ["figures", attr(expression, "figures")],
461
+ ]);
462
+ return;
463
+ case "fieldValue":
464
+ withChildren("qti-field-value", [["field-identifier", str(expression, "fieldIdentifier")]]);
465
+ return;
466
+ case "index":
467
+ withChildren("qti-index", [["n", attr(expression, "n")]]);
468
+ return;
469
+ case "inside":
470
+ withChildren("qti-inside", [
471
+ ["shape", str(expression, "shape")],
472
+ ["coords", str(expression, "coords")],
473
+ ]);
474
+ return;
475
+ case "patternMatch":
476
+ withChildren("qti-pattern-match", [["pattern", str(expression, "pattern")]]);
477
+ return;
478
+ case "stringMatch":
479
+ withChildren("qti-string-match", [
480
+ ["case-sensitive", attr(expression, "caseSensitive")],
481
+ ["substring", attr(expression, "substring")],
482
+ ]);
483
+ return;
484
+ case "substring":
485
+ withChildren("qti-substring", [["case-sensitive", attr(expression, "caseSensitive")]]);
486
+ return;
487
+ case "repeat":
488
+ withChildren("qti-repeat", [["number-repeats", attr(expression, "numberRepeats")]]);
489
+ return;
490
+ case "customOperator":
491
+ withChildren("qti-custom-operator", [
492
+ ["class", str(expression, "class")],
493
+ ["definition", str(expression, "definition")],
494
+ ]);
495
+ return;
496
+ case "numberCorrect":
497
+ writer.element("qti-number-correct", subsetAttributes(expression));
498
+ return;
499
+ case "numberIncorrect":
500
+ writer.element("qti-number-incorrect", subsetAttributes(expression));
501
+ return;
502
+ case "numberPresented":
503
+ writer.element("qti-number-presented", subsetAttributes(expression));
504
+ return;
505
+ case "numberResponded":
506
+ writer.element("qti-number-responded", subsetAttributes(expression));
507
+ return;
508
+ case "numberSelected":
509
+ writer.element("qti-number-selected", subsetAttributes(expression));
510
+ return;
511
+ case "outcomeMinimum":
512
+ writer.element("qti-outcome-minimum", [
513
+ ...subsetAttributes(expression),
514
+ ["outcome-identifier", str(expression, "outcomeIdentifier")],
515
+ ["weight-identifier", str(expression, "weightIdentifier")],
516
+ ]);
517
+ return;
518
+ case "outcomeMaximum":
519
+ writer.element("qti-outcome-maximum", [
520
+ ...subsetAttributes(expression),
521
+ ["outcome-identifier", str(expression, "outcomeIdentifier")],
522
+ ["weight-identifier", str(expression, "weightIdentifier")],
523
+ ]);
524
+ return;
525
+ case "testVariables":
526
+ writer.element("qti-test-variables", [
527
+ ...subsetAttributes(expression),
528
+ ["variable-identifier", str(expression, "variableIdentifier")],
529
+ ["weight-identifier", str(expression, "weightIdentifier")],
530
+ ["base-type", str(expression, "baseType")],
531
+ ]);
532
+ return;
533
+ default:
534
+ throw new Error(`Cannot serialize QTI 3.0.1 expression of kind "${kind}".`);
535
+ }
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Response / template / outcome rules.
540
+ // ---------------------------------------------------------------------------
541
+
542
+ function writeConditionBranch(
543
+ writer: XmlWriter,
544
+ tag: string,
545
+ branch: Node,
546
+ writeRule: (rule: Node) => void,
547
+ withExpression: boolean,
548
+ ): void {
549
+ writer.element(tag, [], () => {
550
+ if (withExpression && branch["expression"]) {
551
+ writeExpression(writer, asNode(branch["expression"]));
552
+ }
553
+ for (const action of nodes(branch, "actions")) {
554
+ writeRule(action);
555
+ }
556
+ });
557
+ }
558
+
559
+ function writeSetExpressionRule(writer: XmlWriter, tag: string, rule: Node): void {
560
+ writer.element(tag, [["identifier", str(rule, "identifier")]], () => {
561
+ if (rule["expression"]) {
562
+ writeExpression(writer, asNode(rule["expression"]));
563
+ }
564
+ });
565
+ }
566
+
567
+ function writeResponseRule(writer: XmlWriter, rule: Node): void {
568
+ switch (str(rule, "kind")) {
569
+ case "responseCondition":
570
+ writer.element("qti-response-condition", [], () => {
571
+ writeConditionBranch(
572
+ writer,
573
+ "qti-response-if",
574
+ asNode(rule["responseIf"]),
575
+ (r) => writeResponseRule(writer, r),
576
+ true,
577
+ );
578
+ for (const elseIf of nodes(rule, "responseElseIf")) {
579
+ writeConditionBranch(writer, "qti-response-else-if", elseIf, (r) => writeResponseRule(writer, r), true);
580
+ }
581
+ if (rule["responseElse"]) {
582
+ writeConditionBranch(
583
+ writer,
584
+ "qti-response-else",
585
+ asNode(rule["responseElse"]),
586
+ (r) => writeResponseRule(writer, r),
587
+ false,
588
+ );
589
+ }
590
+ });
591
+ return;
592
+ case "setOutcomeValue":
593
+ writeSetExpressionRule(writer, "qti-set-outcome-value", rule);
594
+ return;
595
+ case "lookupOutcomeValue":
596
+ writeSetExpressionRule(writer, "qti-lookup-outcome-value", rule);
597
+ return;
598
+ case "exitResponse":
599
+ writer.element("qti-exit-response", []);
600
+ return;
601
+ case "responseProcessingFragment":
602
+ writer.element("qti-response-processing-fragment", [], () => {
603
+ for (const inner of nodes(rule, "rules")) {
604
+ writeResponseRule(writer, inner);
605
+ }
606
+ });
607
+ return;
608
+ default:
609
+ throw new Error(`Cannot serialize QTI 3.0.1 response rule of kind "${str(rule, "kind")}".`);
610
+ }
611
+ }
612
+
613
+ function writeTemplateRule(writer: XmlWriter, rule: Node): void {
614
+ switch (str(rule, "kind")) {
615
+ case "templateCondition":
616
+ writer.element("qti-template-condition", [], () => {
617
+ writeConditionBranch(
618
+ writer,
619
+ "qti-template-if",
620
+ asNode(rule["templateIf"]),
621
+ (r) => writeTemplateRule(writer, r),
622
+ true,
623
+ );
624
+ for (const elseIf of nodes(rule, "templateElseIf")) {
625
+ writeConditionBranch(writer, "qti-template-else-if", elseIf, (r) => writeTemplateRule(writer, r), true);
626
+ }
627
+ if (rule["templateElse"]) {
628
+ writeConditionBranch(
629
+ writer,
630
+ "qti-template-else",
631
+ asNode(rule["templateElse"]),
632
+ (r) => writeTemplateRule(writer, r),
633
+ false,
634
+ );
635
+ }
636
+ });
637
+ return;
638
+ case "setTemplateValue":
639
+ writeSetExpressionRule(writer, "qti-set-template-value", rule);
640
+ return;
641
+ case "setDefaultValue":
642
+ writeSetExpressionRule(writer, "qti-set-default-value", rule);
643
+ return;
644
+ case "setCorrectResponse":
645
+ writeSetExpressionRule(writer, "qti-set-correct-response", rule);
646
+ return;
647
+ case "templateConstraint":
648
+ writer.element("qti-template-constraint", [], () => {
649
+ if (rule["expression"]) {
650
+ writeExpression(writer, asNode(rule["expression"]));
651
+ }
652
+ });
653
+ return;
654
+ case "exitTemplate":
655
+ writer.element("qti-exit-template", []);
656
+ return;
657
+ default:
658
+ throw new Error(`Cannot serialize QTI 3.0.1 template rule of kind "${str(rule, "kind")}".`);
659
+ }
660
+ }
661
+
662
+ function writeOutcomeRule(writer: XmlWriter, rule: Node): void {
663
+ switch (str(rule, "kind")) {
664
+ case "outcomeCondition":
665
+ writer.element("qti-outcome-condition", [], () => {
666
+ writeConditionBranch(
667
+ writer,
668
+ "qti-outcome-if",
669
+ asNode(rule["outcomeIf"]),
670
+ (r) => writeOutcomeRule(writer, r),
671
+ true,
672
+ );
673
+ for (const elseIf of nodes(rule, "outcomeElseIf")) {
674
+ writeConditionBranch(writer, "qti-outcome-else-if", elseIf, (r) => writeOutcomeRule(writer, r), true);
675
+ }
676
+ if (rule["outcomeElse"]) {
677
+ writeConditionBranch(
678
+ writer,
679
+ "qti-outcome-else",
680
+ asNode(rule["outcomeElse"]),
681
+ (r) => writeOutcomeRule(writer, r),
682
+ false,
683
+ );
684
+ }
685
+ });
686
+ return;
687
+ case "setOutcomeValue":
688
+ writeSetExpressionRule(writer, "qti-set-outcome-value", rule);
689
+ return;
690
+ case "lookupOutcomeValue":
691
+ writeSetExpressionRule(writer, "qti-lookup-outcome-value", rule);
692
+ return;
693
+ case "exitTest":
694
+ writer.element("qti-exit-test", []);
695
+ return;
696
+ case "outcomeProcessingFragment":
697
+ writer.element("qti-outcome-processing-fragment", [], () => {
698
+ for (const inner of nodes(rule, "rules")) {
699
+ writeOutcomeRule(writer, inner);
700
+ }
701
+ });
702
+ return;
703
+ default:
704
+ throw new Error(`Cannot serialize QTI 3.0.1 outcome rule of kind "${str(rule, "kind")}".`);
705
+ }
706
+ }
707
+
708
+ function writeResponseProcessing(writer: XmlWriter, processing: Node): void {
709
+ const attributes: Attrs = [
710
+ ["template", str(processing, "template")],
711
+ ["template-location", str(processing, "templateLocation")],
712
+ ];
713
+ const rules = nodes(processing, "rules");
714
+ if (!rules.length) {
715
+ writer.element("qti-response-processing", attributes);
716
+ return;
717
+ }
718
+ writer.element("qti-response-processing", attributes, () => {
719
+ for (const rule of rules) {
720
+ writeResponseRule(writer, rule);
721
+ }
722
+ });
723
+ }
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // Declarations.
727
+ // ---------------------------------------------------------------------------
728
+
729
+ function writeValues(writer: XmlWriter, container: Node): void {
730
+ for (const value of nodes(container, "values")) {
731
+ writer.element(
732
+ "qti-value",
733
+ [
734
+ ["base-type", str(value, "baseType")],
735
+ ["field-identifier", str(value, "fieldIdentifier")],
736
+ ],
737
+ str(value, "value") ?? "",
738
+ );
739
+ }
740
+ }
741
+
742
+ function writeDefaultValue(writer: XmlWriter, declaration: Node): void {
743
+ const defaultValue = declaration["defaultValue"];
744
+ if (defaultValue) {
745
+ writer.element("qti-default-value", [], () => writeValues(writer, asNode(defaultValue)));
746
+ }
747
+ }
748
+
749
+ function writeMappingBounds(node: Node): Attrs {
750
+ return [
751
+ ["lower-bound", attr(node, "lowerBound")],
752
+ ["upper-bound", attr(node, "upperBound")],
753
+ ["default-value", attr(node, "defaultValue")],
754
+ ];
755
+ }
756
+
757
+ function writeResponseDeclaration(writer: XmlWriter, declaration: Node): void {
758
+ writer.element(
759
+ "qti-response-declaration",
760
+ [
761
+ ["identifier", str(declaration, "identifier")],
762
+ ["cardinality", str(declaration, "cardinality")],
763
+ ["base-type", str(declaration, "baseType")],
764
+ ],
765
+ () => {
766
+ writeDefaultValue(writer, declaration);
767
+ const correct = declaration["correctResponse"];
768
+ if (correct) {
769
+ writer.element("qti-correct-response", [], () => writeValues(writer, asNode(correct)));
770
+ }
771
+ const mapping = declaration["mapping"];
772
+ if (mapping) {
773
+ writer.element("qti-mapping", writeMappingBounds(asNode(mapping)), () => {
774
+ for (const entry of nodes(asNode(mapping), "mapEntries")) {
775
+ writer.element("qti-map-entry", [
776
+ ["map-key", str(entry, "mapKey")],
777
+ ["mapped-value", attr(entry, "mappedValue")],
778
+ ["case-sensitive", attr(entry, "caseSensitive")],
779
+ ]);
780
+ }
781
+ });
782
+ }
783
+ const areaMapping = declaration["areaMapping"];
784
+ if (areaMapping) {
785
+ writer.element("qti-area-mapping", writeMappingBounds(asNode(areaMapping)), () => {
786
+ for (const entry of nodes(asNode(areaMapping), "areaMapEntries")) {
787
+ writer.element("qti-area-map-entry", [
788
+ ["shape", str(entry, "shape")],
789
+ ["coords", str(entry, "coords")],
790
+ ["mapped-value", attr(entry, "mappedValue")],
791
+ ]);
792
+ }
793
+ });
794
+ }
795
+ },
796
+ );
797
+ }
798
+
799
+ function writeOutcomeDeclaration(writer: XmlWriter, declaration: Node): void {
800
+ writer.element(
801
+ "qti-outcome-declaration",
802
+ [
803
+ ["identifier", str(declaration, "identifier")],
804
+ ["cardinality", str(declaration, "cardinality")],
805
+ ["base-type", str(declaration, "baseType")],
806
+ ["view", list(declaration, "view")],
807
+ ["external-scored", str(declaration, "externalScored")],
808
+ ["interpretation", str(declaration, "interpretation")],
809
+ ["long-interpretation", str(declaration, "longInterpretation")],
810
+ ["normal-maximum", attr(declaration, "normalMaximum")],
811
+ ["normal-minimum", attr(declaration, "normalMinimum")],
812
+ ["mastery-value", attr(declaration, "masteryValue")],
813
+ ],
814
+ () => {
815
+ writeDefaultValue(writer, declaration);
816
+ const matchTable = declaration["matchTable"];
817
+ if (matchTable) {
818
+ writer.element("qti-match-table", [["default-value", str(asNode(matchTable), "defaultValue")]], () => {
819
+ for (const entry of nodes(asNode(matchTable), "matchTableEntries")) {
820
+ writer.element("qti-match-table-entry", [
821
+ ["source-value", attr(entry, "sourceValue")],
822
+ ["target-value", str(entry, "targetValue")],
823
+ ]);
824
+ }
825
+ });
826
+ }
827
+ const interpolationTable = declaration["interpolationTable"];
828
+ if (interpolationTable) {
829
+ writer.element(
830
+ "qti-interpolation-table",
831
+ [["default-value", str(asNode(interpolationTable), "defaultValue")]],
832
+ () => {
833
+ for (const entry of nodes(asNode(interpolationTable), "interpolationTableEntries")) {
834
+ writer.element("qti-interpolation-table-entry", [
835
+ ["source-value", attr(entry, "sourceValue")],
836
+ ["target-value", str(entry, "targetValue")],
837
+ ["include-boundary", attr(entry, "includeBoundary")],
838
+ ]);
839
+ }
840
+ },
841
+ );
842
+ }
843
+ },
844
+ );
845
+ }
846
+
847
+ function writeTemplateDeclaration(writer: XmlWriter, declaration: Node): void {
848
+ writer.element(
849
+ "qti-template-declaration",
850
+ [
851
+ ["identifier", str(declaration, "identifier")],
852
+ ["cardinality", str(declaration, "cardinality")],
853
+ ["base-type", str(declaration, "baseType")],
854
+ ["param-variable", attr(declaration, "paramVariable")],
855
+ ["math-variable", attr(declaration, "mathVariable")],
856
+ ],
857
+ () => writeDefaultValue(writer, declaration),
858
+ );
859
+ }
860
+
861
+ function writeContextDeclaration(writer: XmlWriter, declaration: Node): void {
862
+ writer.element(
863
+ "qti-context-declaration",
864
+ [
865
+ ["identifier", str(declaration, "identifier")],
866
+ ["cardinality", str(declaration, "cardinality")],
867
+ ["base-type", str(declaration, "baseType")],
868
+ ],
869
+ () => writeDefaultValue(writer, declaration),
870
+ );
871
+ }
872
+
873
+ function writeStylesheet(writer: XmlWriter, stylesheet: Node): void {
874
+ writer.element("qti-stylesheet", [
875
+ ["href", str(stylesheet, "href")],
876
+ ["type", str(stylesheet, "type")],
877
+ ["media", str(stylesheet, "media")],
878
+ ["title", str(stylesheet, "title")],
879
+ ]);
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Interaction / body domain nodes.
884
+ // ---------------------------------------------------------------------------
885
+
886
+ function writePrompt(writer: XmlWriter, node: Node, ambient: string): void {
887
+ const prompt = node["prompt"];
888
+ if (prompt) {
889
+ writer.element("qti-prompt", [], () => writeContent(writer, fragments(asNode(prompt), "content"), ambient));
890
+ }
891
+ }
892
+
893
+ function writeBodyContent(writer: XmlWriter, node: Node, ambient: string): void {
894
+ writeContent(writer, fragments(node, "content"), ambient);
895
+ }
896
+
897
+ /**
898
+ * Block containers (feedback-block, modal-feedback, rubric-block, test-rubric-block,
899
+ * template-block, test-feedback) wrap their flow content in <qti-content-body> — the
900
+ * ASI XSD requires it for several of them. The normalizer unwraps it transparently,
901
+ * so this stays round-trip-faithful while satisfying the official schema.
902
+ */
903
+ function writeContentBody(writer: XmlWriter, node: Node, ambient: string): void {
904
+ writer.element("qti-content-body", [], () => writeContent(writer, fragments(node, "content"), ambient));
905
+ }
906
+
907
+ function writeChoices(writer: XmlWriter, choices: Node[], ambient: string): void {
908
+ for (const choice of choices) {
909
+ writeDomainNode(writer, choice, ambient);
910
+ }
911
+ }
912
+
913
+ function writeHotspot(writer: XmlWriter, tag: string, node: Node): void {
914
+ writer.element(tag, [
915
+ ["identifier", str(node, "identifier")],
916
+ ["shape", str(node, "shape")],
917
+ ["coords", str(node, "coords")],
918
+ ["match-max", attr(node, "matchMax")],
919
+ ["hotspot-label", str(node, "hotspotLabel")],
920
+ ["match-group", list(node, "matchGroup")],
921
+ ]);
922
+ }
923
+
924
+ function writeDomainNode(writer: XmlWriter, node: Node, ambient: string): void {
925
+ const kind = str(node, "kind") ?? "";
926
+
927
+ switch (kind) {
928
+ case "prompt":
929
+ writer.element("qti-prompt", [], () => writeContent(writer, fragments(node, "content"), ambient));
930
+ return;
931
+
932
+ case "simpleChoice":
933
+ writer.element(
934
+ "qti-simple-choice",
935
+ [
936
+ ["identifier", str(node, "identifier")],
937
+ ["fixed", attr(node, "fixed")],
938
+ ["template-identifier", str(node, "templateIdentifier")],
939
+ ["show-hide", str(node, "showHide")],
940
+ ],
941
+ () => writeBodyContent(writer, node, ambient),
942
+ );
943
+ return;
944
+
945
+ case "choiceInteraction":
946
+ case "orderInteraction":
947
+ writer.element(
948
+ kind === "choiceInteraction" ? "qti-choice-interaction" : "qti-order-interaction",
949
+ [
950
+ ["response-identifier", str(node, "responseIdentifier")],
951
+ ["shuffle", attr(node, "shuffle")],
952
+ ["max-choices", attr(node, "maxChoices")],
953
+ ["min-choices", attr(node, "minChoices")],
954
+ ["orientation", str(node, "orientation")],
955
+ ],
956
+ () => {
957
+ writePrompt(writer, node, ambient);
958
+ writeChoices(writer, nodes(node, "simpleChoices"), ambient);
959
+ },
960
+ );
961
+ return;
962
+
963
+ case "inlineChoice":
964
+ writer.element(
965
+ "qti-inline-choice",
966
+ [
967
+ ["identifier", str(node, "identifier")],
968
+ ["fixed", attr(node, "fixed")],
969
+ ["template-identifier", str(node, "templateIdentifier")],
970
+ ["show-hide", str(node, "showHide")],
971
+ ],
972
+ () => writeBodyContent(writer, node, ambient),
973
+ );
974
+ return;
975
+
976
+ case "inlineChoiceInteraction":
977
+ writer.element(
978
+ "qti-inline-choice-interaction",
979
+ [
980
+ ["response-identifier", str(node, "responseIdentifier")],
981
+ ["shuffle", attr(node, "shuffle")],
982
+ ["required", attr(node, "required")],
983
+ ["min-choices", attr(node, "minChoices")],
984
+ ["data-prompt", str(node, "dataPrompt")],
985
+ ],
986
+ () => writeChoices(writer, nodes(node, "inlineChoices"), ambient),
987
+ );
988
+ return;
989
+
990
+ case "textEntryInteraction":
991
+ writer.element("qti-text-entry-interaction", [
992
+ ["response-identifier", str(node, "responseIdentifier")],
993
+ ["base", attr(node, "base")],
994
+ ["string-identifier", str(node, "stringIdentifier")],
995
+ ["expected-length", attr(node, "expectedLength")],
996
+ ["pattern-mask", str(node, "patternMask")],
997
+ ["placeholder-text", str(node, "placeholderText")],
998
+ ["format", str(node, "format")],
999
+ ]);
1000
+ return;
1001
+
1002
+ case "extendedTextInteraction":
1003
+ writer.element(
1004
+ "qti-extended-text-interaction",
1005
+ [
1006
+ ["response-identifier", str(node, "responseIdentifier")],
1007
+ ["base", attr(node, "base")],
1008
+ ["string-identifier", str(node, "stringIdentifier")],
1009
+ ["expected-length", attr(node, "expectedLength")],
1010
+ ["pattern-mask", str(node, "patternMask")],
1011
+ ["placeholder-text", str(node, "placeholderText")],
1012
+ ["max-strings", attr(node, "maxStrings")],
1013
+ ["min-strings", attr(node, "minStrings")],
1014
+ ["expected-lines", attr(node, "expectedLines")],
1015
+ ["format", str(node, "format")],
1016
+ ],
1017
+ () => writePrompt(writer, node, ambient),
1018
+ );
1019
+ return;
1020
+
1021
+ case "hotText":
1022
+ writer.element(
1023
+ "qti-hottext",
1024
+ [
1025
+ ["identifier", str(node, "identifier")],
1026
+ ["template-identifier", str(node, "templateIdentifier")],
1027
+ ["show-hide", str(node, "showHide")],
1028
+ ],
1029
+ () => writeBodyContent(writer, node, ambient),
1030
+ );
1031
+ return;
1032
+
1033
+ case "hotTextInteraction":
1034
+ writer.element(
1035
+ "qti-hottext-interaction",
1036
+ [
1037
+ ["response-identifier", str(node, "responseIdentifier")],
1038
+ ["max-choices", attr(node, "maxChoices")],
1039
+ ["min-choices", attr(node, "minChoices")],
1040
+ ],
1041
+ () => {
1042
+ writePrompt(writer, node, ambient);
1043
+ writeBodyContent(writer, node, ambient);
1044
+ },
1045
+ );
1046
+ return;
1047
+
1048
+ case "matchInteraction":
1049
+ writer.element(
1050
+ "qti-match-interaction",
1051
+ [
1052
+ ["response-identifier", str(node, "responseIdentifier")],
1053
+ ["shuffle", attr(node, "shuffle")],
1054
+ ["max-associations", attr(node, "maxAssociations")],
1055
+ ["min-associations", attr(node, "minAssociations")],
1056
+ ["data-first-column-header", str(node, "dataFirstColumnHeader")],
1057
+ ],
1058
+ () => {
1059
+ writePrompt(writer, node, ambient);
1060
+ for (const set of nodes(node, "simpleMatchSets")) {
1061
+ writer.element("qti-simple-match-set", [], () => {
1062
+ for (const choice of nodes(set, "simpleAssociableChoices")) {
1063
+ writeDomainNode(writer, choice, ambient);
1064
+ }
1065
+ });
1066
+ }
1067
+ },
1068
+ );
1069
+ return;
1070
+
1071
+ case "simpleAssociableChoice":
1072
+ writer.element(
1073
+ "qti-simple-associable-choice",
1074
+ [
1075
+ ["identifier", str(node, "identifier")],
1076
+ ["match-max", attr(node, "matchMax")],
1077
+ ["match-min", attr(node, "matchMin")],
1078
+ ["fixed", attr(node, "fixed")],
1079
+ ["match-group", list(node, "matchGroup")],
1080
+ ],
1081
+ () => writeBodyContent(writer, node, ambient),
1082
+ );
1083
+ return;
1084
+
1085
+ case "associateInteraction":
1086
+ writer.element(
1087
+ "qti-associate-interaction",
1088
+ [
1089
+ ["response-identifier", str(node, "responseIdentifier")],
1090
+ ["shuffle", attr(node, "shuffle")],
1091
+ ["max-associations", attr(node, "maxAssociations")],
1092
+ ["min-associations", attr(node, "minAssociations")],
1093
+ ],
1094
+ () => {
1095
+ writePrompt(writer, node, ambient);
1096
+ for (const choice of nodes(node, "simpleAssociableChoices")) {
1097
+ writeDomainNode(writer, choice, ambient);
1098
+ }
1099
+ },
1100
+ );
1101
+ return;
1102
+
1103
+ case "gap":
1104
+ writer.element("qti-gap", [
1105
+ ["identifier", str(node, "identifier")],
1106
+ ["required", attr(node, "required")],
1107
+ ["template-identifier", str(node, "templateIdentifier")],
1108
+ ["show-hide", str(node, "showHide")],
1109
+ ]);
1110
+ return;
1111
+
1112
+ case "gapText":
1113
+ writer.element(
1114
+ "qti-gap-text",
1115
+ [
1116
+ ["identifier", str(node, "identifier")],
1117
+ ["match-max", attr(node, "matchMax")],
1118
+ ["match-min", attr(node, "matchMin")],
1119
+ ],
1120
+ () => writeBodyContent(writer, node, ambient),
1121
+ );
1122
+ return;
1123
+
1124
+ case "gapImg":
1125
+ writer.element(
1126
+ "qti-gap-img",
1127
+ [
1128
+ ["identifier", str(node, "identifier")],
1129
+ ["match-max", attr(node, "matchMax")],
1130
+ ["match-min", attr(node, "matchMin")],
1131
+ ["object-label", str(node, "objectLabel")],
1132
+ ["top", str(node, "top")],
1133
+ ["left", str(node, "left")],
1134
+ ],
1135
+ () => writeXmlNode(writer, asNode(node["media"]), ambient),
1136
+ );
1137
+ return;
1138
+
1139
+ case "gapMatchInteraction":
1140
+ writer.element(
1141
+ "qti-gap-match-interaction",
1142
+ [
1143
+ ["response-identifier", str(node, "responseIdentifier")],
1144
+ ["shuffle", attr(node, "shuffle")],
1145
+ ["max-associations", attr(node, "maxAssociations")],
1146
+ ["min-associations", attr(node, "minAssociations")],
1147
+ ],
1148
+ () => {
1149
+ writePrompt(writer, node, ambient);
1150
+ for (const choice of nodes(node, "gapChoices")) {
1151
+ writeDomainNode(writer, choice, ambient);
1152
+ }
1153
+ writeBodyContent(writer, node, ambient);
1154
+ },
1155
+ );
1156
+ return;
1157
+
1158
+ case "hotspotChoice":
1159
+ writeHotspot(writer, "qti-hotspot-choice", node);
1160
+ return;
1161
+
1162
+ case "associableHotspot":
1163
+ writeHotspot(writer, "qti-associable-hotspot", node);
1164
+ return;
1165
+
1166
+ case "hotspotInteraction":
1167
+ case "graphicOrderInteraction":
1168
+ writer.element(
1169
+ kind === "hotspotInteraction" ? "qti-hotspot-interaction" : "qti-graphic-order-interaction",
1170
+ [
1171
+ ["response-identifier", str(node, "responseIdentifier")],
1172
+ ["max-choices", attr(node, "maxChoices")],
1173
+ ["min-choices", attr(node, "minChoices")],
1174
+ ],
1175
+ () => {
1176
+ writePrompt(writer, node, ambient);
1177
+ writeXmlNode(writer, asNode(node["image"]), ambient);
1178
+ for (const choice of nodes(node, "hotspotChoices")) {
1179
+ writeHotspot(writer, "qti-hotspot-choice", choice);
1180
+ }
1181
+ },
1182
+ );
1183
+ return;
1184
+
1185
+ case "graphicAssociateInteraction":
1186
+ writer.element(
1187
+ "qti-graphic-associate-interaction",
1188
+ [
1189
+ ["response-identifier", str(node, "responseIdentifier")],
1190
+ ["max-associations", attr(node, "maxAssociations")],
1191
+ ["min-associations", attr(node, "minAssociations")],
1192
+ ],
1193
+ () => {
1194
+ writePrompt(writer, node, ambient);
1195
+ writeXmlNode(writer, asNode(node["image"]), ambient);
1196
+ for (const choice of nodes(node, "associableHotspots")) {
1197
+ writeHotspot(writer, "qti-associable-hotspot", choice);
1198
+ }
1199
+ },
1200
+ );
1201
+ return;
1202
+
1203
+ case "graphicGapMatchInteraction":
1204
+ writer.element(
1205
+ "qti-graphic-gap-match-interaction",
1206
+ [
1207
+ ["response-identifier", str(node, "responseIdentifier")],
1208
+ ["max-associations", attr(node, "maxAssociations")],
1209
+ ["min-associations", attr(node, "minAssociations")],
1210
+ ],
1211
+ () => {
1212
+ writePrompt(writer, node, ambient);
1213
+ writeXmlNode(writer, asNode(node["image"]), ambient);
1214
+ for (const choice of nodes(node, "gapChoices")) {
1215
+ writeDomainNode(writer, choice, ambient);
1216
+ }
1217
+ for (const choice of nodes(node, "associableHotspots")) {
1218
+ writeHotspot(writer, "qti-associable-hotspot", choice);
1219
+ }
1220
+ },
1221
+ );
1222
+ return;
1223
+
1224
+ case "selectPointInteraction":
1225
+ writer.element(
1226
+ "qti-select-point-interaction",
1227
+ [
1228
+ ["response-identifier", str(node, "responseIdentifier")],
1229
+ ["max-choices", attr(node, "maxChoices")],
1230
+ ["min-choices", attr(node, "minChoices")],
1231
+ ],
1232
+ () => {
1233
+ writePrompt(writer, node, ambient);
1234
+ writeXmlNode(writer, asNode(node["image"]), ambient);
1235
+ },
1236
+ );
1237
+ return;
1238
+
1239
+ case "positionObjectStage":
1240
+ writer.element("qti-position-object-stage", [], () => {
1241
+ writeXmlNode(writer, asNode(node["image"]), ambient);
1242
+ for (const interaction of nodes(node, "positionObjectInteractions")) {
1243
+ writeDomainNode(writer, interaction, ambient);
1244
+ }
1245
+ });
1246
+ return;
1247
+
1248
+ case "positionObjectInteraction":
1249
+ // The XSD requires the stage image on both the stage and each interaction; the
1250
+ // normalizer copied the stage image onto each interaction, so emit it here too.
1251
+ writer.element(
1252
+ "qti-position-object-interaction",
1253
+ [
1254
+ ["response-identifier", str(node, "responseIdentifier")],
1255
+ ["center-point", list(node, "centerPoint")],
1256
+ ["min-choices", attr(node, "minChoices")],
1257
+ ["max-choices", attr(node, "maxChoices")],
1258
+ ],
1259
+ () => writeXmlNode(writer, asNode(node["image"]), ambient),
1260
+ );
1261
+ return;
1262
+
1263
+ case "mediaInteraction":
1264
+ writer.element(
1265
+ "qti-media-interaction",
1266
+ [
1267
+ ["response-identifier", str(node, "responseIdentifier")],
1268
+ ["autostart", attr(node, "autostart")],
1269
+ ["min-plays", attr(node, "minPlays")],
1270
+ ["max-plays", attr(node, "maxPlays")],
1271
+ ["loop", attr(node, "loop")],
1272
+ ["coords", str(node, "coords")],
1273
+ ],
1274
+ () => {
1275
+ writePrompt(writer, node, ambient);
1276
+ writeXmlNode(writer, asNode(node["media"]), ambient);
1277
+ },
1278
+ );
1279
+ return;
1280
+
1281
+ case "uploadInteraction":
1282
+ writer.element(
1283
+ "qti-upload-interaction",
1284
+ [
1285
+ ["response-identifier", str(node, "responseIdentifier")],
1286
+ ["type", list(node, "acceptedTypes")],
1287
+ ],
1288
+ () => writePrompt(writer, node, ambient),
1289
+ );
1290
+ return;
1291
+
1292
+ case "sliderInteraction":
1293
+ writer.element(
1294
+ "qti-slider-interaction",
1295
+ [
1296
+ ["response-identifier", str(node, "responseIdentifier")],
1297
+ ["lower-bound", attr(node, "lowerBound")],
1298
+ ["upper-bound", attr(node, "upperBound")],
1299
+ ["step", attr(node, "step")],
1300
+ ["step-label", attr(node, "stepLabel")],
1301
+ ["orientation", str(node, "orientation")],
1302
+ ["reverse", attr(node, "reverse")],
1303
+ ],
1304
+ () => writePrompt(writer, node, ambient),
1305
+ );
1306
+ return;
1307
+
1308
+ case "endAttemptInteraction":
1309
+ writer.element("qti-end-attempt-interaction", [
1310
+ ["response-identifier", str(node, "responseIdentifier")],
1311
+ ["title", str(node, "title")],
1312
+ ]);
1313
+ return;
1314
+
1315
+ case "feedbackInline":
1316
+ writer.element(
1317
+ "qti-feedback-inline",
1318
+ [
1319
+ ["outcome-identifier", str(node, "outcomeIdentifier")],
1320
+ ["identifier", str(node, "identifier")],
1321
+ ["show-hide", str(node, "showHide")],
1322
+ ],
1323
+ () => {
1324
+ writeBodyContent(writer, node, ambient);
1325
+ writeCatalogInfo(writer, node);
1326
+ },
1327
+ );
1328
+ return;
1329
+
1330
+ case "feedbackBlock":
1331
+ writer.element(
1332
+ "qti-feedback-block",
1333
+ [
1334
+ ["outcome-identifier", str(node, "outcomeIdentifier")],
1335
+ ["identifier", str(node, "identifier")],
1336
+ ["show-hide", str(node, "showHide")],
1337
+ ],
1338
+ () => {
1339
+ writeContentBody(writer, node, ambient);
1340
+ writeCatalogInfo(writer, node);
1341
+ },
1342
+ );
1343
+ return;
1344
+
1345
+ case "templateInline":
1346
+ writer.element(
1347
+ "qti-template-inline",
1348
+ [
1349
+ ["template-identifier", str(node, "templateIdentifier")],
1350
+ ["identifier", str(node, "identifier")],
1351
+ ["show-hide", str(node, "showHide")],
1352
+ ],
1353
+ () => {
1354
+ writeBodyContent(writer, node, ambient);
1355
+ writeCatalogInfo(writer, node);
1356
+ },
1357
+ );
1358
+ return;
1359
+
1360
+ case "templateBlock":
1361
+ writer.element(
1362
+ "qti-template-block",
1363
+ [
1364
+ ["template-identifier", str(node, "templateIdentifier")],
1365
+ ["identifier", str(node, "identifier")],
1366
+ ["show-hide", str(node, "showHide")],
1367
+ ],
1368
+ () => {
1369
+ writeContentBody(writer, node, ambient);
1370
+ writeCatalogInfo(writer, node);
1371
+ },
1372
+ );
1373
+ return;
1374
+
1375
+ case "printedVariable":
1376
+ writer.element("qti-printed-variable", [
1377
+ ["identifier", str(node, "identifier")],
1378
+ ["format", str(node, "format")],
1379
+ ["base", attr(node, "base")],
1380
+ ["index", attr(node, "index")],
1381
+ ["power-form", attr(node, "powerForm")],
1382
+ ["field", str(node, "field")],
1383
+ ["delimiter", str(node, "delimiter")],
1384
+ ["mapping-indicator", str(node, "mappingIndicator")],
1385
+ ]);
1386
+ return;
1387
+
1388
+ case "rubricBlock":
1389
+ case "testRubricBlock":
1390
+ writer.element(
1391
+ "qti-rubric-block",
1392
+ [
1393
+ ["view", list(node, "view")],
1394
+ ["use", str(node, "use")],
1395
+ ],
1396
+ () => {
1397
+ writeContentBody(writer, node, ambient);
1398
+ writeCatalogInfo(writer, node);
1399
+ },
1400
+ );
1401
+ return;
1402
+
1403
+ case "include":
1404
+ writer.element("qti-include", [
1405
+ ["href", str(node, "href")],
1406
+ ["parse", str(node, "parse")],
1407
+ ["xpointer", str(node, "xpointer")],
1408
+ ]);
1409
+ return;
1410
+
1411
+ case "portableCustomInteraction":
1412
+ writePortableCustomInteraction(writer, node, ambient);
1413
+ return;
1414
+
1415
+ case "customInteraction":
1416
+ writer.element("qti-custom-interaction", [["response-identifier", str(node, "responseIdentifier")]], () =>
1417
+ writeBodyContent(writer, node, ambient),
1418
+ );
1419
+ return;
1420
+
1421
+ case "drawingInteraction":
1422
+ writer.element("qti-drawing-interaction", [["response-identifier", str(node, "responseIdentifier")]], () => {
1423
+ writePrompt(writer, node, ambient);
1424
+ writeBodyContent(writer, node, ambient);
1425
+ });
1426
+ return;
1427
+
1428
+ default:
1429
+ throw new Error(`Cannot serialize QTI 3.0.1 content node of kind "${kind}".`);
1430
+ }
1431
+ }
1432
+
1433
+ function writePortableCustomInteraction(writer: XmlWriter, node: Node, ambient: string): void {
1434
+ const properties = node["properties"];
1435
+ const propertyAttributes: Array<readonly [string, AttributeValue]> =
1436
+ properties && typeof properties === "object"
1437
+ ? Object.entries(properties as Record<string, unknown>).map(([key, value]) => [`data-${key}`, scalar(value)])
1438
+ : [];
1439
+
1440
+ writer.element(
1441
+ "qti-portable-custom-interaction",
1442
+ [
1443
+ ["response-identifier", str(node, "responseIdentifier")],
1444
+ ["custom-interaction-type-identifier", str(node, "customInteractionTypeIdentifier")],
1445
+ ["module", str(node, "module")],
1446
+ ["class", list(node, "class")],
1447
+ ["data-catalog-idref", str(node, "dataCatalogIdref")],
1448
+ ...propertyAttributes,
1449
+ ],
1450
+ () => {
1451
+ // XSD sequence: qti-interaction-modules?, qti-interaction-markup, …, qti-catalog-info?.
1452
+ const modules = node["interactionModules"];
1453
+ if (modules) {
1454
+ const modulesNode = asNode(modules);
1455
+ writer.element(
1456
+ "qti-interaction-modules",
1457
+ [
1458
+ ["primary-configuration", str(modulesNode, "primaryConfiguration")],
1459
+ ["secondary-configuration", str(modulesNode, "secondaryConfiguration")],
1460
+ ],
1461
+ () => {
1462
+ for (const moduleNode of nodes(modulesNode, "modules")) {
1463
+ writer.element("qti-interaction-module", [
1464
+ ["id", str(moduleNode, "id")],
1465
+ ["primary-path", str(moduleNode, "primaryPath")],
1466
+ ["fallback-path", str(moduleNode, "fallbackPath")],
1467
+ ]);
1468
+ }
1469
+ },
1470
+ );
1471
+ }
1472
+
1473
+ const markup = node["interactionMarkup"];
1474
+ if (markup) {
1475
+ const markupContent = fragments(asNode(markup), "content");
1476
+ if (markupContent.length) {
1477
+ writer.element("qti-interaction-markup", [], () => writeContent(writer, markupContent, ambient));
1478
+ } else {
1479
+ writer.element("qti-interaction-markup", []);
1480
+ }
1481
+ }
1482
+
1483
+ writeCatalogInfo(writer, node);
1484
+ },
1485
+ );
1486
+ }
1487
+
1488
+ // ---------------------------------------------------------------------------
1489
+ // Shared section/test structural pieces.
1490
+ // ---------------------------------------------------------------------------
1491
+
1492
+ function writeItemSessionControl(writer: XmlWriter, control: Node): void {
1493
+ writer.element("qti-item-session-control", [
1494
+ ["allow-review", attr(control, "allowReview")],
1495
+ ["max-attempts", attr(control, "maxAttempts")],
1496
+ ["show-feedback", attr(control, "showFeedback")],
1497
+ ["show-solution", attr(control, "showSolution")],
1498
+ ["allow-comment", attr(control, "allowComment")],
1499
+ ["allow-skipping", attr(control, "allowSkipping")],
1500
+ ["validate-responses", attr(control, "validateResponses")],
1501
+ ]);
1502
+ }
1503
+
1504
+ function writeTimeLimits(writer: XmlWriter, limits: Node): void {
1505
+ writer.element("qti-time-limits", [
1506
+ ["min-time", attr(limits, "minTime")],
1507
+ ["max-time", attr(limits, "maxTime")],
1508
+ ["allow-late-submission", attr(limits, "allowLateSubmission")],
1509
+ ]);
1510
+ }
1511
+
1512
+ function writePreCondition(writer: XmlWriter, condition: Node): void {
1513
+ writer.element("qti-pre-condition", [], () => {
1514
+ if (condition["expression"]) {
1515
+ writeExpression(writer, asNode(condition["expression"]));
1516
+ }
1517
+ });
1518
+ }
1519
+
1520
+ function writeBranchRule(writer: XmlWriter, rule: Node): void {
1521
+ writer.element("qti-branch-rule", [["target", str(rule, "target")]], () => {
1522
+ if (rule["expression"]) {
1523
+ writeExpression(writer, asNode(rule["expression"]));
1524
+ }
1525
+ });
1526
+ }
1527
+
1528
+ function writeRubricBlocks(writer: XmlWriter, container: Node, ambient: string): void {
1529
+ for (const rubric of nodes(container, "rubricBlocks")) {
1530
+ writeDomainNode(writer, rubric, ambient);
1531
+ }
1532
+ }
1533
+
1534
+ function writeTestFeedback(writer: XmlWriter, feedback: Node, ambient: string): void {
1535
+ writer.element(
1536
+ "qti-test-feedback",
1537
+ [
1538
+ ["access", str(feedback, "access")],
1539
+ ["outcome-identifier", str(feedback, "outcomeIdentifier")],
1540
+ ["show-hide", str(feedback, "showHide")],
1541
+ ["identifier", str(feedback, "identifier")],
1542
+ ["title", str(feedback, "title")],
1543
+ ],
1544
+ () => {
1545
+ writeContentBody(writer, feedback, ambient);
1546
+ writeCatalogInfo(writer, feedback);
1547
+ },
1548
+ );
1549
+ }
1550
+
1551
+ function writeAdaptiveRef(writer: XmlWriter, tag: string, ref: Node): void {
1552
+ writer.element(tag, [
1553
+ ["identifier", str(ref, "identifier")],
1554
+ ["href", str(ref, "href")],
1555
+ ]);
1556
+ }
1557
+
1558
+ function writeSelectionOrdering(writer: XmlWriter, section: Node): void {
1559
+ const adaptive = section["adaptiveSelection"];
1560
+ if (adaptive) {
1561
+ const adaptiveNode = asNode(adaptive);
1562
+ writer.element("qti-adaptive-selection", [], () => {
1563
+ writeAdaptiveRef(writer, "qti-adaptive-engine-ref", asNode(adaptiveNode["adaptiveEngineRef"]));
1564
+ if (adaptiveNode["adaptiveSettingsRef"]) {
1565
+ writeAdaptiveRef(writer, "qti-adaptive-settings-ref", asNode(adaptiveNode["adaptiveSettingsRef"]));
1566
+ }
1567
+ if (adaptiveNode["usagedataRef"]) {
1568
+ writeAdaptiveRef(writer, "qti-usagedata-ref", asNode(adaptiveNode["usagedataRef"]));
1569
+ }
1570
+ if (adaptiveNode["metadataRef"]) {
1571
+ writeAdaptiveRef(writer, "qti-metadata-ref", asNode(adaptiveNode["metadataRef"]));
1572
+ }
1573
+ });
1574
+ }
1575
+ const selection = section["selection"];
1576
+ if (selection) {
1577
+ writer.element("qti-selection", [
1578
+ ["select", attr(asNode(selection), "select")],
1579
+ ["with-replacement", attr(asNode(selection), "withReplacement")],
1580
+ ]);
1581
+ }
1582
+ const ordering = section["ordering"];
1583
+ if (ordering) {
1584
+ writer.element("qti-ordering", [["shuffle", attr(asNode(ordering), "shuffle")]]);
1585
+ }
1586
+ }
1587
+
1588
+ function writeItemRef(writer: XmlWriter, ref: Node): void {
1589
+ writer.element(
1590
+ "qti-assessment-item-ref",
1591
+ [
1592
+ ["identifier", str(ref, "identifier")],
1593
+ ["href", str(ref, "href")],
1594
+ ["required", attr(ref, "required")],
1595
+ ["fixed", attr(ref, "fixed")],
1596
+ ["category", list(ref, "category")],
1597
+ ],
1598
+ () => {
1599
+ for (const condition of nodes(ref, "preConditions")) {
1600
+ writePreCondition(writer, condition);
1601
+ }
1602
+ for (const rule of nodes(ref, "branchRules")) {
1603
+ writeBranchRule(writer, rule);
1604
+ }
1605
+ if (ref["itemSessionControl"]) {
1606
+ writeItemSessionControl(writer, asNode(ref["itemSessionControl"]));
1607
+ }
1608
+ if (ref["timeLimits"]) {
1609
+ writeTimeLimits(writer, asNode(ref["timeLimits"]));
1610
+ }
1611
+ for (const weight of nodes(ref, "weights")) {
1612
+ writer.element("qti-weight", [
1613
+ ["identifier", str(weight, "identifier")],
1614
+ ["value", attr(weight, "value")],
1615
+ ]);
1616
+ }
1617
+ for (const mapping of nodes(ref, "variableMappings")) {
1618
+ writer.element("qti-variable-mapping", [
1619
+ ["source-identifier", str(mapping, "sourceIdentifier")],
1620
+ ["target-identifier", str(mapping, "targetIdentifier")],
1621
+ ]);
1622
+ }
1623
+ for (const templateDefault of nodes(ref, "templateDefaults")) {
1624
+ writer.element(
1625
+ "qti-template-default",
1626
+ [["template-identifier", str(templateDefault, "templateIdentifier")]],
1627
+ () => {
1628
+ if (templateDefault["expression"]) {
1629
+ writeExpression(writer, asNode(templateDefault["expression"]));
1630
+ }
1631
+ },
1632
+ );
1633
+ }
1634
+ },
1635
+ );
1636
+ }
1637
+
1638
+ function writeSectionChild(writer: XmlWriter, child: Node, ambient: string): void {
1639
+ // A nested section is the only child carrying a title; everything else is a ref.
1640
+ // Bare item-refs and section-refs coalesce to {identifier, href} in the model, so
1641
+ // emitting an item-ref for both round-trips identically (documented in ADR-0010).
1642
+ if (str(child, "title") !== undefined && child["visible"] !== undefined) {
1643
+ writeSection(writer, child, ambient);
1644
+ return;
1645
+ }
1646
+ writeItemRef(writer, child);
1647
+ }
1648
+
1649
+ function writeSection(writer: XmlWriter, section: Node, ambient: string): void {
1650
+ writer.element(
1651
+ "qti-assessment-section",
1652
+ [
1653
+ ["identifier", str(section, "identifier")],
1654
+ ["title", str(section, "title")],
1655
+ ["visible", attr(section, "visible")],
1656
+ ["required", attr(section, "required")],
1657
+ ["fixed", attr(section, "fixed")],
1658
+ ["keep-together", attr(section, "keepTogether")],
1659
+ ],
1660
+ () => {
1661
+ for (const condition of nodes(section, "preConditions")) {
1662
+ writePreCondition(writer, condition);
1663
+ }
1664
+ for (const rule of nodes(section, "branchRules")) {
1665
+ writeBranchRule(writer, rule);
1666
+ }
1667
+ if (section["itemSessionControl"]) {
1668
+ writeItemSessionControl(writer, asNode(section["itemSessionControl"]));
1669
+ }
1670
+ if (section["timeLimits"]) {
1671
+ writeTimeLimits(writer, asNode(section["timeLimits"]));
1672
+ }
1673
+ writeSelectionOrdering(writer, section);
1674
+ writeRubricBlocks(writer, section, ambient);
1675
+ for (const child of nodes(section, "children")) {
1676
+ writeSectionChild(writer, child, ambient);
1677
+ }
1678
+ },
1679
+ );
1680
+ }
1681
+
1682
+ // ---------------------------------------------------------------------------
1683
+ // Roots.
1684
+ // ---------------------------------------------------------------------------
1685
+
1686
+ function rootAttributes(extra: Attrs): Attrs {
1687
+ return [
1688
+ ["xmlns", asiNamespace],
1689
+ ["xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"],
1690
+ ["xsi:schemaLocation", asiSchemaLocation],
1691
+ ...extra,
1692
+ ];
1693
+ }
1694
+
1695
+ function writeDeclarations(writer: XmlWriter, container: Node): void {
1696
+ for (const declaration of nodes(container, "contextDeclarations")) {
1697
+ writeContextDeclaration(writer, declaration);
1698
+ }
1699
+ for (const declaration of nodes(container, "responseDeclarations")) {
1700
+ writeResponseDeclaration(writer, declaration);
1701
+ }
1702
+ for (const declaration of nodes(container, "outcomeDeclarations")) {
1703
+ writeOutcomeDeclaration(writer, declaration);
1704
+ }
1705
+ for (const declaration of nodes(container, "templateDeclarations")) {
1706
+ writeTemplateDeclaration(writer, declaration);
1707
+ }
1708
+ }
1709
+
1710
+ /** Serialize a qti-assessment-item document against the ASI binding. */
1711
+ export function serializeQtiAssessmentItem(document: unknown): string {
1712
+ const item = asNode(asNode(document)["assessmentItem"]);
1713
+ const writer = new XmlWriter();
1714
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1715
+
1716
+ writer.element(
1717
+ "qti-assessment-item",
1718
+ rootAttributes([
1719
+ ["identifier", str(item, "identifier")],
1720
+ ["title", str(item, "title")],
1721
+ ["label", str(item, "label")],
1722
+ ["xml:lang", str(item, "xmlLang")],
1723
+ ["tool-name", str(item, "toolName")],
1724
+ ["tool-version", str(item, "toolVersion")],
1725
+ ["adaptive", attr(item, "adaptive")],
1726
+ ["time-dependent", attr(item, "timeDependent")],
1727
+ ]),
1728
+ () => {
1729
+ writeDeclarations(writer, item);
1730
+
1731
+ const templateProcessing = item["templateProcessing"];
1732
+ if (templateProcessing) {
1733
+ writer.element("qti-template-processing", [], () => {
1734
+ for (const rule of nodes(asNode(templateProcessing), "rules")) {
1735
+ writeTemplateRule(writer, rule);
1736
+ }
1737
+ });
1738
+ }
1739
+
1740
+ for (const ref of nodes(item, "assessmentStimulusRefs")) {
1741
+ writer.element("qti-assessment-stimulus-ref", [
1742
+ ["identifier", str(ref, "identifier")],
1743
+ ["href", str(ref, "href")],
1744
+ ["title", str(ref, "title")],
1745
+ ]);
1746
+ }
1747
+
1748
+ writeCompanionMaterials(writer, item);
1749
+
1750
+ for (const stylesheet of nodes(item, "stylesheets")) {
1751
+ writeStylesheet(writer, stylesheet);
1752
+ }
1753
+
1754
+ const itemBody = item["itemBody"];
1755
+ if (itemBody) {
1756
+ writer.element("qti-item-body", [], () =>
1757
+ writeContent(writer, fragments(asNode(itemBody), "content"), asiNamespace),
1758
+ );
1759
+ }
1760
+
1761
+ writeCatalogInfo(writer, item);
1762
+
1763
+ const responseProcessing = item["responseProcessing"];
1764
+ if (responseProcessing) {
1765
+ writeResponseProcessing(writer, asNode(responseProcessing));
1766
+ }
1767
+
1768
+ for (const feedback of nodes(item, "modalFeedbacks")) {
1769
+ writer.element(
1770
+ "qti-modal-feedback",
1771
+ [
1772
+ ["outcome-identifier", str(feedback, "outcomeIdentifier")],
1773
+ ["identifier", str(feedback, "identifier")],
1774
+ ["show-hide", str(feedback, "showHide")],
1775
+ ["title", str(feedback, "title")],
1776
+ ],
1777
+ () => {
1778
+ writeContentBody(writer, feedback, asiNamespace);
1779
+ writeCatalogInfo(writer, feedback);
1780
+ },
1781
+ );
1782
+ }
1783
+ },
1784
+ );
1785
+
1786
+ return writer.toString();
1787
+ }
1788
+
1789
+ /** Serialize a qti-assessment-stimulus document against the ASI binding. */
1790
+ export function serializeQtiAssessmentStimulus(document: unknown): string {
1791
+ const stimulus = asNode(asNode(document)["assessmentStimulus"]);
1792
+ const writer = new XmlWriter();
1793
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1794
+
1795
+ writer.element(
1796
+ "qti-assessment-stimulus",
1797
+ rootAttributes([
1798
+ ["identifier", str(stimulus, "identifier")],
1799
+ ["title", str(stimulus, "title")],
1800
+ ["label", str(stimulus, "label")],
1801
+ ["xml:lang", str(stimulus, "xmlLang")],
1802
+ ["tool-name", str(stimulus, "toolName")],
1803
+ ["tool-version", str(stimulus, "toolVersion")],
1804
+ ]),
1805
+ () => {
1806
+ for (const stylesheet of nodes(stimulus, "stylesheets")) {
1807
+ writeStylesheet(writer, stylesheet);
1808
+ }
1809
+ const body = asNode(stimulus["stimulusBody"]);
1810
+ writer.element("qti-stimulus-body", [], () => writeContent(writer, fragments(body, "content"), asiNamespace));
1811
+ writeCatalogInfo(writer, stimulus);
1812
+ },
1813
+ );
1814
+
1815
+ return writer.toString();
1816
+ }
1817
+
1818
+ /** Serialize a qti-assessment-test document against the ASI binding. */
1819
+ export function serializeQtiAssessmentTest(document: unknown): string {
1820
+ const test = asNode(asNode(document)["assessmentTest"]);
1821
+ const writer = new XmlWriter();
1822
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1823
+
1824
+ writer.element(
1825
+ "qti-assessment-test",
1826
+ rootAttributes([
1827
+ ["identifier", str(test, "identifier")],
1828
+ ["title", str(test, "title")],
1829
+ ["tool-name", str(test, "toolName")],
1830
+ ["tool-version", str(test, "toolVersion")],
1831
+ ]),
1832
+ () => {
1833
+ for (const declaration of nodes(test, "contextDeclarations")) {
1834
+ writeContextDeclaration(writer, declaration);
1835
+ }
1836
+ for (const declaration of nodes(test, "outcomeDeclarations")) {
1837
+ writeOutcomeDeclaration(writer, declaration);
1838
+ }
1839
+ if (test["timeLimits"]) {
1840
+ writeTimeLimits(writer, asNode(test["timeLimits"]));
1841
+ }
1842
+ for (const stylesheet of nodes(test, "stylesheets")) {
1843
+ writeStylesheet(writer, stylesheet);
1844
+ }
1845
+ writeRubricBlocks(writer, test, asiNamespace);
1846
+
1847
+ for (const part of nodes(test, "testParts")) {
1848
+ writer.element(
1849
+ "qti-test-part",
1850
+ [
1851
+ ["identifier", str(part, "identifier")],
1852
+ ["navigation-mode", str(part, "navigationMode")],
1853
+ ["submission-mode", str(part, "submissionMode")],
1854
+ ["title", str(part, "title")],
1855
+ ],
1856
+ () => {
1857
+ for (const condition of nodes(part, "preConditions")) {
1858
+ writePreCondition(writer, condition);
1859
+ }
1860
+ for (const rule of nodes(part, "branchRules")) {
1861
+ writeBranchRule(writer, rule);
1862
+ }
1863
+ if (part["itemSessionControl"]) {
1864
+ writeItemSessionControl(writer, asNode(part["itemSessionControl"]));
1865
+ }
1866
+ if (part["timeLimits"]) {
1867
+ writeTimeLimits(writer, asNode(part["timeLimits"]));
1868
+ }
1869
+ writeRubricBlocks(writer, part, asiNamespace);
1870
+ for (const section of nodes(part, "children")) {
1871
+ writeSection(writer, section, asiNamespace);
1872
+ }
1873
+ for (const feedback of nodes(part, "testFeedbacks")) {
1874
+ writeTestFeedback(writer, feedback, asiNamespace);
1875
+ }
1876
+ },
1877
+ );
1878
+ }
1879
+
1880
+ const outcomeProcessing = test["outcomeProcessing"];
1881
+ if (outcomeProcessing) {
1882
+ writer.element("qti-outcome-processing", [], () => {
1883
+ for (const rule of nodes(asNode(outcomeProcessing), "rules")) {
1884
+ writeOutcomeRule(writer, rule);
1885
+ }
1886
+ });
1887
+ }
1888
+
1889
+ for (const feedback of nodes(test, "testFeedbacks")) {
1890
+ writeTestFeedback(writer, feedback, asiNamespace);
1891
+ }
1892
+ },
1893
+ );
1894
+
1895
+ return writer.toString();
1896
+ }
1897
+
1898
+ /** Serialize a standalone qti-assessment-section document. */
1899
+ export function serializeQtiAssessmentSection(document: unknown): string {
1900
+ const section = asNode(asNode(document)["assessmentSection"]);
1901
+ const writer = new XmlWriter();
1902
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1903
+
1904
+ // The root section is the shared section structure with the binding namespace
1905
+ // attributes added on the root element.
1906
+ writer.element(
1907
+ "qti-assessment-section",
1908
+ rootAttributes([
1909
+ ["identifier", str(section, "identifier")],
1910
+ ["title", str(section, "title")],
1911
+ ["visible", attr(section, "visible")],
1912
+ ["required", attr(section, "required")],
1913
+ ["fixed", attr(section, "fixed")],
1914
+ ["keep-together", attr(section, "keepTogether")],
1915
+ ]),
1916
+ () => {
1917
+ for (const condition of nodes(section, "preConditions")) {
1918
+ writePreCondition(writer, condition);
1919
+ }
1920
+ for (const rule of nodes(section, "branchRules")) {
1921
+ writeBranchRule(writer, rule);
1922
+ }
1923
+ if (section["itemSessionControl"]) {
1924
+ writeItemSessionControl(writer, asNode(section["itemSessionControl"]));
1925
+ }
1926
+ if (section["timeLimits"]) {
1927
+ writeTimeLimits(writer, asNode(section["timeLimits"]));
1928
+ }
1929
+ writeSelectionOrdering(writer, section);
1930
+ writeRubricBlocks(writer, section, asiNamespace);
1931
+ for (const child of nodes(section, "children")) {
1932
+ writeSectionChild(writer, child, asiNamespace);
1933
+ }
1934
+ },
1935
+ );
1936
+
1937
+ return writer.toString();
1938
+ }
1939
+
1940
+ /** Serialize a standalone qti-response-processing document (best-practice templates). */
1941
+ export function serializeQtiResponseProcessingDocument(document: unknown): string {
1942
+ const processing = asNode(asNode(document)["responseProcessing"]);
1943
+ const writer = new XmlWriter();
1944
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1945
+
1946
+ writer.element(
1947
+ "qti-response-processing",
1948
+ rootAttributes([
1949
+ ["template", str(processing, "template")],
1950
+ ["template-location", str(processing, "templateLocation")],
1951
+ ]),
1952
+ () => {
1953
+ for (const rule of nodes(processing, "rules")) {
1954
+ writeResponseRule(writer, rule);
1955
+ }
1956
+ },
1957
+ );
1958
+
1959
+ return writer.toString();
1960
+ }
1961
+
1962
+ /** Serialize a standalone qti-outcome-declaration document. */
1963
+ export function serializeQtiOutcomeDeclarationDocument(document: unknown): string {
1964
+ const declaration = asNode(asNode(document)["outcomeDeclaration"]);
1965
+ const writer = new XmlWriter();
1966
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
1967
+
1968
+ writer.element(
1969
+ "qti-outcome-declaration",
1970
+ rootAttributes([
1971
+ ["identifier", str(declaration, "identifier")],
1972
+ ["cardinality", str(declaration, "cardinality")],
1973
+ ["base-type", str(declaration, "baseType")],
1974
+ ["view", list(declaration, "view")],
1975
+ ["external-scored", str(declaration, "externalScored")],
1976
+ ["interpretation", str(declaration, "interpretation")],
1977
+ ["long-interpretation", str(declaration, "longInterpretation")],
1978
+ ["normal-maximum", attr(declaration, "normalMaximum")],
1979
+ ["normal-minimum", attr(declaration, "normalMinimum")],
1980
+ ["mastery-value", attr(declaration, "masteryValue")],
1981
+ ]),
1982
+ () => {
1983
+ writeDefaultValue(writer, declaration);
1984
+ const matchTable = declaration["matchTable"];
1985
+ if (matchTable) {
1986
+ writer.element("qti-match-table", [["default-value", str(asNode(matchTable), "defaultValue")]], () => {
1987
+ for (const entry of nodes(asNode(matchTable), "matchTableEntries")) {
1988
+ writer.element("qti-match-table-entry", [
1989
+ ["source-value", attr(entry, "sourceValue")],
1990
+ ["target-value", str(entry, "targetValue")],
1991
+ ]);
1992
+ }
1993
+ });
1994
+ }
1995
+ const interpolationTable = declaration["interpolationTable"];
1996
+ if (interpolationTable) {
1997
+ writer.element(
1998
+ "qti-interpolation-table",
1999
+ [["default-value", str(asNode(interpolationTable), "defaultValue")]],
2000
+ () => {
2001
+ for (const entry of nodes(asNode(interpolationTable), "interpolationTableEntries")) {
2002
+ writer.element("qti-interpolation-table-entry", [
2003
+ ["source-value", attr(entry, "sourceValue")],
2004
+ ["target-value", str(entry, "targetValue")],
2005
+ ["include-boundary", attr(entry, "includeBoundary")],
2006
+ ]);
2007
+ }
2008
+ },
2009
+ );
2010
+ }
2011
+ },
2012
+ );
2013
+
2014
+ return writer.toString();
2015
+ }
2016
+
2017
+ /** Serialize a standalone qti-outcome-processing document. */
2018
+ export function serializeQtiOutcomeProcessingDocument(document: unknown): string {
2019
+ const processing = asNode(asNode(document)["outcomeProcessing"]);
2020
+ const writer = new XmlWriter();
2021
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
2022
+
2023
+ writer.element("qti-outcome-processing", rootAttributes([]), () => {
2024
+ for (const rule of nodes(processing, "rules")) {
2025
+ writeOutcomeRule(writer, rule);
2026
+ }
2027
+ });
2028
+
2029
+ return writer.toString();
2030
+ }