@longsightgroup/qti3-core 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +14 -0
  3. package/dist/content-text.d.ts.map +1 -0
  4. package/dist/content-text.js +58 -0
  5. package/dist/content-text.js.map +1 -0
  6. package/dist/index.d.ts +7 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/package-xml.d.ts +12 -0
  11. package/dist/package-xml.d.ts.map +1 -0
  12. package/dist/package-xml.js +17 -0
  13. package/dist/package-xml.js.map +1 -0
  14. package/dist/parser-custom-interactions.d.ts +8 -0
  15. package/dist/parser-custom-interactions.d.ts.map +1 -0
  16. package/dist/parser-custom-interactions.js +178 -0
  17. package/dist/parser-custom-interactions.js.map +1 -0
  18. package/dist/parser-declarations.d.ts +6 -0
  19. package/dist/parser-declarations.d.ts.map +1 -0
  20. package/dist/parser-declarations.js +136 -0
  21. package/dist/parser-declarations.js.map +1 -0
  22. package/dist/parser-item-metadata.d.ts +9 -0
  23. package/dist/parser-item-metadata.d.ts.map +1 -0
  24. package/dist/parser-item-metadata.js +158 -0
  25. package/dist/parser-item-metadata.js.map +1 -0
  26. package/dist/parser-processing.d.ts +5 -0
  27. package/dist/parser-processing.d.ts.map +1 -0
  28. package/dist/parser-processing.js +642 -0
  29. package/dist/parser-processing.js.map +1 -0
  30. package/dist/parser-values.d.ts +8 -0
  31. package/dist/parser-values.d.ts.map +1 -0
  32. package/dist/parser-values.js +49 -0
  33. package/dist/parser-values.js.map +1 -0
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +32 -1086
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-expression-tags.d.ts +56 -0
  38. package/dist/processing-expression-tags.d.ts.map +1 -0
  39. package/dist/processing-expression-tags.js +52 -0
  40. package/dist/processing-expression-tags.js.map +1 -0
  41. package/dist/processing-rules.d.ts +3 -0
  42. package/dist/processing-rules.d.ts.map +1 -0
  43. package/dist/processing-rules.js +10 -0
  44. package/dist/processing-rules.js.map +1 -0
  45. package/dist/serializer-processing-expressions.d.ts +8 -0
  46. package/dist/serializer-processing-expressions.d.ts.map +1 -0
  47. package/dist/serializer-processing-expressions.js +198 -0
  48. package/dist/serializer-processing-expressions.js.map +1 -0
  49. package/dist/serializer-processing-xml.d.ts +15 -0
  50. package/dist/serializer-processing-xml.d.ts.map +1 -0
  51. package/dist/serializer-processing-xml.js +46 -0
  52. package/dist/serializer-processing-xml.js.map +1 -0
  53. package/dist/serializer-processing.d.ts +8 -0
  54. package/dist/serializer-processing.d.ts.map +1 -0
  55. package/dist/serializer-processing.js +106 -0
  56. package/dist/serializer-processing.js.map +1 -0
  57. package/dist/shared-vocabulary-authoring.d.ts +50 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +302 -0
  60. package/dist/shared-vocabulary-authoring.js.map +1 -0
  61. package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
  62. package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
  63. package/dist/shared-vocabulary-interaction-sets.js +23 -0
  64. package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
  65. package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
  66. package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
  67. package/dist/shared-vocabulary-interaction-validation.js +99 -0
  68. package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
  69. package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
  70. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
  71. package/dist/shared-vocabulary-registry-validation.js +147 -0
  72. package/dist/shared-vocabulary-registry-validation.js.map +1 -0
  73. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  74. package/dist/shared-vocabulary-support.js +96 -56
  75. package/dist/shared-vocabulary-support.js.map +1 -1
  76. package/dist/shared-vocabulary.d.ts +6 -0
  77. package/dist/shared-vocabulary.d.ts.map +1 -1
  78. package/dist/shared-vocabulary.js +12 -0
  79. package/dist/shared-vocabulary.js.map +1 -1
  80. package/dist/support.d.ts +4 -1
  81. package/dist/support.d.ts.map +1 -1
  82. package/dist/support.js +159 -10
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +38 -7
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/validation.d.ts.map +1 -1
  87. package/dist/validation.js +29 -250
  88. package/dist/validation.js.map +1 -1
  89. package/dist/xml.d.ts +10 -0
  90. package/dist/xml.d.ts.map +1 -1
  91. package/dist/xml.js +321 -249
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +80 -0
  95. package/src/index.ts +67 -0
  96. package/src/interaction-test-fixtures.ts +44 -0
  97. package/src/package-xml.ts +30 -0
  98. package/src/parser-custom-interactions.ts +309 -0
  99. package/src/parser-declarations.ts +174 -0
  100. package/src/parser-item-metadata.ts +194 -0
  101. package/src/parser-processing.ts +701 -0
  102. package/src/parser-values.ts +61 -0
  103. package/src/parser.ts +59 -1230
  104. package/src/processing-expression-tags.ts +65 -0
  105. package/src/processing-rules.ts +11 -0
  106. package/src/serializer-processing-expressions.ts +456 -0
  107. package/src/serializer-processing-xml.ts +67 -0
  108. package/src/serializer-processing.fixtures.ts +469 -0
  109. package/src/serializer-processing.ts +196 -0
  110. package/src/shared-vocabulary-authoring.ts +409 -0
  111. package/src/shared-vocabulary-interaction-sets.ts +28 -0
  112. package/src/shared-vocabulary-interaction-validation.ts +143 -0
  113. package/src/shared-vocabulary-registry-validation.ts +207 -0
  114. package/src/shared-vocabulary-support.ts +127 -101
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +174 -14
  117. package/src/types.ts +48 -7
  118. package/src/validation.ts +33 -341
  119. package/src/xml.ts +378 -255
