@conform-ed/contracts 0.0.3

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 (187) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-format.log +6 -0
  3. package/.turbo/turbo-lint.log +4 -0
  4. package/.turbo/turbo-test.log +196 -0
  5. package/.turbo/turbo-typecheck.log +2 -0
  6. package/README.md +52 -0
  7. package/caliper-v1_2-zod-templates.md +123 -0
  8. package/case-v1_1-zod-templates.md +290 -0
  9. package/cat-v1_0-zod-templates.md +73 -0
  10. package/cc-v1_3-zod-templates.md +531 -0
  11. package/cc-v1_4-zod-templates.md +247 -0
  12. package/clr-v2_0-zod-templates.md +117 -0
  13. package/cmi5-v1_0-zod-templates.md +9 -0
  14. package/lti-zod-templates.md +11 -0
  15. package/oneroster-v1_2-zod-templates.md +76 -0
  16. package/open-badges-v3_0-zod-templates.md +66 -0
  17. package/package.json +42 -0
  18. package/qti-v2_1-zod-templates.md +45 -0
  19. package/qti-v2_2-zod-templates.md +32 -0
  20. package/qti-v3_0_1-zod-templates.md +421 -0
  21. package/src/adapter.ts +83 -0
  22. package/src/caliper/v1_2/caliper_v1p2_bootcamp_schema.ts +781 -0
  23. package/src/caliper/v1_2/index.ts +37 -0
  24. package/src/caliper/v1_2/shared.ts +122 -0
  25. package/src/caliper/v1_2/textual_requirements.ts +219 -0
  26. package/src/case/v1_1/case_v1p1_cfassociation_jsonschema1.ts +1 -0
  27. package/src/case/v1_1/case_v1p1_cfassociationgrouping_jsonschema1.ts +1 -0
  28. package/src/case/v1_1/case_v1p1_cfassociationset_jsonschema1.ts +1 -0
  29. package/src/case/v1_1/case_v1p1_cfconceptset_jsonschema1.ts +1 -0
  30. package/src/case/v1_1/case_v1p1_cfdocument_jsonschema1.ts +1 -0
  31. package/src/case/v1_1/case_v1p1_cfdocumentset_jsonschema1.ts +1 -0
  32. package/src/case/v1_1/case_v1p1_cfitem_jsonschema1.ts +1 -0
  33. package/src/case/v1_1/case_v1p1_cfitemtypeset_jsonschema1.ts +1 -0
  34. package/src/case/v1_1/case_v1p1_cflicense_jsonschema1.ts +1 -0
  35. package/src/case/v1_1/case_v1p1_cfpackage_jsonschema1.ts +1 -0
  36. package/src/case/v1_1/case_v1p1_cfrubric_jsonschema1.ts +1 -0
  37. package/src/case/v1_1/case_v1p1_cfsubjectset_jsonschema1.ts +1 -0
  38. package/src/case/v1_1/case_v1p1_imsx_statusinfo_jsonschema1.ts +1 -0
  39. package/src/case/v1_1/case_v1p1_openapi3_restbinding_schema.ts +113 -0
  40. package/src/case/v1_1/index.ts +95 -0
  41. package/src/case/v1_1/shared.ts +384 -0
  42. package/src/cat/v1_0/cat_v1p0_restbinding_operations_schema.ts +75 -0
  43. package/src/cat/v1_0/index.ts +124 -0
  44. package/src/cat/v1_0/shared.ts +345 -0
  45. package/src/clr/v2_0/clr_v2p0_achievementcredential_schema.ts +16 -0
  46. package/src/clr/v2_0/clr_v2p0_clrcredential_schema.ts +9 -0
  47. package/src/clr/v2_0/clr_v2p0_endorsementcredential_schema.ts +11 -0
  48. package/src/clr/v2_0/clr_v2p0_getclrcredentialsresponse_schema.ts +1 -0
  49. package/src/clr/v2_0/clr_v2p0_imsx_statusinfo_schema.ts +8 -0
  50. package/src/clr/v2_0/clr_v2p0_profile_schema.ts +9 -0
  51. package/src/clr/v2_0/index.ts +22 -0
  52. package/src/clr/v2_0/shared.ts +206 -0
  53. package/src/cmi5/index.ts +3 -0
  54. package/src/cmi5/v1_0/index.ts +134 -0
  55. package/src/common-cartridge/v1_3/ccv1p3_imsccauth_v1p3.ts +26 -0
  56. package/src/common-cartridge/v1_3/ccv1p3_imscp_v1p2_v1p0.ts +352 -0
  57. package/src/common-cartridge/v1_3/ccv1p3_imscsmd_v1p0.ts +35 -0
  58. package/src/common-cartridge/v1_3/ccv1p3_imsdt_v1p3.ts +33 -0
  59. package/src/common-cartridge/v1_3/ccv1p3_imswl_v1p3.ts +23 -0
  60. package/src/common-cartridge/v1_3/ccv1p3_lomccltilink_v1p0.ts +14 -0
  61. package/src/common-cartridge/v1_3/ccv1p3_lommanifest_v1p0.ts +14 -0
  62. package/src/common-cartridge/v1_3/ccv1p3_lomresource_v1p0.ts +14 -0
  63. package/src/common-cartridge/v1_3/ccv1p3_qtiasiv1p2p1_v1p0.ts +964 -0
  64. package/src/common-cartridge/v1_3/index.ts +41 -0
  65. package/src/common-cartridge/v1_3/lom-internal.ts +396 -0
  66. package/src/common-cartridge/v1_3/shared.ts +68 -0
  67. package/src/common-cartridge/v1_4/core/ccv1p4_imscp_v1p2_v1p0.ts +360 -0
  68. package/src/common-cartridge/v1_4/core/ccv1p4_lommanifest_v1p0.ts +14 -0
  69. package/src/common-cartridge/v1_4/core/ccv1p4_lomresource_v1p0.ts +14 -0
  70. package/src/common-cartridge/v1_4/extension/cc_extresource_assignmentv1p0_v1p0.ts +61 -0
  71. package/src/common-cartridge/v1_4/extension/ccv1p4_cpextensionv1p2_v1p0.ts +20 -0
  72. package/src/common-cartridge/v1_4/extension/ims_openvideov1p0_v1p0.ts +325 -0
  73. package/src/common-cartridge/v1_4/index.ts +104 -0
  74. package/src/common-cartridge/v1_4/k12/ccv1p4_imscp_v1p2_v1p0.ts +19 -0
  75. package/src/common-cartridge/v1_4/k12/ccv1p4_imscp_v1p2_v1p0_thin.ts +17 -0
  76. package/src/common-cartridge/v1_4/k12/ccv1p4_lommanifest_v1p0.ts +14 -0
  77. package/src/common-cartridge/v1_4/k12/ccv1p4_lomresource_v1p0.ts +14 -0
  78. package/src/common-cartridge/v1_4/lom-internal.ts +476 -0
  79. package/src/common-cartridge/v1_4/shared/ccv1p4_imsccauth_v1p4.ts +26 -0
  80. package/src/common-cartridge/v1_4/shared/ccv1p4_imscsmd_v1p1.ts +36 -0
  81. package/src/common-cartridge/v1_4/shared/ccv1p4_imsdt_v1p4.ts +33 -0
  82. package/src/common-cartridge/v1_4/shared/ccv1p4_imslticc_v1p4.ts +45 -0
  83. package/src/common-cartridge/v1_4/shared/ccv1p4_imswl_v1p4.ts +23 -0
  84. package/src/common-cartridge/v1_4/shared/ccv1p4_lomccltilink_v1p0.ts +14 -0
  85. package/src/common-cartridge/v1_4/shared/ccv1p4_qtiasiv1p2p1_v1p0.ts +964 -0
  86. package/src/common-cartridge/v1_4/shared/imsbasiclti_v1p0p1.ts +24 -0
  87. package/src/common-cartridge/v1_4/shared/imslticm_v1p0.ts +23 -0
  88. package/src/common-cartridge/v1_4/shared/imslticp_v1p0.ts +57 -0
  89. package/src/common-cartridge/v1_4/shared/lineitem_v1p0.ts +35 -0
  90. package/src/common-cartridge/v1_4/shared/resourcea11ymetadata-20210915.ts +110 -0
  91. package/src/common-cartridge/v1_4/shared.ts +68 -0
  92. package/src/common-cartridge/v1_4/thin/ccv1p4_imscp_v1p2_v1p0.ts +243 -0
  93. package/src/common-cartridge/v1_4/thin/ccv1p4_lommanifest_v1p0.ts +14 -0
  94. package/src/common-cartridge/v1_4/thin/ccv1p4_lomresource_v1p0.ts +14 -0
  95. package/src/common-cartridge/v1_4/vdex/imsmd_loose_v1p3p2.ts +13 -0
  96. package/src/common-cartridge/v1_4/vdex/imsvdex_v1p0.ts +124 -0
  97. package/src/config.ts +121 -0
  98. package/src/index.ts +32 -0
  99. package/src/lti/ags/v2_0/index.ts +97 -0
  100. package/src/lti/deep-linking/v2_0/index.ts +100 -0
  101. package/src/lti/index.ts +26 -0
  102. package/src/lti/nrps/v2_0/index.ts +69 -0
  103. package/src/lti/proctoring/v1_0/index.ts +131 -0
  104. package/src/lti/shared.ts +66 -0
  105. package/src/lti/v1_3/index.ts +84 -0
  106. package/src/oneroster/v1_2/index.ts +156 -0
  107. package/src/oneroster/v1_2/or_v1p2_csv_binding_schema.ts +427 -0
  108. package/src/oneroster/v1_2/or_v1p2_gradebook_restbinding_schema.ts +120 -0
  109. package/src/oneroster/v1_2/or_v1p2_gradebook_service_schema.ts +243 -0
  110. package/src/oneroster/v1_2/or_v1p2_resource_restbinding_schema.ts +24 -0
  111. package/src/oneroster/v1_2/or_v1p2_resource_service_schema.ts +47 -0
  112. package/src/oneroster/v1_2/or_v1p2_rostering_restbinding_schema.ts +84 -0
  113. package/src/oneroster/v1_2/or_v1p2_rostering_service_schema.ts +288 -0
  114. package/src/oneroster/v1_2/shared.ts +90 -0
  115. package/src/open-badges/v3_0/index.ts +20 -0
  116. package/src/open-badges/v3_0/ob_v3p0_achievementcredential_schema.ts +17 -0
  117. package/src/open-badges/v3_0/ob_v3p0_endorsementcredential_schema.ts +11 -0
  118. package/src/open-badges/v3_0/ob_v3p0_getopenbadgecredentialsresponse_schema.ts +1 -0
  119. package/src/open-badges/v3_0/ob_v3p0_imsx_statusinfo_schema.ts +8 -0
  120. package/src/open-badges/v3_0/ob_v3p0_profile_schema.ts +9 -0
  121. package/src/open-badges/v3_0/shared.ts +458 -0
  122. package/src/qti/v2-internal.ts +1683 -0
  123. package/src/qti/v2_1/apipv1p0_qtiextv2p1_v1p0.ts +3 -0
  124. package/src/qti/v2_1/imsqti_metadata_v2p1.ts +3 -0
  125. package/src/qti/v2_1/imsqti_result_v2p1.ts +3 -0
  126. package/src/qti/v2_1/imsqti_usagedata_v2p1.ts +3 -0
  127. package/src/qti/v2_1/imsqti_v2p1p2.ts +71 -0
  128. package/src/qti/v2_1/index.ts +53 -0
  129. package/src/qti/v2_1/qtiv2p1_imscpv1p2_v1p0.ts +3 -0
  130. package/src/qti/v2_1/schemas.ts +15 -0
  131. package/src/qti/v2_1/shared.ts +3 -0
  132. package/src/qti/v2_2/apipv1p0_qtiextv2p2_v1p0p1.ts +3 -0
  133. package/src/qti/v2_2/imsqti_metadata_v2p2.ts +3 -0
  134. package/src/qti/v2_2/imsqti_result_v2p2.ts +3 -0
  135. package/src/qti/v2_2/imsqti_usagedata_v2p2.ts +3 -0
  136. package/src/qti/v2_2/imsqti_v2p2p4.ts +76 -0
  137. package/src/qti/v2_2/imsqtiv2p2p4_html5_v1p0.ts +8 -0
  138. package/src/qti/v2_2/index.ts +59 -0
  139. package/src/qti/v2_2/qtiv2p2_csm_v2p2.ts +4 -0
  140. package/src/qti/v2_2/qtiv2p2_imscpv1p2_v1p0.ts +3 -0
  141. package/src/qti/v2_2/schemas.ts +19 -0
  142. package/src/qti/v2_2/shared.ts +3 -0
  143. package/src/qti/v3_0_1/assessment-internal.ts +1509 -0
  144. package/src/qti/v3_0_1/imsqti_asiv3p0p1_v1p0.ts +57 -0
  145. package/src/qti/v3_0_1/imsqti_itemv3p0p1_v1p0.ts +60 -0
  146. package/src/qti/v3_0_1/imsqti_metadatav3p0_v1p0.ts +73 -0
  147. package/src/qti/v3_0_1/imsqti_outcomev3p0p1_v1p0.ts +11 -0
  148. package/src/qti/v3_0_1/imsqti_responseprocessingv3p0p1_v1p0.ts +17 -0
  149. package/src/qti/v3_0_1/imsqti_resultv3p0_v1p0.ts +222 -0
  150. package/src/qti/v3_0_1/imsqti_sectionv3p0p1_v1p0.ts +15 -0
  151. package/src/qti/v3_0_1/imsqti_stimulusv3p0p1_v1p0.ts +15 -0
  152. package/src/qti/v3_0_1/imsqti_testv3p0p1_v1p0.ts +49 -0
  153. package/src/qti/v3_0_1/imsqti_usagedatav3p0_v1p0.ts +77 -0
  154. package/src/qti/v3_0_1/imsqtiv3p0_afa3p0pnp_v1p0.ts +269 -0
  155. package/src/qti/v3_0_1/index.ts +50 -0
  156. package/src/qti/v3_0_1/processing-internal.ts +667 -0
  157. package/src/qti/v3_0_1/shared.ts +146 -0
  158. package/src/qti/v3_0_1/variables-internal.ts +274 -0
  159. package/src/shared/imsx-status.ts +49 -0
  160. package/src/summary.ts +70 -0
  161. package/src/vc-data-model/v2_0/index.ts +27 -0
  162. package/src/vc-data-model/v2_0/shared.ts +206 -0
  163. package/src/xapi/index.ts +12 -0
  164. package/src/xapi/shared.ts +444 -0
  165. package/src/xapi/v1_0_3/index.ts +169 -0
  166. package/src/xapi/v2_0/index.ts +256 -0
  167. package/test/caliper-v1_2.test.ts +270 -0
  168. package/test/case-v1_1.test.ts +147 -0
  169. package/test/cat-v1_0.test.ts +338 -0
  170. package/test/cc-v1_4.test.ts +239 -0
  171. package/test/clr-v2_0.test.ts +225 -0
  172. package/test/cmi5-v1_0.test.ts +196 -0
  173. package/test/contracts.test.ts +125 -0
  174. package/test/fixtures/cmi5/extended-cmi5.xml +72 -0
  175. package/test/fixtures/cmi5/simple-cmi5.xml +26 -0
  176. package/test/lti.test.ts +146 -0
  177. package/test/oneroster-v1_2.test.ts +234 -0
  178. package/test/open-badges-v3_0.test.ts +144 -0
  179. package/test/qti-v2_1.test.ts +146 -0
  180. package/test/qti-v2_2.test.ts +154 -0
  181. package/test/qti-v3_0_1.test.ts +576 -0
  182. package/test/schema-examples.test.ts +101 -0
  183. package/test/vc-data-model-v2_0.test.ts +63 -0
  184. package/test/xapi.test.ts +384 -0
  185. package/tsconfig.json +7 -0
  186. package/vc-data-model-v2_0-zod-templates.md +43 -0
  187. package/xapi-zod-templates.md +95 -0