@@ -0,0 +1,409 @@
1
+ import { SHARED_VOCABULARY_CHOICE_WRITING_ORIENTATIONS } from "./shared-vocabulary-generated-families.js";
2
+ import {
3
+ SHARED_VOCABULARY_CHOICE_AND_ORDER_INTERACTIONS,
4
+ SHARED_VOCABULARY_CHOICES_LAYOUT_INTERACTIONS,
5
+ SHARED_VOCABULARY_MEDIA_INTERACTIONS,
6
+ SHARED_VOCABULARY_SELECTION_PRESENTATION_INTERACTIONS,
7
+ } from "./shared-vocabulary-interaction-sets.js";
8
+ import {
9
+ SHARED_VOCABULARY_CHOICES_POSITIONS,
10
+ SHARED_VOCABULARY_CHOICES_STACKING,
11
+ SHARED_VOCABULARY_LABEL_STYLES,
12
+ SHARED_VOCABULARY_LABEL_SUFFIXES,
13
+ SHARED_VOCABULARY_MEDIA_PLAYER_CONTROLS,
14
+ SHARED_VOCABULARY_ORIENTATIONS,
15
+ SHARED_VOCABULARY_SELECTION_TONES,
16
+ } from "./shared-vocabulary.js";
17
+ import type { QtiInteractionType } from "./types.js";
18
+
19
+ export type QtiSharedVocabularyFieldValue = string | number;
20
+
21
+ export type QtiSharedVocabularyStateValue =
22
+ | QtiSharedVocabularyFieldValue
23
+ | boolean
24
+ | readonly string[]
25
+ | undefined;
26
+
27
+ export type QtiSharedVocabularyState = Record<string, QtiSharedVocabularyStateValue>;
28
+
29
+ export type QtiSharedVocabularyAttributeValueType = "string" | "number" | "token-list";
30
+
31
+ export type QtiSharedVocabularyField =
32
+ | {
33
+ kind: "class-value";
34
+ id: string;
35
+ classPrefix: string;
36
+ values: readonly QtiSharedVocabularyFieldValue[];
37
+ interactions: readonly QtiInteractionType[];
38
+ precedence: "value-order" | "class-order";
39
+ }
40
+ | {
41
+ kind: "class-fixed";
42
+ id: string;
43
+ className: string;
44
+ interactions: readonly QtiInteractionType[];
45
+ }
46
+ | {
47
+ kind: "attribute";
48
+ id: string;
49
+ attributeName: string;
50
+ interactions: readonly QtiInteractionType[];
51
+ values?: readonly string[] | undefined;
52
+ valueType?: QtiSharedVocabularyAttributeValueType | undefined;
53
+ numberMinimum?: number | undefined;
54
+ };
55
+
56
+ const choiceAndOrder = SHARED_VOCABULARY_CHOICE_AND_ORDER_INTERACTIONS;
57
+ const choicesLayoutInteractions = SHARED_VOCABULARY_CHOICES_LAYOUT_INTERACTIONS;
58
+ const selectionPresentationInteractions = SHARED_VOCABULARY_SELECTION_PRESENTATION_INTERACTIONS;
59
+ const mediaInteraction = SHARED_VOCABULARY_MEDIA_INTERACTIONS;
60
+
61
+ export const sharedVocabularyInteractionFields: readonly QtiSharedVocabularyField[] = [
62
+ {
63
+ kind: "class-value",
64
+ id: "labels-style",
65
+ classPrefix: "qti-labels-",
66
+ values: SHARED_VOCABULARY_LABEL_STYLES,
67
+ interactions: choiceAndOrder,
68
+ precedence: "value-order",
69
+ },
70
+ {
71
+ kind: "class-value",
72
+ id: "labels-suffix",
73
+ classPrefix: "qti-labels-suffix-",
74
+ values: SHARED_VOCABULARY_LABEL_SUFFIXES,
75
+ interactions: choiceAndOrder,
76
+ precedence: "value-order",
77
+ },
78
+ {
79
+ kind: "class-value",
80
+ id: "orientation",
81
+ classPrefix: "qti-orientation-",
82
+ values: SHARED_VOCABULARY_ORIENTATIONS,
83
+ interactions: choiceAndOrder,
84
+ precedence: "value-order",
85
+ },
86
+ {
87
+ kind: "class-value",
88
+ id: "choices-stacking",
89
+ classPrefix: "qti-choices-stacking-",
90
+ values: SHARED_VOCABULARY_CHOICES_STACKING,
91
+ interactions: ["choice"],
92
+ precedence: "class-order",
93
+ },
94
+ {
95
+ kind: "class-value",
96
+ id: "choices-position",
97
+ classPrefix: "qti-choices-",
98
+ values: SHARED_VOCABULARY_CHOICES_POSITIONS,
99
+ interactions: choicesLayoutInteractions,
100
+ precedence: "class-order",
101
+ },
102
+ {
103
+ kind: "class-value",
104
+ id: "selections-tone",
105
+ classPrefix: "qti-selections-",
106
+ values: SHARED_VOCABULARY_SELECTION_TONES,
107
+ interactions: selectionPresentationInteractions,
108
+ precedence: "value-order",
109
+ },
110
+ {
111
+ kind: "class-value",
112
+ id: "writing-orientation",
113
+ classPrefix: "qti-writing-orientation-",
114
+ values: SHARED_VOCABULARY_CHOICE_WRITING_ORIENTATIONS,
115
+ interactions: ["choice", "inlineChoice"],
116
+ precedence: "value-order",
117
+ },
118
+ {
119
+ kind: "class-fixed",
120
+ id: "input-control-hidden",
121
+ className: "qti-input-control-hidden",
122
+ interactions: ["choice", "hottext"],
123
+ },
124
+ {
125
+ kind: "class-fixed",
126
+ id: "unselected-hidden",
127
+ className: "qti-unselected-hidden",
128
+ interactions: selectionPresentationInteractions,
129
+ },
130
+ {
131
+ kind: "class-fixed",
132
+ id: "match-tabular",
133
+ className: "qti-match-tabular",
134
+ interactions: ["match"],
135
+ },
136
+ {
137
+ kind: "class-fixed",
138
+ id: "header-hidden",
139
+ className: "qti-header-hidden",
140
+ interactions: ["match"],
141
+ },
142
+ {
143
+ kind: "class-fixed",
144
+ id: "gap-placement",
145
+ className: "qti-gap-placement",
146
+ interactions: ["gapMatch"],
147
+ },
148
+ {
149
+ kind: "attribute",
150
+ id: "media-player-controls",
151
+ attributeName: "data-qti-media-player-controls",
152
+ values: SHARED_VOCABULARY_MEDIA_PLAYER_CONTROLS,
153
+ valueType: "token-list",
154
+ interactions: mediaInteraction,
155
+ },
156
+ {
157
+ kind: "attribute",
158
+ id: "media-player-pause-delay",
159
+ attributeName: "data-qti-media-player-pause-delay",
160
+ valueType: "number",
161
+ numberMinimum: 0,
162
+ interactions: mediaInteraction,
163
+ },
164
+ {
165
+ kind: "attribute",
166
+ id: "media-player-pause-duration",
167
+ attributeName: "data-qti-media-player-pause-duration",
168
+ valueType: "number",
169
+ numberMinimum: 0,
170
+ interactions: mediaInteraction,
171
+ },
172
+ ];
173
+
174
+ export function sharedVocabularyFieldsForInteraction(
175
+ interaction: QtiInteractionType,
176
+ ): readonly QtiSharedVocabularyField[] {
177
+ return sharedVocabularyInteractionFields.filter((field) =>
178
+ field.interactions.includes(interaction),
179
+ );
180
+ }
181
+
182
+ export function sharedVocabularyFieldById(id: string): QtiSharedVocabularyField | undefined {
183
+ return sharedVocabularyInteractionFields.find((field) => field.id === id);
184
+ }
185
+
186
+ export function sharedVocabularyFixedClassName(fieldId: string): string | undefined {
187
+ const field = sharedVocabularyFieldById(fieldId);
188
+ return field?.kind === "class-fixed" ? field.className : undefined;
189
+ }
190
+
191
+ export function parseSharedVocabularyClasses(
192
+ className: string,
193
+ interaction?: QtiInteractionType,
194
+ ): QtiSharedVocabularyState {
195
+ const classNames = className.split(/\s+/).filter(Boolean);
196
+ const classNameSet = new Set(classNames);
197
+ const state: QtiSharedVocabularyState = {};
198
+
199
+ for (const field of fieldsForInteractionFilter(interaction)) {
200
+ if (field.kind === "attribute") continue;
201
+ if (field.kind === "class-fixed") {
202
+ if (classNameSet.has(field.className)) state[field.id] = true;
203
+ continue;
204
+ }
205
+
206
+ const value = parseClassValueField(field, classNames);
207
+ if (value !== undefined) state[field.id] = value;
208
+ }
209
+
210
+ return state;
211
+ }
212
+
213
+ export function serializeSharedVocabularyClassNames(
214
+ state: QtiSharedVocabularyState,
215
+ interaction?: QtiInteractionType,
216
+ ): string[] {
217
+ const classNames: string[] = [];
218
+
219
+ for (const field of fieldsForInteractionFilter(interaction)) {
220
+ if (field.kind === "attribute") continue;
221
+ const value = state[field.id];
222
+ if (field.kind === "class-fixed") {
223
+ if (value === true) classNames.push(field.className);
224
+ continue;
225
+ }
226
+ if (isOmittedStateValue(value)) continue;
227
+ if (!isAllowedFieldValue(field.values, value)) continue;
228
+ classNames.push(`${field.classPrefix}${String(value)}`);
229
+ }
230
+
231
+ return classNames;
232
+ }
233
+
234
+ export function parseSharedVocabularyAttributes(
235
+ attrs: Record<string, string | undefined>,
236
+ interaction?: QtiInteractionType,
237
+ ): QtiSharedVocabularyState {
238
+ const state: QtiSharedVocabularyState = {};
239
+
240
+ for (const field of fieldsForInteractionFilter(interaction)) {
241
+ if (field.kind !== "attribute") continue;
242
+ const value = attrs[field.attributeName];
243
+ if (value === undefined || value === "") continue;
244
+ const parsed = parseAttributeValue(field, value);
245
+ if (parsed !== undefined) state[field.id] = parsed;
246
+ }
247
+
248
+ return state;
249
+ }
250
+
251
+ export function serializeSharedVocabularyAttributes(
252
+ state: QtiSharedVocabularyState,
253
+ interaction?: QtiInteractionType,
254
+ ): Record<string, string> {
255
+ const attrs: Record<string, string> = {};
256
+
257
+ for (const field of fieldsForInteractionFilter(interaction)) {
258
+ if (field.kind !== "attribute") continue;
259
+ const value = state[field.id];
260
+ if (isOmittedStateValue(value)) continue;
261
+ const serialized = serializeAttributeValue(field, value);
262
+ if (serialized !== undefined) attrs[field.attributeName] = serialized;
263
+ }
264
+
265
+ return attrs;
266
+ }
267
+
268
+ function fieldsForInteractionFilter(
269
+ interaction: QtiInteractionType | undefined,
270
+ ): readonly QtiSharedVocabularyField[] {
271
+ return interaction === undefined
272
+ ? sharedVocabularyInteractionFields
273
+ : sharedVocabularyFieldsForInteraction(interaction);
274
+ }
275
+
276
+ export function sharedVocabularyClassNamesForField(
277
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
278
+ ): string[] {
279
+ return field.values.map((value) => `${field.classPrefix}${String(value)}`);
280
+ }
281
+
282
+ export function matchedSharedVocabularyClassNames(
283
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
284
+ classNames: readonly string[],
285
+ ): string[] {
286
+ const matched: string[] = [];
287
+ for (const className of classNames) {
288
+ if (parseClassValue(field, className) !== undefined) matched.push(className);
289
+ }
290
+ return matched;
291
+ }
292
+
293
+ export function describeSharedVocabularyPrecedence(
294
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
295
+ matchedClassNames: readonly string[],
296
+ ): string {
297
+ const matched = new Set(matchedClassNames);
298
+ if (field.precedence === "class-order") {
299
+ const ordered = matchedClassNames.filter((className) => matched.has(className));
300
+ if (ordered.length < 2) return `${ordered[0] ?? "The first matching class"} takes precedence`;
301
+ const [first, ...rest] = ordered;
302
+ return `${first} takes precedence over ${rest.join(", then ")}`;
303
+ }
304
+
305
+ const ordered = sharedVocabularyClassNamesForField(field).filter((className) =>
306
+ matched.has(className),
307
+ );
308
+ if (ordered.length < 2) return `${ordered[0] ?? "The first matching class"} takes precedence`;
309
+ const [first, ...rest] = ordered;
310
+ return `${first} takes precedence over ${rest.join(", then ")}`;
311
+ }
312
+
313
+ export function formatSharedVocabularyClassValueRange(
314
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
315
+ ): string {
316
+ const classNames = sharedVocabularyClassNamesForField(field);
317
+ if (classNames.length <= 1) return classNames[0] ?? "";
318
+ return `${classNames[0]} through ${classNames[classNames.length - 1]}`;
319
+ }
320
+
321
+ function parseClassValueField(
322
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
323
+ classNames: string[],
324
+ ): QtiSharedVocabularyFieldValue | undefined {
325
+ if (field.precedence === "class-order") {
326
+ for (const className of classNames) {
327
+ const value = parseClassValue(field, className);
328
+ if (value !== undefined) return value;
329
+ }
330
+ return undefined;
331
+ }
332
+
333
+ for (const value of field.values) {
334
+ const className = `${field.classPrefix}${String(value)}`;
335
+ if (classNames.includes(className)) return value;
336
+ }
337
+ return undefined;
338
+ }
339
+
340
+ export function parseClassValue(
341
+ field: Extract<QtiSharedVocabularyField, { kind: "class-value" }>,
342
+ className: string,
343
+ ): QtiSharedVocabularyFieldValue | undefined {
344
+ if (!className.startsWith(field.classPrefix)) return undefined;
345
+ const suffix = className.slice(field.classPrefix.length);
346
+ for (const value of field.values) {
347
+ if (String(value) === suffix) return value;
348
+ }
349
+ return undefined;
350
+ }
351
+
352
+ function parseAttributeValue(
353
+ field: Extract<QtiSharedVocabularyField, { kind: "attribute" }>,
354
+ value: string,
355
+ ): QtiSharedVocabularyStateValue {
356
+ if (field.valueType === "token-list") {
357
+ const allowed = new Set(field.values ?? []);
358
+ const tokens = value.split(/\s+/).filter((token) => allowed.has(token));
359
+ return tokens.length > 0 ? [...new Set(tokens)] : undefined;
360
+ }
361
+
362
+ if (field.valueType === "number") {
363
+ const parsed = Number(value);
364
+ const minimum = field.numberMinimum ?? Number.NEGATIVE_INFINITY;
365
+ return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined;
366
+ }
367
+
368
+ if (field.values !== undefined && !field.values.includes(value)) return undefined;
369
+ return value;
370
+ }
371
+
372
+ function serializeAttributeValue(
373
+ field: Extract<QtiSharedVocabularyField, { kind: "attribute" }>,
374
+ value: QtiSharedVocabularyStateValue,
375
+ ): string | undefined {
376
+ if (field.valueType === "token-list") {
377
+ const values = Array.isArray(value) ? value : String(value).split(/\s+/);
378
+ const allowed = new Set(field.values ?? []);
379
+ const tokens = values.filter((token): token is string => allowed.has(String(token)));
380
+ return tokens.length > 0 ? [...new Set(tokens)].join(" ") : undefined;
381
+ }
382
+
383
+ if (field.valueType === "number") {
384
+ if (typeof value !== "number") return undefined;
385
+ const minimum = field.numberMinimum ?? Number.NEGATIVE_INFINITY;
386
+ return Number.isFinite(value) && value >= minimum ? String(value) : undefined;
387
+ }
388
+
389
+ if (typeof value !== "string" && typeof value !== "number") return undefined;
390
+ const serialized = String(value);
391
+ if (field.values !== undefined && !field.values.includes(serialized)) return undefined;
392
+ return serialized;
393
+ }
394
+
395
+ function isAllowedFieldValue(
396
+ values: readonly QtiSharedVocabularyFieldValue[],
397
+ value: QtiSharedVocabularyStateValue,
398
+ ): value is QtiSharedVocabularyFieldValue {
399
+ return values.some((allowed) => allowed === value);
400
+ }
401
+
402
+ function isOmittedStateValue(value: QtiSharedVocabularyStateValue): boolean {
403
+ return (
404
+ value === undefined ||
405
+ value === false ||
406
+ value === "" ||
407
+ (Array.isArray(value) && value.length === 0)
408
+ );
409
+ }
@@ -0,0 +1,28 @@
1
+ import type { QtiInteractionType } from "./types.js";
2
+
3
+ export const SHARED_VOCABULARY_CHOICE_INTERACTIONS = [
4
+ "choice",
5
+ ] as const satisfies readonly QtiInteractionType[];
6
+
7
+ export const SHARED_VOCABULARY_CHOICE_AND_ORDER_INTERACTIONS = [
8
+ "choice",
9
+ "order",
10
+ ] as const satisfies readonly QtiInteractionType[];
11
+
12
+ export const SHARED_VOCABULARY_CHOICES_LAYOUT_INTERACTIONS = [
13
+ "match",
14
+ "gapMatch",
15
+ "graphicGapMatch",
16
+ "order",
17
+ ] as const satisfies readonly QtiInteractionType[];
18
+
19
+ export const SHARED_VOCABULARY_SELECTION_PRESENTATION_INTERACTIONS = [
20
+ "choice",
21
+ "hottext",
22
+ "hotspot",
23
+ "graphicGapMatch",
24
+ ] as const satisfies readonly QtiInteractionType[];
25
+
26
+ export const SHARED_VOCABULARY_MEDIA_INTERACTIONS = [
27
+ "media",
28
+ ] as const satisfies readonly QtiInteractionType[];
@@ -0,0 +1,143 @@
1
+ import {
2
+ validateChoicesContainerWidthSharedVocabulary,
3
+ validateMatchInteractionSharedVocabulary,
4
+ validateRegistrySharedVocabularyClasses,
5
+ } from "./shared-vocabulary-registry-validation.js";
6
+ import {
7
+ validateSharedVocabularyExtendedText,
8
+ validateSharedVocabularyInputWidth,
9
+ validateSharedVocabularyMediaPlayerControls,
10
+ } from "./shared-vocabulary-validation.js";
11
+ import type { QtiDiagnostic, QtiInteraction } from "./types.js";
12
+
13
+ const SHARED_VOCABULARY_INTERACTION_TYPES = new Set<QtiInteraction["type"]>([
14
+ "choice",
15
+ "match",
16
+ "gapMatch",
17
+ "graphicGapMatch",
18
+ "inlineChoice",
19
+ "textEntry",
20
+ "extendedText",
21
+ "media",
22
+ "order",
23
+ ]);
24
+
25
+ export function validateInteractionSharedVocabulary(
26
+ interaction: QtiInteraction,
27
+ diagnostics: QtiDiagnostic[],
28
+ ): void {
29
+ if (!SHARED_VOCABULARY_INTERACTION_TYPES.has(interaction.type)) return;
30
+
31
+ const classNames = sharedClassNames(interaction.attributes);
32
+ if (interaction.type === "media") {
33
+ validateMediaInteractionSharedVocabulary(interaction, diagnostics);
34
+ return;
35
+ }
36
+
37
+ if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
38
+ validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics);
39
+ return;
40
+ }
41
+
42
+ if (interaction.type === "extendedText") {
43
+ diagnostics.push(
44
+ ...validateSharedVocabularyExtendedText({
45
+ classNames,
46
+ subjectQtiName: interaction.qtiName,
47
+ expectedLength: interaction.attributes["expected-length"],
48
+ path: interaction.source?.path,
49
+ source: interaction.source,
50
+ }),
51
+ );
52
+ return;
53
+ }
54
+
55
+ if (
56
+ interaction.type === "match" ||
57
+ interaction.type === "gapMatch" ||
58
+ interaction.type === "graphicGapMatch"
59
+ ) {
60
+ if (interaction.type === "match") {
61
+ validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics);
62
+ }
63
+ validateRegistrySharedVocabularyClasses(interaction, classNames, diagnostics);
64
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
65
+ if (interaction.type === "gapMatch") {
66
+ validateGapInputWidthSharedVocabulary(interaction, diagnostics);
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (interaction.type === "order") {
72
+ validateRegistrySharedVocabularyClasses(interaction, classNames, diagnostics);
73
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
74
+ return;
75
+ }
76
+
77
+ if (interaction.type === "choice") {
78
+ validateRegistrySharedVocabularyClasses(interaction, classNames, diagnostics);
79
+ }
80
+ }
81
+
82
+ function validateGapInputWidthSharedVocabulary(
83
+ interaction: QtiInteraction,
84
+ diagnostics: QtiDiagnostic[],
85
+ ): void {
86
+ for (const gap of interaction.choices.filter((choice) => choice.qtiName === "qti-gap")) {
87
+ diagnostics.push(
88
+ ...validateSharedVocabularyInputWidth({
89
+ classNames: sharedClassNames(gap.attributes),
90
+ subjectQtiName: "qti-gap",
91
+ path: gap.source?.path ?? interaction.source?.path,
92
+ source: gap.source ?? interaction.source,
93
+ conflictCode: "interaction.sharedVocabulary.gapInputWidthConflict",
94
+ invalidCode: "interaction.sharedVocabulary.gapInputWidthInvalid",
95
+ }),
96
+ );
97
+ }
98
+ }
99
+
100
+ function validateInteractionInputWidthSharedVocabulary(
101
+ interaction: QtiInteraction,
102
+ classNames: string[],
103
+ diagnostics: QtiDiagnostic[],
104
+ ): void {
105
+ diagnostics.push(
106
+ ...validateSharedVocabularyInputWidth({
107
+ classNames,
108
+ subjectQtiName: interaction.qtiName,
109
+ path: interaction.source?.path,
110
+ source: interaction.source,
111
+ conflictCode: "interaction.sharedVocabulary.inputWidthConflict",
112
+ invalidCode: "interaction.sharedVocabulary.inputWidthInvalid",
113
+ }),
114
+ );
115
+ }
116
+
117
+ function validateMediaInteractionSharedVocabulary(
118
+ interaction: QtiInteraction,
119
+ diagnostics: QtiDiagnostic[],
120
+ ): void {
121
+ diagnostics.push(
122
+ ...validateSharedVocabularyMediaPlayerControls({
123
+ value: interaction.attributes["data-qti-media-player-controls"],
124
+ subjectQtiName: interaction.qtiName,
125
+ path: interaction.source?.path,
126
+ source: interaction.source,
127
+ }),
128
+ );
129
+
130
+ if (!interaction.object) return;
131
+ diagnostics.push(
132
+ ...validateSharedVocabularyMediaPlayerControls({
133
+ value: interaction.object.attributes["data-qti-media-player-controls"],
134
+ subjectQtiName: `${interaction.qtiName} media object`,
135
+ path: interaction.object.source?.path ?? interaction.source?.path,
136
+ source: interaction.object.source ?? interaction.source,
137
+ }),
138
+ );
139
+ }
140
+
141
+ function sharedClassNames(attributes: Record<string, string>): string[] {
142
+ return (attributes.class ?? "").split(/\s+/).filter(Boolean);
143
+ }