@@ -0,0 +1,964 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ NonEmptyStringSchema,
5
+ YesNoSchema,
6
+ XmlSpaceSchema,
7
+ addIssue,
8
+ asArray,
9
+ collectDuplicates,
10
+ strictObject,
11
+ } from "./shared";
12
+
13
+ export const QtiMatTextSchema = strictObject({
14
+ kind: z.literal("mattext"),
15
+ value: z.string(),
16
+ texttype: z.string().optional(),
17
+ charset: z.string().optional(),
18
+ label: z.string().optional(),
19
+ uri: z.string().optional(),
20
+ width: z.string().optional(),
21
+ height: z.string().optional(),
22
+ x0: z.string().optional(),
23
+ y0: z.string().optional(),
24
+ xmlLang: z.string().optional(),
25
+ xmlSpace: XmlSpaceSchema.optional(),
26
+ });
27
+
28
+ export const QtiMatRefSchema = strictObject({
29
+ kind: z.literal("matref"),
30
+ linkrefid: NonEmptyStringSchema,
31
+ });
32
+
33
+ export const QtiMatBreakSchema = strictObject({
34
+ kind: z.literal("matbreak"),
35
+ });
36
+
37
+ export const QtiMaterialRefSchema = strictObject({
38
+ kind: z.literal("material_ref"),
39
+ linkrefid: NonEmptyStringSchema,
40
+ });
41
+
42
+ export const QtiMaterialChildSchema = z.union([QtiMatTextSchema, QtiMatRefSchema, QtiMatBreakSchema]);
43
+
44
+ export const QtiAltMaterialSchema = strictObject({
45
+ kind: z.literal("altmaterial"),
46
+ xmlLang: z.string().optional(),
47
+ children: z.array(QtiMaterialChildSchema).min(1),
48
+ });
49
+
50
+ export const QtiMaterialSchema = strictObject({
51
+ kind: z.literal("material"),
52
+ label: z.string().optional(),
53
+ xmlLang: z.string().optional(),
54
+ children: z.array(QtiMaterialChildSchema).min(1),
55
+ altmaterial: z.array(QtiAltMaterialSchema).optional(),
56
+ });
57
+
58
+ export const QtiFlowMatSchema: z.ZodTypeAny = z.lazy(() =>
59
+ strictObject({
60
+ kind: z.literal("flow_mat"),
61
+ class: z.string().optional(),
62
+ children: z.array(z.union([QtiFlowMatSchema, QtiMaterialSchema, QtiMaterialRefSchema])).min(1),
63
+ }),
64
+ );
65
+
66
+ export const QtiResponseLabelSchema = strictObject({
67
+ kind: z.literal("response_label"),
68
+ ident: NonEmptyStringSchema,
69
+ labelrefid: z.string().optional(),
70
+ rshuffle: YesNoSchema.optional(),
71
+ match_group: z.string().optional(),
72
+ match_max: z.string().optional(),
73
+ children: z.array(z.union([QtiMaterialSchema, QtiMaterialRefSchema, QtiFlowMatSchema])).optional(),
74
+ });
75
+
76
+ export const QtiFlowLabelSchema: z.ZodTypeAny = z.lazy(() =>
77
+ strictObject({
78
+ kind: z.literal("flow_label"),
79
+ class: z.string().optional(),
80
+ children: z.array(z.union([QtiFlowLabelSchema, QtiResponseLabelSchema])).min(1),
81
+ }),
82
+ );
83
+
84
+ export const QtiRenderChoiceSchema = strictObject({
85
+ kind: z.literal("render_choice"),
86
+ shuffle: YesNoSchema.optional(),
87
+ minnumber: z.string().optional(),
88
+ maxnumber: z.string().optional(),
89
+ children: z
90
+ .array(z.union([QtiMaterialSchema, QtiMaterialRefSchema, QtiResponseLabelSchema, QtiFlowLabelSchema]))
91
+ .optional(),
92
+ });
93
+
94
+ export const QtiRenderFibSchema = strictObject({
95
+ kind: z.literal("render_fib"),
96
+ encoding: z.string().optional(),
97
+ charset: z.string().optional(),
98
+ rows: z.string().optional(),
99
+ columns: z.string().optional(),
100
+ maxchars: z.string().optional(),
101
+ minnumber: z.string().optional(),
102
+ maxnumber: z.string().optional(),
103
+ prompt: z.enum(["Asterisk", "Box", "Dashline", "Underline"]).optional(),
104
+ fibtype: z.enum(["Decimal", "Integer", "Scientific", "String"]).optional(),
105
+ children: z
106
+ .array(z.union([QtiMaterialSchema, QtiMaterialRefSchema, QtiResponseLabelSchema, QtiFlowLabelSchema]))
107
+ .optional(),
108
+ });
109
+
110
+ const QtiResponseRenderSchema = z.union([QtiRenderChoiceSchema, QtiRenderFibSchema]);
111
+ const QtiLeadingOrTrailingMaterialSchema = z.union([QtiMaterialSchema, QtiMaterialRefSchema]);
112
+
113
+ export const QtiResponseLidSchema = strictObject({
114
+ kind: z.literal("response_lid"),
115
+ ident: NonEmptyStringSchema,
116
+ rcardinality: z.enum(["Single", "Multiple", "Ordered"]).optional(),
117
+ rtiming: YesNoSchema.optional(),
118
+ leading: QtiLeadingOrTrailingMaterialSchema.optional(),
119
+ render: QtiResponseRenderSchema,
120
+ trailing: QtiLeadingOrTrailingMaterialSchema.optional(),
121
+ });
122
+
123
+ export const QtiResponseStrSchema = strictObject({
124
+ kind: z.literal("response_str"),
125
+ ident: NonEmptyStringSchema,
126
+ rcardinality: z.enum(["Single", "Multiple", "Ordered"]).optional(),
127
+ rtiming: YesNoSchema.optional(),
128
+ leading: QtiLeadingOrTrailingMaterialSchema.optional(),
129
+ render: QtiResponseRenderSchema,
130
+ trailing: QtiLeadingOrTrailingMaterialSchema.optional(),
131
+ });
132
+
133
+ export const QtiFlowSchema: z.ZodTypeAny = z.lazy(() =>
134
+ strictObject({
135
+ kind: z.literal("flow"),
136
+ class: z.string().optional(),
137
+ children: z
138
+ .array(
139
+ z.union([QtiFlowSchema, QtiMaterialSchema, QtiMaterialRefSchema, QtiResponseLidSchema, QtiResponseStrSchema]),
140
+ )
141
+ .min(1),
142
+ }),
143
+ );
144
+
145
+ export const QtiPresentationSchema = z.union([
146
+ strictObject({
147
+ flow: QtiFlowSchema,
148
+ label: z.string().optional(),
149
+ xmlLang: z.string().optional(),
150
+ x0: z.string().optional(),
151
+ y0: z.string().optional(),
152
+ width: z.string().optional(),
153
+ height: z.string().optional(),
154
+ }),
155
+ strictObject({
156
+ children: z.array(z.union([QtiMaterialSchema, QtiResponseLidSchema, QtiResponseStrSchema])).min(1),
157
+ label: z.string().optional(),
158
+ xmlLang: z.string().optional(),
159
+ x0: z.string().optional(),
160
+ y0: z.string().optional(),
161
+ width: z.string().optional(),
162
+ height: z.string().optional(),
163
+ }),
164
+ ]);
165
+
166
+ export const QtiPresentationMaterialSchema = strictObject({
167
+ flow_mat: z.array(QtiFlowMatSchema).min(1),
168
+ });
169
+
170
+ export const QtiHintMaterialSchema = z.union([
171
+ strictObject({
172
+ flow_mat: z.array(QtiFlowMatSchema).min(1),
173
+ }),
174
+ strictObject({
175
+ material: z.array(QtiMaterialSchema).min(1),
176
+ }),
177
+ ]);
178
+
179
+ export const QtiHintSchema = strictObject({
180
+ kind: z.literal("hint"),
181
+ feedbackstyle: z.literal("Complete").optional(),
182
+ hintmaterial: z.array(QtiHintMaterialSchema).min(1),
183
+ });
184
+
185
+ export const QtiSolutionMaterialSchema = z.union([
186
+ strictObject({
187
+ flow_mat: z.array(QtiFlowMatSchema).min(1),
188
+ }),
189
+ strictObject({
190
+ material: z.array(QtiMaterialSchema).min(1),
191
+ }),
192
+ ]);
193
+
194
+ export const QtiSolutionSchema = strictObject({
195
+ kind: z.literal("solution"),
196
+ feedbackstyle: z.literal("Complete").optional(),
197
+ solutionmaterial: z.array(QtiSolutionMaterialSchema).min(1),
198
+ });
199
+
200
+ export const QtiItemFeedbackSchema = strictObject({
201
+ ident: NonEmptyStringSchema,
202
+ title: z.string().optional(),
203
+ children: z.array(z.union([QtiFlowMatSchema, QtiMaterialSchema, QtiSolutionSchema, QtiHintSchema])).min(1),
204
+ });
205
+
206
+ export const QtiQtimetadatafieldSchema = strictObject({
207
+ fieldlabel: NonEmptyStringSchema,
208
+ fieldentry: z.string(),
209
+ xmlLang: z.string().optional(),
210
+ });
211
+
212
+ export const QtiQtimetadataSchema = strictObject({
213
+ qtimetadatafield: z.array(QtiQtimetadatafieldSchema).min(1),
214
+ });
215
+
216
+ export const QtiItemMetadataSchema = strictObject({
217
+ qtimetadata: z.array(QtiQtimetadataSchema).min(1),
218
+ });
219
+
220
+ export const QtiRubricSchema = strictObject({
221
+ material: QtiMaterialSchema,
222
+ });
223
+
224
+ export const QtiDecvarSchema = strictObject({
225
+ value: z.string(),
226
+ varname: z.literal("SCORE"),
227
+ vartype: z.enum(["Decimal", "Integer"]).optional(),
228
+ minvalue: z.string().optional(),
229
+ maxvalue: z.string().optional(),
230
+ });
231
+
232
+ export const QtiDisplayFeedbackSchema = strictObject({
233
+ value: z.string().optional(),
234
+ feedbacktype: z.enum(["Response", "Solution", "Hint"]),
235
+ linkrefid: NonEmptyStringSchema,
236
+ });
237
+
238
+ export const QtiSetVarSchema = strictObject({
239
+ value: z.string(),
240
+ varname: z.string().optional(),
241
+ action: z.literal("Set").optional(),
242
+ });
243
+
244
+ export const QtiVarEqualSchema = strictObject({
245
+ kind: z.literal("varequal"),
246
+ value: z.string(),
247
+ respident: NonEmptyStringSchema,
248
+ case: YesNoSchema.optional(),
249
+ });
250
+
251
+ export const QtiVarSubstringSchema = strictObject({
252
+ kind: z.literal("varsubstring"),
253
+ value: z.string(),
254
+ respident: NonEmptyStringSchema,
255
+ case: YesNoSchema.optional(),
256
+ });
257
+
258
+ export const QtiOtherConditionSchema = strictObject({
259
+ kind: z.literal("other"),
260
+ });
261
+
262
+ export const QtiNotSchema: z.ZodTypeAny = z.lazy(() =>
263
+ strictObject({
264
+ kind: z.literal("not"),
265
+ tests: z.array(z.union([QtiAndSchema, QtiOrSchema, QtiVarEqualSchema])).min(1),
266
+ }),
267
+ );
268
+
269
+ export const QtiAndSchema: z.ZodTypeAny = z.lazy(() =>
270
+ strictObject({
271
+ kind: z.literal("and"),
272
+ tests: z.array(z.union([QtiNotSchema, QtiVarEqualSchema])).min(1),
273
+ }),
274
+ );
275
+
276
+ export const QtiOrSchema: z.ZodTypeAny = z.lazy(() =>
277
+ strictObject({
278
+ kind: z.literal("or"),
279
+ tests: z.array(z.union([QtiNotSchema, QtiVarEqualSchema])).min(1),
280
+ }),
281
+ );
282
+
283
+ export const QtiConditionNodeSchema: z.ZodTypeAny = z.lazy(() =>
284
+ z.union([QtiAndSchema, QtiOrSchema, QtiNotSchema, QtiOtherConditionSchema, QtiVarEqualSchema, QtiVarSubstringSchema]),
285
+ );
286
+
287
+ export const QtiConditionVarSchema = strictObject({
288
+ tests: z.array(QtiConditionNodeSchema).min(1),
289
+ });
290
+
291
+ export const QtiRespConditionSchema = strictObject({
292
+ title: z.string().optional(),
293
+ continue: YesNoSchema.optional(),
294
+ conditionvar: QtiConditionVarSchema,
295
+ setvar: z.array(QtiSetVarSchema).optional(),
296
+ displayfeedback: z.array(QtiDisplayFeedbackSchema).optional(),
297
+ });
298
+
299
+ export const QtiOutcomesSchema = strictObject({
300
+ decvar: QtiDecvarSchema,
301
+ });
302
+
303
+ export const QtiResprocessingSchema = strictObject({
304
+ outcomes: QtiOutcomesSchema,
305
+ respcondition: z.array(QtiRespConditionSchema).min(1),
306
+ });
307
+
308
+ export const QtiItemSchema = strictObject({
309
+ ident: NonEmptyStringSchema,
310
+ title: z.string().optional(),
311
+ xmlLang: z.string().optional(),
312
+ itemmetadata: QtiItemMetadataSchema.optional(),
313
+ presentation: QtiPresentationSchema.optional(),
314
+ resprocessing: z.array(QtiResprocessingSchema).optional(),
315
+ itemfeedback: z.array(QtiItemFeedbackSchema).optional(),
316
+ });
317
+
318
+ export const QtiSectionSchema = strictObject({
319
+ ident: NonEmptyStringSchema,
320
+ title: z.string().optional(),
321
+ xmlLang: z.string().optional(),
322
+ item: z.array(QtiItemSchema).min(1),
323
+ });
324
+
325
+ export const QtiAssessmentSchema = strictObject({
326
+ ident: NonEmptyStringSchema,
327
+ title: NonEmptyStringSchema,
328
+ xmlLang: z.string().optional(),
329
+ qtimetadata: QtiQtimetadataSchema.optional(),
330
+ rubric: QtiRubricSchema.optional(),
331
+ presentation_material: QtiPresentationMaterialSchema.optional(),
332
+ section: QtiSectionSchema,
333
+ });
334
+
335
+ export const QtiObjectbankSchema = strictObject({
336
+ ident: NonEmptyStringSchema,
337
+ qtimetadata: QtiQtimetadataSchema.optional(),
338
+ item: z.array(QtiItemSchema).min(1),
339
+ });
340
+
341
+ export const QtiQuestestinteropRawSchema = z.union([
342
+ strictObject({
343
+ assessment: QtiAssessmentSchema,
344
+ }),
345
+ strictObject({
346
+ objectbank: QtiObjectbankSchema,
347
+ }),
348
+ ]);
349
+
350
+ const qtiAssessmentFieldRules: Record<string, z.ZodTypeAny> = {
351
+ cc_profile: z.literal("cc.exam.v0p1"),
352
+ qmd_assessmenttype: z.literal("Examination"),
353
+ qmd_scoretype: z.literal("Percentage"),
354
+ qmd_feedbackpermitted: z.enum(["Yes", "No"]),
355
+ qmd_hintspermitted: z.enum(["Yes", "No"]),
356
+ qmd_solutionspermitted: z.enum(["Yes", "No"]),
357
+ qmd_timelimit: z.string().regex(/^[1-9]\d{0,5}$/u),
358
+ cc_allow_late_submission: z.enum(["Yes", "No"]),
359
+ cc_maxattempts: z.enum(["Examination", "1", "2", "3", "4", "5", "unlimited"]),
360
+ };
361
+
362
+ const qtiItemFieldRules: Record<string, z.ZodTypeAny> = {
363
+ cc_profile: z.enum([
364
+ "cc.multiple_choice.v0p1",
365
+ "cc.multiple_response.v0p1",
366
+ "cc.true_false.v0p1",
367
+ "cc.fib.v0p1",
368
+ "cc.pattern_match.v0p1",
369
+ "cc.essay.v0p1",
370
+ ]),
371
+ cc_question_category: z.string(),
372
+ cc_weighting: z.string().regex(/^[1-9]\d?$/u),
373
+ qmd_scoringpermitted: z.literal("Yes"),
374
+ qmd_computerscored: z.enum(["Yes", "No"]),
375
+ };
376
+
377
+ type QtiMetadataFieldEntry = {
378
+ fieldlabel: string;
379
+ fieldentry: string;
380
+ };
381
+
382
+ type QtiMetadataLike = {
383
+ qtimetadatafield?: QtiMetadataFieldEntry[];
384
+ };
385
+
386
+ type QtiItemMetadataLike = {
387
+ itemmetadata?: {
388
+ qtimetadata?: QtiMetadataLike[];
389
+ };
390
+ };
391
+
392
+ type QtiRenderLike = {
393
+ kind?: "render_choice" | "render_fib";
394
+ children?: Array<{ kind?: string }>;
395
+ };
396
+
397
+ type QtiPresentationNodeLike = {
398
+ kind?: string;
399
+ children?: QtiPresentationNodeLike[];
400
+ ident?: string;
401
+ rcardinality?: "Single" | "Multiple" | "Ordered";
402
+ render?: QtiRenderLike;
403
+ };
404
+
405
+ type QtiPresentationLike = { flow: QtiPresentationNodeLike } | { children?: QtiPresentationNodeLike[] };
406
+
407
+ type QtiConditionNodeLike = {
408
+ kind?: string;
409
+ tests?: QtiConditionNodeLike[];
410
+ respident?: string;
411
+ };
412
+
413
+ type QtiDisplayFeedbackLike = {
414
+ feedbacktype: "Response" | "Solution" | "Hint";
415
+ linkrefid: string;
416
+ };
417
+
418
+ type QtiItemFeedbackLike = {
419
+ ident: string;
420
+ children?: Array<{ kind?: string }>;
421
+ };
422
+
423
+ type QtiResprocessingLike = {
424
+ respcondition?: Array<{
425
+ conditionvar?: {
426
+ tests?: QtiConditionNodeLike[];
427
+ };
428
+ displayfeedback?: QtiDisplayFeedbackLike[];
429
+ }>;
430
+ };
431
+
432
+ type QtiItemLike = QtiItemMetadataLike & {
433
+ ident: string;
434
+ presentation?: QtiPresentationLike;
435
+ resprocessing?: QtiResprocessingLike[];
436
+ itemfeedback?: QtiItemFeedbackLike[];
437
+ };
438
+
439
+ function flattenQtiMetadata(
440
+ qtimetadata: QtiMetadataLike | undefined,
441
+ ): Array<{ fieldlabel: string; fieldentry: string }> {
442
+ return asArray(qtimetadata?.qtimetadatafield).map((field) => ({
443
+ fieldlabel: field.fieldlabel,
444
+ fieldentry: field.fieldentry,
445
+ }));
446
+ }
447
+
448
+ function flattenQtiItemMetadata(item: QtiItemMetadataLike): Array<{ fieldlabel: string; fieldentry: string }> {
449
+ return asArray(item.itemmetadata?.qtimetadata).flatMap((metadata) => flattenQtiMetadata(metadata));
450
+ }
451
+
452
+ function getQtiMetadataValues(fields: Array<{ fieldlabel: string; fieldentry: string }>, label: string): string[] {
453
+ return fields.filter((field) => field.fieldlabel === label).map((field) => field.fieldentry);
454
+ }
455
+
456
+ function validateControlledMetadata(
457
+ fields: Array<{ fieldlabel: string; fieldentry: string }>,
458
+ rules: Record<string, z.ZodTypeAny>,
459
+ requiredExactlyOnceLabels: string[],
460
+ context: z.RefinementCtx,
461
+ path: Array<string | number>,
462
+ ) {
463
+ const allowedLabels = new Set(Object.keys(rules));
464
+
465
+ fields.forEach((field, index) => {
466
+ if (!allowedLabels.has(field.fieldlabel)) {
467
+ addIssue(context, [...path, index, "fieldlabel"], `Unexpected metadata fieldlabel: ${field.fieldlabel}`);
468
+ return;
469
+ }
470
+
471
+ const rule = rules[field.fieldlabel];
472
+ if (!rule) {
473
+ addIssue(
474
+ context,
475
+ [...path, index, "fieldlabel"],
476
+ `Missing validation rule for metadata fieldlabel: ${field.fieldlabel}`,
477
+ );
478
+ return;
479
+ }
480
+ const result = rule.safeParse(field.fieldentry);
481
+ if (!result.success) {
482
+ addIssue(
483
+ context,
484
+ [...path, index, "fieldentry"],
485
+ `Invalid value ${JSON.stringify(field.fieldentry)} for metadata fieldlabel ${field.fieldlabel}`,
486
+ );
487
+ }
488
+ });
489
+
490
+ for (const label of allowedLabels) {
491
+ const values = getQtiMetadataValues(fields, label);
492
+ if (requiredExactlyOnceLabels.includes(label)) {
493
+ if (values.length !== 1) {
494
+ addIssue(
495
+ context,
496
+ path,
497
+ `Metadata fieldlabel ${label} must occur exactly once when its metadata container is present.`,
498
+ );
499
+ }
500
+ continue;
501
+ }
502
+
503
+ if (values.length > 1) {
504
+ addIssue(context, path, `Metadata fieldlabel ${label} must not occur more than once.`);
505
+ }
506
+ }
507
+ }
508
+
509
+ function visitQtiPresentationNode(node: QtiPresentationNodeLike, visit: (candidate: QtiPresentationNodeLike) => void) {
510
+ visit(node);
511
+
512
+ if (node.kind === "flow" || node.kind === "flow_mat" || node.kind === "flow_label") {
513
+ for (const child of asArray(node.children)) {
514
+ visitQtiPresentationNode(child, visit);
515
+ }
516
+ }
517
+ }
518
+
519
+ function collectQtiResponsesFromPresentation(presentation: QtiPresentationLike | undefined): {
520
+ responseLid: QtiPresentationNodeLike[];
521
+ responseStr: QtiPresentationNodeLike[];
522
+ } {
523
+ const responseLid: QtiPresentationNodeLike[] = [];
524
+ const responseStr: QtiPresentationNodeLike[] = [];
525
+
526
+ if (!presentation) {
527
+ return { responseLid, responseStr };
528
+ }
529
+
530
+ const nodes = "flow" in presentation ? [presentation.flow] : asArray(presentation.children);
531
+
532
+ for (const node of nodes) {
533
+ visitQtiPresentationNode(node, (candidate) => {
534
+ if (candidate?.kind === "response_lid") {
535
+ responseLid.push(candidate);
536
+ }
537
+ if (candidate?.kind === "response_str") {
538
+ responseStr.push(candidate);
539
+ }
540
+ });
541
+ }
542
+
543
+ return { responseLid, responseStr };
544
+ }
545
+
546
+ function visitQtiConditionNode(node: QtiConditionNodeLike, visit: (candidate: QtiConditionNodeLike) => void) {
547
+ visit(node);
548
+ if (node.kind === "and" || node.kind === "or" || node.kind === "not") {
549
+ for (const child of asArray(node.tests)) {
550
+ visitQtiConditionNode(child, visit);
551
+ }
552
+ }
553
+ }
554
+
555
+ function collectQtiConditionNodes(item: QtiItemLike, kind: string): QtiConditionNodeLike[] {
556
+ const matches: QtiConditionNodeLike[] = [];
557
+
558
+ for (const resprocessing of asArray(item.resprocessing)) {
559
+ for (const respcondition of asArray(resprocessing.respcondition)) {
560
+ for (const node of asArray(respcondition.conditionvar?.tests)) {
561
+ visitQtiConditionNode(node, (candidate) => {
562
+ if (candidate.kind === kind) {
563
+ matches.push(candidate);
564
+ }
565
+ });
566
+ }
567
+ }
568
+ }
569
+
570
+ return matches;
571
+ }
572
+
573
+ function collectQtiDisplayFeedback(item: QtiItemLike): QtiDisplayFeedbackLike[] {
574
+ return asArray(item.resprocessing).flatMap((resprocessing) =>
575
+ asArray(resprocessing.respcondition).flatMap((respcondition) => asArray(respcondition.displayfeedback)),
576
+ );
577
+ }
578
+
579
+ function countQtiResponseLabels(response: QtiPresentationNodeLike | undefined): number {
580
+ if (response?.render?.kind === "render_choice") {
581
+ return asArray(response.render.children).filter((child) => child.kind === "response_label").length;
582
+ }
583
+
584
+ return 0;
585
+ }
586
+
587
+ function renderKindIs(response: QtiPresentationNodeLike | undefined, kind: "render_choice" | "render_fib"): boolean {
588
+ return response?.render?.kind === kind;
589
+ }
590
+
591
+ function validateQtiItemByProfile(item: QtiItemLike, context: z.RefinementCtx, path: Array<string | number>) {
592
+ const fields = flattenQtiItemMetadata(item);
593
+ const profile = getQtiMetadataValues(fields, "cc_profile")[0];
594
+
595
+ if (!profile) {
596
+ return;
597
+ }
598
+
599
+ const responses = collectQtiResponsesFromPresentation(item.presentation);
600
+ const firstResponseLid = responses.responseLid[0];
601
+ const firstResponseStr = responses.responseStr[0];
602
+ const varequals = collectQtiConditionNodes(item, "varequal");
603
+ const varsubstrings = collectQtiConditionNodes(item, "varsubstring");
604
+
605
+ if (profile === "cc.true_false.v0p1") {
606
+ if (!firstResponseLid || firstResponseLid.rcardinality !== "Single") {
607
+ addIssue(
608
+ context,
609
+ [...path, "presentation"],
610
+ "True/False items must use response_lid with rcardinality='Single'.",
611
+ );
612
+ }
613
+ if (responses.responseStr.length > 0) {
614
+ addIssue(context, [...path, "presentation"], "True/False items must not use response_str.");
615
+ }
616
+ if (firstResponseLid && renderKindIs(firstResponseLid, "render_fib")) {
617
+ addIssue(context, [...path, "presentation"], "True/False items must not use render_fib.");
618
+ }
619
+ if (firstResponseLid && countQtiResponseLabels(firstResponseLid) !== 2) {
620
+ addIssue(context, [...path, "presentation"], "True/False items must expose exactly two response_label entries.");
621
+ }
622
+ if (firstResponseLid && varequals.some((candidate) => candidate.respident !== firstResponseLid.ident)) {
623
+ addIssue(
624
+ context,
625
+ [...path, "resprocessing"],
626
+ "True/False varequal/@respident values must match response_lid/@ident.",
627
+ );
628
+ }
629
+ if (varsubstrings.length > 0) {
630
+ addIssue(context, [...path, "resprocessing"], "True/False items must not use varsubstring.");
631
+ }
632
+ }
633
+
634
+ if (profile === "cc.multiple_choice.v0p1") {
635
+ if (!firstResponseLid || firstResponseLid.rcardinality !== "Single") {
636
+ addIssue(
637
+ context,
638
+ [...path, "presentation"],
639
+ "Single-response multiple choice items must use response_lid with rcardinality='Single'.",
640
+ );
641
+ }
642
+ if (responses.responseStr.length > 0) {
643
+ addIssue(context, [...path, "presentation"], "Single-response multiple choice items must not use response_str.");
644
+ }
645
+ if (firstResponseLid && renderKindIs(firstResponseLid, "render_fib")) {
646
+ addIssue(context, [...path, "presentation"], "Single-response multiple choice items must not use render_fib.");
647
+ }
648
+ if (firstResponseLid && countQtiResponseLabels(firstResponseLid) <= 1) {
649
+ addIssue(
650
+ context,
651
+ [...path, "presentation"],
652
+ "Single-response multiple choice items must expose more than one response_label.",
653
+ );
654
+ }
655
+ if (firstResponseLid && varequals.some((candidate) => candidate.respident !== firstResponseLid.ident)) {
656
+ addIssue(
657
+ context,
658
+ [...path, "resprocessing"],
659
+ "Multiple choice varequal/@respident values must match response_lid/@ident.",
660
+ );
661
+ }
662
+ if (varsubstrings.length > 0) {
663
+ addIssue(context, [...path, "resprocessing"], "Multiple choice items must not use varsubstring.");
664
+ }
665
+ }
666
+
667
+ if (profile === "cc.multiple_response.v0p1") {
668
+ if (!firstResponseLid || firstResponseLid.rcardinality !== "Multiple") {
669
+ addIssue(
670
+ context,
671
+ [...path, "presentation"],
672
+ "Multiple-response items must use response_lid with rcardinality='Multiple'.",
673
+ );
674
+ }
675
+ if (responses.responseStr.length > 0) {
676
+ addIssue(context, [...path, "presentation"], "Multiple-response items must not use response_str.");
677
+ }
678
+ if (firstResponseLid && renderKindIs(firstResponseLid, "render_fib")) {
679
+ addIssue(context, [...path, "presentation"], "Multiple-response items must not use render_fib.");
680
+ }
681
+ if (firstResponseLid && countQtiResponseLabels(firstResponseLid) <= 1) {
682
+ addIssue(context, [...path, "presentation"], "Multiple-response items must expose more than one response_label.");
683
+ }
684
+ if (firstResponseLid && varequals.some((candidate) => candidate.respident !== firstResponseLid.ident)) {
685
+ addIssue(
686
+ context,
687
+ [...path, "resprocessing"],
688
+ "Multiple-response varequal/@respident values must match response_lid/@ident.",
689
+ );
690
+ }
691
+ if (varsubstrings.length > 0) {
692
+ addIssue(context, [...path, "resprocessing"], "Multiple-response items must not use varsubstring.");
693
+ }
694
+ }
695
+
696
+ if (profile === "cc.fib.v0p1") {
697
+ if (responses.responseLid.length > 0) {
698
+ addIssue(context, [...path, "presentation"], "Fill-in-the-blank items must not use response_lid.");
699
+ }
700
+ if (firstResponseStr && renderKindIs(firstResponseStr, "render_choice")) {
701
+ addIssue(context, [...path, "presentation"], "Fill-in-the-blank items must not use render_choice.");
702
+ }
703
+ if (firstResponseStr && varequals.some((candidate) => candidate.respident !== firstResponseStr.ident)) {
704
+ addIssue(
705
+ context,
706
+ [...path, "resprocessing"],
707
+ "Fill-in-the-blank varequal/@respident values must match response_str/@ident.",
708
+ );
709
+ }
710
+ if (varsubstrings.length > 0) {
711
+ addIssue(context, [...path, "resprocessing"], "Fill-in-the-blank items must not use varsubstring.");
712
+ }
713
+ }
714
+
715
+ if (profile === "cc.pattern_match.v0p1") {
716
+ if (responses.responseLid.length > 0) {
717
+ addIssue(context, [...path, "presentation"], "Pattern-match items must not use response_lid.");
718
+ }
719
+ if (firstResponseStr && renderKindIs(firstResponseStr, "render_choice")) {
720
+ addIssue(context, [...path, "presentation"], "Pattern-match items must not use render_choice.");
721
+ }
722
+ if (firstResponseStr && varequals.some((candidate) => candidate.respident !== firstResponseStr.ident)) {
723
+ addIssue(
724
+ context,
725
+ [...path, "resprocessing"],
726
+ "Pattern-match varequal/@respident values must match response_str/@ident.",
727
+ );
728
+ }
729
+ }
730
+
731
+ if (profile === "cc.essay.v0p1") {
732
+ if (responses.responseLid.length > 0) {
733
+ addIssue(context, [...path, "presentation"], "Essay items must not use response_lid.");
734
+ }
735
+ if (firstResponseStr && renderKindIs(firstResponseStr, "render_choice")) {
736
+ addIssue(context, [...path, "presentation"], "Essay items must not use render_choice.");
737
+ }
738
+ if (varequals.length > 0) {
739
+ addIssue(context, [...path, "resprocessing"], "Essay items must not use varequal.");
740
+ }
741
+ if (varsubstrings.length > 0) {
742
+ addIssue(context, [...path, "resprocessing"], "Essay items must not use varsubstring.");
743
+ }
744
+ const solutionCount = asArray(item.itemfeedback)
745
+ .flatMap((feedback) => asArray(feedback.children))
746
+ .filter((child) => child.kind === "solution").length;
747
+ if (solutionCount > 1) {
748
+ addIssue(
749
+ context,
750
+ [...path, "itemfeedback"],
751
+ "Essay items must not contain more than one solution feedback block.",
752
+ );
753
+ }
754
+ const computerScoredValue = getQtiMetadataValues(fields, "qmd_computerscored")[0];
755
+ if (computerScoredValue !== "No") {
756
+ addIssue(context, [...path, "itemmetadata"], "Essay items must declare qmd_computerscored='No'.");
757
+ }
758
+ }
759
+ }
760
+
761
+ function validateQtiFeedbackLinkage(item: QtiItemLike, context: z.RefinementCtx, path: Array<string | number>) {
762
+ const displayFeedback = collectQtiDisplayFeedback(item);
763
+ const feedbackIds = new Set(asArray(item.itemfeedback).map((feedback) => feedback.ident));
764
+
765
+ const hasHintTrigger = displayFeedback.some((entry) => entry.feedbacktype === "Hint");
766
+ const hasSolutionTrigger = displayFeedback.some((entry) => entry.feedbacktype === "Solution");
767
+
768
+ if (hasHintTrigger) {
769
+ const hintCount = asArray(item.itemfeedback).filter((feedback) => feedback.ident === "hint").length;
770
+ if (hintCount !== 1) {
771
+ addIssue(
772
+ context,
773
+ [...path, "itemfeedback"],
774
+ "Hint feedback triggers require exactly one itemfeedback with ident='hint'.",
775
+ );
776
+ }
777
+ }
778
+
779
+ if (hasSolutionTrigger) {
780
+ const solutionCount = asArray(item.itemfeedback).filter((feedback) => feedback.ident === "solution").length;
781
+ if (solutionCount !== 1) {
782
+ addIssue(
783
+ context,
784
+ [...path, "itemfeedback"],
785
+ "Solution feedback triggers require exactly one itemfeedback with ident='solution'.",
786
+ );
787
+ }
788
+ }
789
+
790
+ displayFeedback.forEach((entry, entryIndex) => {
791
+ if (entry.feedbacktype === "Response" && ["hint", "solution"].includes(entry.linkrefid)) {
792
+ addIssue(
793
+ context,
794
+ [...path, "resprocessing", entryIndex, "displayfeedback", "linkrefid"],
795
+ "Response feedback must not point at hint or solution identifiers.",
796
+ );
797
+ }
798
+
799
+ if (entry.feedbacktype === "Response" && !feedbackIds.has(entry.linkrefid)) {
800
+ addIssue(
801
+ context,
802
+ [...path, "resprocessing", entryIndex, "displayfeedback", "linkrefid"],
803
+ `displayfeedback linkrefid ${entry.linkrefid} does not match any itemfeedback/@ident.`,
804
+ );
805
+ }
806
+ });
807
+
808
+ asArray(item.itemfeedback).forEach((feedback, feedbackIndex) => {
809
+ const kinds = asArray(feedback.children).map((child) => child.kind);
810
+
811
+ if (feedback.ident === "hint") {
812
+ if (kinds.includes("solution")) {
813
+ addIssue(
814
+ context,
815
+ [...path, "itemfeedback", feedbackIndex],
816
+ "itemfeedback ident='hint' must not contain solution content.",
817
+ );
818
+ }
819
+ if (kinds.includes("flow_mat")) {
820
+ addIssue(
821
+ context,
822
+ [...path, "itemfeedback", feedbackIndex],
823
+ "itemfeedback ident='hint' must not contain direct response feedback flow_mat content.",
824
+ );
825
+ }
826
+ }
827
+
828
+ if (feedback.ident === "solution") {
829
+ if (kinds.includes("hint")) {
830
+ addIssue(
831
+ context,
832
+ [...path, "itemfeedback", feedbackIndex],
833
+ "itemfeedback ident='solution' must not contain hint content.",
834
+ );
835
+ }
836
+ if (kinds.includes("flow_mat")) {
837
+ addIssue(
838
+ context,
839
+ [...path, "itemfeedback", feedbackIndex],
840
+ "itemfeedback ident='solution' must not contain direct response feedback flow_mat content.",
841
+ );
842
+ }
843
+ }
844
+
845
+ if (kinds.includes("flow_mat")) {
846
+ const hasResponseTrigger = displayFeedback.some(
847
+ (entry) => entry.feedbacktype === "Response" && entry.linkrefid === feedback.ident,
848
+ );
849
+ if (!hasResponseTrigger) {
850
+ addIssue(
851
+ context,
852
+ [...path, "itemfeedback", feedbackIndex],
853
+ `Response feedback block ${feedback.ident} does not have a matching displayfeedback trigger.`,
854
+ );
855
+ }
856
+ }
857
+ });
858
+ }
859
+
860
+ export const QtiQuestestinteropProfileSchema = QtiQuestestinteropRawSchema.superRefine((root, context) => {
861
+ if ("assessment" in root) {
862
+ const assessmentFields = flattenQtiMetadata(root.assessment.qtimetadata);
863
+ if (assessmentFields.length > 0) {
864
+ validateControlledMetadata(assessmentFields, qtiAssessmentFieldRules, ["cc_profile"], context, [
865
+ "assessment",
866
+ "qtimetadata",
867
+ "qtimetadatafield",
868
+ ]);
869
+ }
870
+
871
+ const itemIds = asArray(root.assessment.section.item).map((item) => item.ident);
872
+ for (const duplicate of collectDuplicates(itemIds)) {
873
+ addIssue(context, ["assessment", "section", "item"], `Duplicate item ident in assessment section: ${duplicate}`);
874
+ }
875
+
876
+ asArray(root.assessment.section.item).forEach((item, itemIndex) => {
877
+ const fields = flattenQtiItemMetadata(item);
878
+ if (fields.length > 0) {
879
+ validateControlledMetadata(fields, qtiItemFieldRules, ["cc_profile"], context, [
880
+ "assessment",
881
+ "section",
882
+ "item",
883
+ itemIndex,
884
+ "itemmetadata",
885
+ "qtimetadata",
886
+ ]);
887
+ }
888
+ validateQtiItemByProfile(item as QtiItemLike, context, ["assessment", "section", "item", itemIndex]);
889
+ validateQtiFeedbackLinkage(item as QtiItemLike, context, ["assessment", "section", "item", itemIndex]);
890
+ });
891
+ }
892
+
893
+ if ("objectbank" in root) {
894
+ const itemIds = asArray(root.objectbank.item).map((item) => item.ident);
895
+ for (const duplicate of collectDuplicates(itemIds)) {
896
+ addIssue(context, ["objectbank", "item"], `Duplicate item ident in object bank: ${duplicate}`);
897
+ }
898
+
899
+ asArray(root.objectbank.item).forEach((item, itemIndex) => {
900
+ const fields = flattenQtiItemMetadata(item);
901
+ if (fields.length > 0) {
902
+ validateControlledMetadata(fields, qtiItemFieldRules, ["cc_profile"], context, [
903
+ "objectbank",
904
+ "item",
905
+ itemIndex,
906
+ "itemmetadata",
907
+ "qtimetadata",
908
+ ]);
909
+ }
910
+ validateQtiItemByProfile(item as QtiItemLike, context, ["objectbank", "item", itemIndex]);
911
+ validateQtiFeedbackLinkage(item as QtiItemLike, context, ["objectbank", "item", itemIndex]);
912
+ });
913
+ }
914
+ });
915
+
916
+ export const QtiQuestestinteropRawDocumentSchema = strictObject({
917
+ questestinterop: QtiQuestestinteropRawSchema,
918
+ });
919
+
920
+ export const QtiQuestestinteropProfileDocumentSchema = strictObject({
921
+ questestinterop: QtiQuestestinteropProfileSchema,
922
+ });
923
+ // Inferred types from exported Zod validators.
924
+ export type QtiMatText = z.infer<typeof QtiMatTextSchema>;
925
+ export type QtiMatRef = z.infer<typeof QtiMatRefSchema>;
926
+ export type QtiMatBreak = z.infer<typeof QtiMatBreakSchema>;
927
+ export type QtiMaterialRef = z.infer<typeof QtiMaterialRefSchema>;
928
+ export type QtiMaterialChild = z.infer<typeof QtiMaterialChildSchema>;
929
+ export type QtiAltMaterial = z.infer<typeof QtiAltMaterialSchema>;
930
+ export type QtiMaterial = z.infer<typeof QtiMaterialSchema>;
931
+ export type QtiResponseLabel = z.infer<typeof QtiResponseLabelSchema>;
932
+ export type QtiRenderChoice = z.infer<typeof QtiRenderChoiceSchema>;
933
+ export type QtiRenderFib = z.infer<typeof QtiRenderFibSchema>;
934
+ export type QtiResponseLid = z.infer<typeof QtiResponseLidSchema>;
935
+ export type QtiResponseStr = z.infer<typeof QtiResponseStrSchema>;
936
+ export type QtiPresentation = z.infer<typeof QtiPresentationSchema>;
937
+ export type QtiPresentationMaterial = z.infer<typeof QtiPresentationMaterialSchema>;
938
+ export type QtiHintMaterial = z.infer<typeof QtiHintMaterialSchema>;
939
+ export type QtiHint = z.infer<typeof QtiHintSchema>;
940
+ export type QtiSolutionMaterial = z.infer<typeof QtiSolutionMaterialSchema>;
941
+ export type QtiSolution = z.infer<typeof QtiSolutionSchema>;
942
+ export type QtiItemFeedback = z.infer<typeof QtiItemFeedbackSchema>;
943
+ export type QtiQtimetadatafield = z.infer<typeof QtiQtimetadatafieldSchema>;
944
+ export type QtiQtimetadata = z.infer<typeof QtiQtimetadataSchema>;
945
+ export type QtiItemMetadata = z.infer<typeof QtiItemMetadataSchema>;
946
+ export type QtiRubric = z.infer<typeof QtiRubricSchema>;
947
+ export type QtiDecvar = z.infer<typeof QtiDecvarSchema>;
948
+ export type QtiDisplayFeedback = z.infer<typeof QtiDisplayFeedbackSchema>;
949
+ export type QtiSetVar = z.infer<typeof QtiSetVarSchema>;
950
+ export type QtiVarEqual = z.infer<typeof QtiVarEqualSchema>;
951
+ export type QtiVarSubstring = z.infer<typeof QtiVarSubstringSchema>;
952
+ export type QtiOtherCondition = z.infer<typeof QtiOtherConditionSchema>;
953
+ export type QtiConditionVar = z.infer<typeof QtiConditionVarSchema>;
954
+ export type QtiRespCondition = z.infer<typeof QtiRespConditionSchema>;
955
+ export type QtiOutcomes = z.infer<typeof QtiOutcomesSchema>;
956
+ export type QtiResprocessing = z.infer<typeof QtiResprocessingSchema>;
957
+ export type QtiItem = z.infer<typeof QtiItemSchema>;
958
+ export type QtiSection = z.infer<typeof QtiSectionSchema>;
959
+ export type QtiAssessment = z.infer<typeof QtiAssessmentSchema>;
960
+ export type QtiObjectbank = z.infer<typeof QtiObjectbankSchema>;
961
+ export type QtiQuestestinteropRaw = z.infer<typeof QtiQuestestinteropRawSchema>;
962
+ export type QtiQuestestinteropProfile = z.infer<typeof QtiQuestestinteropProfileSchema>;
963
+ export type QtiQuestestinteropRawDocument = z.infer<typeof QtiQuestestinteropRawDocumentSchema>;
964
+ export type QtiQuestestinteropProfileDocument = z.infer<typeof QtiQuestestinteropProfileDocumentSchema>;