@conform-ed/qti-react 0.0.15 → 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.
@@ -11,13 +11,15 @@
11
11
  * - `gapChoices` split into the runtime's `gapTexts` (gapMatch) / `gapImgs` (graphic)
12
12
  * - media/upload/positionObjectStage flatten to the descriptor fields
13
13
  * - processing trees: `children` → `expressions`, `actions` → `rules`,
14
- * `responseElseIf`/`templateElseIf` pluralize
14
+ * `responseElseIf`/`templateElseIf` pluralize; fragment rules keep their
15
+ * nested `rules` verbatim
15
16
  *
16
17
  * Used by the corpus delivery meter (ADR-0002) and by any consumer ingesting
17
18
  * normalized XML.
18
19
  */
19
20
 
20
21
  import { parseCoords } from "./graphic";
22
+ import type { CatalogView } from "./pnp";
21
23
  import type {
22
24
  OutcomeDeclarationView,
23
25
  ResponseProcessingView,
@@ -27,7 +29,13 @@ import type {
27
29
  TemplateProcessingView,
28
30
  TemplateRuleView,
29
31
  } from "./rp";
30
- import type { AssessmentItemView, BodyNode, FeedbackView } from "./runtime";
32
+ import type {
33
+ AssessmentItemView,
34
+ BodyNode,
35
+ CompanionMaterialsView,
36
+ FeedbackView,
37
+ StimulusContentView,
38
+ } from "./runtime";
31
39
  import type {
32
40
  AssessmentItemRefView,
33
41
  AssessmentSectionView,
@@ -303,6 +311,11 @@ function convertRpRule(rule: unknown): Record<string, unknown> {
303
311
  };
304
312
  }
305
313
 
314
+ // Fragments nest their rules under `rules` (not the branches' `actions`).
315
+ if (kind === "responseProcessingFragment") {
316
+ return { kind, rules: (Array.isArray(record["rules"]) ? record["rules"] : []).map(convertRpRule) };
317
+ }
318
+
306
319
  return {
307
320
  kind,
308
321
  ...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
@@ -369,6 +382,47 @@ function convertResponseDeclaration(declaration: Record<string, unknown>): Respo
369
382
  * Reshape a normalized QTI document (the `normalizedDocument` from qti-xml validation)
370
383
  * into an `AssessmentItemView`, or null when it is not an assessment item.
371
384
  */
385
+ // ---------- catalogs (CatalogInfo, §5.29) ----------
386
+
387
+ /**
388
+ * Collect every catalog reachable from a converted node tree — catalog ids are
389
+ * document-unique (xs:ID), so item-level and nested (rubric/feedback block) catalogs
390
+ * pool into one list for idref resolution.
391
+ */
392
+ function appendCatalogViews(target: CatalogView[], value: unknown): void {
393
+ if (Array.isArray(value)) {
394
+ for (const entry of value) {
395
+ appendCatalogViews(target, entry);
396
+ }
397
+ return;
398
+ }
399
+
400
+ if (!isRecord(value)) {
401
+ return;
402
+ }
403
+
404
+ const catalogInfo = value["catalogInfo"];
405
+ if (isRecord(catalogInfo) && Array.isArray(catalogInfo["catalogs"])) {
406
+ target.push(...(catalogInfo["catalogs"] as CatalogView[]));
407
+ }
408
+
409
+ appendCatalogViews(target, value["content"]);
410
+ appendCatalogViews(target, value["children"]);
411
+ }
412
+
413
+ /** Item/stimulus-level catalogInfo, converted so card content is renderer-ready. */
414
+ function documentCatalogViews(root: Record<string, unknown>, convertedContent: unknown): CatalogView[] {
415
+ const catalogs: CatalogView[] = [];
416
+
417
+ appendCatalogViews(
418
+ catalogs,
419
+ isRecord(root["catalogInfo"]) ? { catalogInfo: convertContentValue(root["catalogInfo"]) } : undefined,
420
+ );
421
+ appendCatalogViews(catalogs, convertedContent);
422
+
423
+ return catalogs;
424
+ }
425
+
372
426
  export function assessmentItemViewFromNormalized(document: unknown): AssessmentItemView | null {
373
427
  if (!isRecord(document) || !isRecord(document["assessmentItem"])) {
374
428
  return null;
@@ -378,6 +432,10 @@ export function assessmentItemViewFromNormalized(document: unknown): AssessmentI
378
432
  const itemBody = isRecord(item["itemBody"]) ? item["itemBody"] : {};
379
433
  const content = Array.isArray(itemBody["content"]) ? itemBody["content"].map(convertContentEntry) : [];
380
434
  const templateRules = isRecord(item["templateProcessing"]) ? item["templateProcessing"]["rules"] : undefined;
435
+ const convertedModalFeedbacks = Array.isArray(item["modalFeedbacks"])
436
+ ? item["modalFeedbacks"].map(convertContentValue)
437
+ : undefined;
438
+ const catalogs = documentCatalogViews(item, [content, convertedModalFeedbacks]);
381
439
 
382
440
  return {
383
441
  responseDeclarations: asRecords(item["responseDeclarations"]).map(convertResponseDeclaration),
@@ -396,13 +454,43 @@ export function assessmentItemViewFromNormalized(document: unknown): AssessmentI
396
454
  }
397
455
  : {}),
398
456
  ...(typeof item["adaptive"] === "boolean" ? { adaptive: item["adaptive"] } : {}),
399
- ...(Array.isArray(item["modalFeedbacks"])
400
- ? { modalFeedbacks: item["modalFeedbacks"].map(convertContentValue) as unknown as readonly FeedbackView[] }
457
+ ...(convertedModalFeedbacks
458
+ ? { modalFeedbacks: convertedModalFeedbacks as unknown as readonly FeedbackView[] }
459
+ : {}),
460
+ ...(catalogs.length ? { catalogs } : {}),
461
+ ...(isRecord(item["companionMaterialsInfo"])
462
+ ? { companionMaterials: item["companionMaterialsInfo"] as CompanionMaterialsView }
463
+ : {}),
464
+ ...(Array.isArray(item["assessmentStimulusRefs"])
465
+ ? {
466
+ assessmentStimulusRefs: asRecords(item["assessmentStimulusRefs"]).map((ref) => ({
467
+ identifier: typeof ref["identifier"] === "string" ? ref["identifier"] : "",
468
+ href: typeof ref["href"] === "string" ? ref["href"] : "",
469
+ ...(typeof ref["title"] === "string" ? { title: ref["title"] } : {}),
470
+ })),
471
+ }
401
472
  : {}),
402
473
  itemBody: { content: content as BodyNode[] },
403
474
  };
404
475
  }
405
476
 
477
+ /**
478
+ * The renderable body of a normalized AssessmentStimulus document, for
479
+ * `QtiRuntimeConfig.resolveStimulus`. Returns null for non-stimulus documents.
480
+ */
481
+ export function stimulusContentFromNormalized(document: unknown): StimulusContentView | null {
482
+ if (!isRecord(document) || !isRecord(document["assessmentStimulus"])) {
483
+ return null;
484
+ }
485
+
486
+ const stimulus = document["assessmentStimulus"];
487
+ const body = isRecord(stimulus["stimulusBody"]) ? stimulus["stimulusBody"] : {};
488
+ const content = Array.isArray(body["content"]) ? body["content"].map(convertContentEntry) : [];
489
+ const catalogs = documentCatalogViews(stimulus, content);
490
+
491
+ return { content: content as BodyNode[], ...(catalogs.length ? { catalogs } : {}) };
492
+ }
493
+
406
494
  // ---------- assessment tests (ADR-0005) ----------
407
495
 
408
496
  /** Normalized `{kind: "preCondition", expression}` wrappers as bare expressions. */
@@ -442,6 +530,11 @@ function convertOutcomeRule(rule: unknown): Record<string, unknown> {
442
530
  };
443
531
  }
444
532
 
533
+ // Fragments nest their rules under `rules` (not the branches' `actions`).
534
+ if (kind === "outcomeProcessingFragment") {
535
+ return { kind, rules: (Array.isArray(record["rules"]) ? record["rules"] : []).map(convertOutcomeRule) };
536
+ }
537
+
445
538
  return {
446
539
  kind,
447
540
  ...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
@@ -475,6 +568,14 @@ function convertItemRef(ref: Record<string, unknown>): AssessmentItemRefView {
475
568
  ...(Array.isArray(ref["weights"])
476
569
  ? { weights: ref["weights"] as NonNullable<AssessmentItemRefView["weights"]> }
477
570
  : {}),
571
+ ...(Array.isArray(ref["templateDefaults"])
572
+ ? {
573
+ templateDefaults: asRecords(ref["templateDefaults"]).map((entry) => ({
574
+ templateIdentifier: typeof entry["templateIdentifier"] === "string" ? entry["templateIdentifier"] : "",
575
+ expression: convertExpression(entry["expression"]),
576
+ })),
577
+ }
578
+ : {}),
478
579
  ...sessionControlAndTimeLimits(ref),
479
580
  };
480
581
  }
@@ -494,6 +595,7 @@ function convertSection(section: Record<string, unknown>): AssessmentSectionView
494
595
  ...(typeof section["visible"] === "boolean" ? { visible: section["visible"] } : {}),
495
596
  ...(typeof section["fixed"] === "boolean" ? { fixed: section["fixed"] } : {}),
496
597
  ...(typeof section["required"] === "boolean" ? { required: section["required"] } : {}),
598
+ ...(typeof section["keepTogether"] === "boolean" ? { keepTogether: section["keepTogether"] } : {}),
497
599
  ...(isRecord(section["selection"])
498
600
  ? { selection: section["selection"] as unknown as NonNullable<AssessmentSectionView["selection"]> }
499
601
  : {}),
package/src/pnp.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * AfA PNP (QTI 3.0 profile) views and catalog support resolution.
3
+ *
4
+ * The catalog holds "support-specific dormant content that can be made active … based
5
+ * on the candidate's PNP information (or an assessment program's settings)" (§5.28).
6
+ * This module owns the two halves of that sentence: which supports are active for a
7
+ * candidate (activation), and which card content realises an active support
8
+ * (matching). Rendering is the runtime's job; time-limit adjustments are the test
9
+ * controller's.
10
+ *
11
+ * Views are structural mirrors of the contracts AfA PNP and catalog schemas — the
12
+ * package depends on contracts only in tests, like the rest of qti-react.
13
+ */
14
+
15
+ import type { BodyNode } from "./runtime";
16
+
17
+ // ---------- PNP views ----------
18
+
19
+ export interface PnpReplaceAccessModeView {
20
+ readonly replaceAccessModes?: readonly string[];
21
+ }
22
+
23
+ export interface PnpLanguageModeView extends PnpReplaceAccessModeView {
24
+ readonly xmlLang: string;
25
+ }
26
+
27
+ /** additional-testing-time: "Only one of the available options can be selected." */
28
+ export interface PnpAdditionalTestingTimeView extends PnpReplaceAccessModeView {
29
+ readonly timeMultiplier?: number;
30
+ readonly fixedMinutes?: number;
31
+ readonly unlimited?: boolean;
32
+ }
33
+
34
+ export interface PnpFeatureSetView {
35
+ readonly features?: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * The candidate's preferences, shaped like the normalized access-for-all-pnp
40
+ * document. Feature preference objects carry the fields card-entry discriminators
41
+ * match against (xmlLang, readingType, …); unknown fields are preserved.
42
+ */
43
+ export interface PnpView {
44
+ readonly [feature: string]: unknown;
45
+ readonly languageOfInterface?: readonly PnpLanguageModeView[];
46
+ readonly keywordTranslation?: PnpLanguageModeView;
47
+ readonly itemTranslation?: PnpLanguageModeView;
48
+ readonly signLanguage?: PnpLanguageModeView;
49
+ readonly additionalTestingTime?: PnpAdditionalTestingTimeView;
50
+ readonly activateAtInitializationSet?: PnpFeatureSetView;
51
+ readonly activateAsOptionSet?: PnpFeatureSetView;
52
+ readonly prohibitSet?: PnpFeatureSetView;
53
+ }
54
+
55
+ // ---------- Catalog views ----------
56
+
57
+ export interface CatalogFileHrefView {
58
+ readonly href: string;
59
+ readonly mimeType: string;
60
+ }
61
+
62
+ export interface CatalogContentView {
63
+ readonly xmlLang?: string;
64
+ readonly dataAttributes?: Readonly<Record<string, string>>;
65
+ readonly content?: readonly BodyNode[];
66
+ }
67
+
68
+ export interface CatalogCardEntryView {
69
+ readonly xmlLang?: string;
70
+ readonly default?: boolean;
71
+ readonly dataAttributes?: Readonly<Record<string, string>>;
72
+ readonly htmlContent?: CatalogContentView;
73
+ readonly fileHrefs?: readonly CatalogFileHrefView[];
74
+ }
75
+
76
+ export interface CatalogCardView {
77
+ readonly support: string;
78
+ readonly xmlLang?: string;
79
+ readonly htmlContent?: CatalogContentView;
80
+ readonly fileHrefs?: readonly CatalogFileHrefView[];
81
+ readonly cardEntries?: readonly CatalogCardEntryView[];
82
+ }
83
+
84
+ export interface CatalogView {
85
+ readonly id: string;
86
+ readonly cards: readonly CatalogCardView[];
87
+ }
88
+
89
+ // ---------- Activation ----------
90
+
91
+ /**
92
+ * PNP feature names (the FeatureSet vocabulary) to their preference fields on the
93
+ * normalized PNP document. Presence of the field states the preference.
94
+ */
95
+ const pnpFeatureFields: Readonly<Record<string, string>> = {
96
+ "linguistic-guidance": "linguisticGuidance",
97
+ "keyword-emphasis": "keywordEmphasis",
98
+ "keyword-translation": "keywordTranslation",
99
+ "simplified-language-portions": "simplifiedLanguagePortions",
100
+ "simplified-graphics": "simplifiedGraphics",
101
+ "item-translation": "itemTranslation",
102
+ "sign-language": "signLanguage",
103
+ encouragement: "encouragement",
104
+ "additional-testing-time": "additionalTestingTime",
105
+ "line-reader": "lineReader",
106
+ "invert-display-polarity": "invertDisplayPolarity",
107
+ magnification: "magnification",
108
+ spoken: "spoken",
109
+ tactile: "tactile",
110
+ braille: "braille",
111
+ "answer-masking": "answerMasking",
112
+ "keyboard-directions": "keyboardDirections",
113
+ "additional-directions": "additionalDirections",
114
+ "long-description": "longDescription",
115
+ captions: "captions",
116
+ transcript: "transcript",
117
+ "alternative-text": "alternativeText",
118
+ "audio-description": "audioDescription",
119
+ "high-contrast": "highContrast",
120
+ "input-requirements": "inputRequirements",
121
+ "language-of-interface": "languageOfInterface",
122
+ "layout-single-column": "layoutSingleColumn",
123
+ "text-appearance": "textAppearance",
124
+ "calculator-on-screen": "calculatorOnScreen",
125
+ "dictionary-on-screen": "dictionaryOnScreen",
126
+ "glossary-on-screen": "glossaryOnScreen",
127
+ "thesaurus-on-screen": "thesaurusOnScreen",
128
+ "homophone-checker-on-screen": "homophoneCheckerOnScreen",
129
+ "note-taking-on-screen": "noteTakingOnScreen",
130
+ "visual-organizer-on-screen": "visualOrganizerOnScreen",
131
+ "outliner-on-screen": "outlinerOnScreen",
132
+ "peer-interaction-on-screen": "peerInteractionOnScreen",
133
+ "spell-checker-on-screen": "spellCheckerOnScreen",
134
+ };
135
+
136
+ export interface PnpActivation {
137
+ /** Supports in effect from the start of the session. */
138
+ readonly active: ReadonlySet<string>;
139
+ /** Supports the candidate may turn on during the session (activate-as-option-set). */
140
+ readonly optional: ReadonlySet<string>;
141
+ /** Supports that must not be offered (prohibit-set) — wins over everything. */
142
+ readonly prohibited: ReadonlySet<string>;
143
+ }
144
+
145
+ /**
146
+ * Resolve the activation policy: prohibit-set wins; activate-at-initialization-set is
147
+ * active; activate-as-option-set is offered but off. A preference stated outside any
148
+ * set (e.g. a bare keyword-translation) is honored from the start — the PNP records
149
+ * the need, and without an activation policy there is nothing to defer to (designed
150
+ * policy, see the ADR).
151
+ */
152
+ export function resolvePnpActivation(pnp: PnpView | undefined): PnpActivation {
153
+ const prohibited = new Set(pnp?.prohibitSet?.features ?? []);
154
+ const active = new Set<string>();
155
+ const optional = new Set<string>();
156
+
157
+ if (!pnp) {
158
+ return { active, optional, prohibited };
159
+ }
160
+
161
+ const optedIn = new Set(pnp.activateAsOptionSet?.features ?? []);
162
+
163
+ for (const feature of pnp.activateAtInitializationSet?.features ?? []) {
164
+ active.add(feature);
165
+ }
166
+
167
+ for (const [feature, field] of Object.entries(pnpFeatureFields)) {
168
+ if (pnp[field] === undefined || active.has(feature) || optedIn.has(feature)) {
169
+ continue;
170
+ }
171
+ active.add(feature);
172
+ }
173
+
174
+ for (const feature of optedIn) {
175
+ if (!active.has(feature)) {
176
+ optional.add(feature);
177
+ }
178
+ }
179
+
180
+ for (const feature of prohibited) {
181
+ active.delete(feature);
182
+ optional.delete(feature);
183
+ }
184
+
185
+ return { active, optional, prohibited };
186
+ }
187
+
188
+ // ---------- Matching ----------
189
+
190
+ /** BCP 47, pragmatically: case-insensitive exact match or equal primary subtags. */
191
+ function languagesMatch(left: string, right: string): boolean {
192
+ const a = left.toLowerCase();
193
+ const b = right.toLowerCase();
194
+
195
+ return a === b || a.split("-")[0] === b.split("-")[0];
196
+ }
197
+
198
+ function camelCase(name: string): string {
199
+ return name.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase());
200
+ }
201
+
202
+ /**
203
+ * The PNP preference object stated for a feature — what card entries discriminate
204
+ * against, and what results reporting reads detail (language, time values) from.
205
+ */
206
+ export function pnpFeaturePreference(pnp: PnpView | undefined, feature: string): Record<string, unknown> | undefined {
207
+ const field = pnpFeatureFields[feature];
208
+ if (!pnp || !field) {
209
+ return undefined;
210
+ }
211
+
212
+ const value = pnp[field];
213
+ const first = Array.isArray(value) ? value[0] : value;
214
+
215
+ return typeof first === "object" && first !== null ? (first as Record<string, unknown>) : undefined;
216
+ }
217
+
218
+ /**
219
+ * An entry matches when every discriminator it declares (xml:lang, data-*) agrees
220
+ * with the candidate's preference for the card's support. Entries declaring nothing
221
+ * match unconditionally.
222
+ */
223
+ function entryMatches(entry: CatalogCardEntryView, preference: Record<string, unknown> | undefined): boolean {
224
+ if (entry.xmlLang !== undefined) {
225
+ const preferred = preference?.["xmlLang"];
226
+ if (typeof preferred !== "string" || !languagesMatch(entry.xmlLang, preferred)) {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ for (const [name, value] of Object.entries(entry.dataAttributes ?? {})) {
232
+ const preferred = preference?.[camelCase(name)];
233
+ // Preference fields are scalars (strings, numbers, booleans) after normalization.
234
+ const comparable =
235
+ typeof preferred === "string" || typeof preferred === "number" || typeof preferred === "boolean"
236
+ ? `${preferred}`
237
+ : undefined;
238
+ if (comparable === undefined || comparable !== value) {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ return true;
244
+ }
245
+
246
+ /** A support's resolved alternative content for one catalog. */
247
+ export interface ResolvedCatalogSupport {
248
+ readonly support: string;
249
+ readonly xmlLang?: string;
250
+ readonly content?: readonly BodyNode[];
251
+ readonly fileHrefs?: readonly CatalogFileHrefView[];
252
+ }
253
+
254
+ export interface CatalogResolution {
255
+ readonly activation: PnpActivation;
256
+ readonly byCatalogId: ReadonlyMap<string, readonly ResolvedCatalogSupport[]>;
257
+ }
258
+
259
+ function resolveCard(card: CatalogCardView, pnp: PnpView | undefined): ResolvedCatalogSupport | undefined {
260
+ if (!card.cardEntries) {
261
+ // Direct content is unconditional once the support is active.
262
+ const cardLang = card.xmlLang ?? card.htmlContent?.xmlLang;
263
+
264
+ return {
265
+ support: card.support,
266
+ ...(cardLang !== undefined ? { xmlLang: cardLang } : {}),
267
+ ...(card.htmlContent?.content ? { content: card.htmlContent.content } : {}),
268
+ ...(card.fileHrefs ? { fileHrefs: card.fileHrefs } : {}),
269
+ };
270
+ }
271
+
272
+ const preference = pnpFeaturePreference(pnp, card.support);
273
+ // "If the CardEntry attribute values do not identify the proper content for a
274
+ // candidate, use the content designated as default." (§5.27.2)
275
+ const entry =
276
+ card.cardEntries.find((candidate) => entryMatches(candidate, preference)) ??
277
+ card.cardEntries.find((candidate) => candidate.default === true);
278
+
279
+ if (!entry) {
280
+ return undefined;
281
+ }
282
+
283
+ const xmlLang = entry.xmlLang ?? entry.htmlContent?.xmlLang ?? card.xmlLang;
284
+
285
+ return {
286
+ support: card.support,
287
+ ...(xmlLang !== undefined ? { xmlLang } : {}),
288
+ ...(entry.htmlContent?.content ? { content: entry.htmlContent.content } : {}),
289
+ ...(entry.fileHrefs ? { fileHrefs: entry.fileHrefs } : {}),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Resolve every catalog's active alternative content for a candidate. `activeSupports`
295
+ * is the delivery-engine channel — program settings and candidate-toggled options
296
+ * ("or an assessment program's settings", §5.28); prohibited supports stay out even
297
+ * when named there.
298
+ */
299
+ export function resolveCatalogSupports(options: {
300
+ readonly catalogs?: readonly CatalogView[] | undefined;
301
+ readonly pnp?: PnpView | undefined;
302
+ readonly activeSupports?: readonly string[] | undefined;
303
+ }): CatalogResolution {
304
+ const activation = resolvePnpActivation(options.pnp);
305
+ const effective = new Set(activation.active);
306
+
307
+ for (const support of options.activeSupports ?? []) {
308
+ if (!activation.prohibited.has(support)) {
309
+ effective.add(support);
310
+ }
311
+ }
312
+
313
+ const byCatalogId = new Map<string, readonly ResolvedCatalogSupport[]>();
314
+
315
+ for (const catalog of options.catalogs ?? []) {
316
+ const resolved: ResolvedCatalogSupport[] = [];
317
+
318
+ for (const card of catalog.cards) {
319
+ if (!effective.has(card.support)) {
320
+ continue;
321
+ }
322
+
323
+ const support = resolveCard(card, options.pnp);
324
+ if (support) {
325
+ resolved.push(support);
326
+ }
327
+ }
328
+
329
+ byCatalogId.set(catalog.id, resolved);
330
+ }
331
+
332
+ return { activation, byCatalogId };
333
+ }
@@ -11,6 +11,8 @@ import type { BodyNode, InteractionRenderProps } from "../runtime";
11
11
  interface SimpleChoiceView {
12
12
  identifier: string;
13
13
  content?: readonly BodyNode[];
14
+ /** Catalog reference for dormant alternative content on this choice (§5.28). */
15
+ dataCatalogIdref?: string;
14
16
  }
15
17
 
16
18
  interface ChoiceNodeView {
@@ -38,6 +40,7 @@ export function ChoiceReferenceSkin(props: InteractionRenderProps): ReactNode {
38
40
  "button",
39
41
  { key: choice.identifier, type: "button", disabled: props.disabled, ...optionProps },
40
42
  props.renderContent(choice.content) ?? choice.identifier,
43
+ props.renderCatalogSupports(choice.dataCatalogIdref),
41
44
  );
42
45
  }),
43
46
  );
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Response validity (ItemSessionControl validate-responses): "An invalid response is
3
+ * defined to be a response which does not satisfy the constraints imposed by the
4
+ * interaction with which it is associated." The constraint vocabulary is what the
5
+ * view model carries on interaction nodes — min/max-choices, min/max-associations,
6
+ * min-strings, pattern-mask, min-plays. Only authored attributes are validated:
7
+ * rendering defaults (e.g. a radio group's single choice) are interaction behavior,
8
+ * not submission constraints.
9
+ */
10
+
11
+ import { compile as compileXsdPattern } from "xspattern";
12
+
13
+ import { isInteractionKind, v0ContentModel } from "./content-model";
14
+ import type { BodyNode } from "./runtime";
15
+ import { isResponseRecord } from "./types";
16
+ import type { ResponseValue } from "./types";
17
+
18
+ export type ResponseConstraintKind =
19
+ | "minChoices"
20
+ | "maxChoices"
21
+ | "minAssociations"
22
+ | "maxAssociations"
23
+ | "minStrings"
24
+ | "patternMask"
25
+ | "minPlays";
26
+
27
+ export interface InteractionConstraint {
28
+ readonly responseIdentifier: string;
29
+ readonly kind: ResponseConstraintKind;
30
+ /** The declared bound: a count for the min/max constraints, the XSD regex for patternMask. */
31
+ readonly bound: number | string;
32
+ }
33
+
34
+ /** A constraint the current response fails — the reason a submission is invalid. */
35
+ export type ResponseViolation = InteractionConstraint;
36
+
37
+ const countConstraintKinds = ["minChoices", "maxChoices", "minAssociations", "maxAssociations", "minStrings"] as const;
38
+
39
+ /**
40
+ * Walk the item body and collect the constraint attributes its interactions carry.
41
+ * Zero bounds impose nothing: "If max-choices is 0 then there is no restriction";
42
+ * "If min-choices is 0 then the candidate is not required to select any choices."
43
+ */
44
+ export function collectInteractionConstraints(content: readonly BodyNode[] | undefined): InteractionConstraint[] {
45
+ const constraints: InteractionConstraint[] = [];
46
+
47
+ function walk(node: BodyNode): void {
48
+ const record = node as unknown as Record<string, unknown>;
49
+
50
+ if (isInteractionKind(v0ContentModel, node.kind) && typeof record["responseIdentifier"] === "string") {
51
+ const responseIdentifier = record["responseIdentifier"];
52
+
53
+ for (const kind of [...countConstraintKinds, "minPlays"] as const) {
54
+ const bound = record[kind];
55
+
56
+ if (typeof bound === "number" && bound > 0) {
57
+ constraints.push({ responseIdentifier, kind, bound });
58
+ }
59
+ }
60
+
61
+ const patternMask = record["patternMask"];
62
+
63
+ if (typeof patternMask === "string" && patternMask !== "") {
64
+ constraints.push({ responseIdentifier, kind: "patternMask", bound: patternMask });
65
+ }
66
+
67
+ return; // interactions do not nest
68
+ }
69
+
70
+ for (const key of ["content", "children"] as const) {
71
+ const nested = record[key];
72
+
73
+ if (Array.isArray(nested)) {
74
+ for (const child of nested as readonly BodyNode[]) {
75
+ walk(child);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const node of content ?? []) {
82
+ walk(node);
83
+ }
84
+
85
+ return constraints;
86
+ }
87
+
88
+ /** Selected members of a response: choices picked, associations made, strings entered. */
89
+ function memberCount(value: ResponseValue): number {
90
+ if (value === null || value === undefined || value === "") {
91
+ return 0;
92
+ }
93
+
94
+ if (Array.isArray(value)) {
95
+ return value.filter((member) => member !== null && member !== "").length;
96
+ }
97
+
98
+ return 1;
99
+ }
100
+
101
+ /** The non-empty strings a pattern mask applies to (each container member must match). */
102
+ function stringMembers(value: ResponseValue): string[] {
103
+ if (typeof value === "string") {
104
+ return value === "" ? [] : [value];
105
+ }
106
+
107
+ if (Array.isArray(value)) {
108
+ return value.filter((member): member is string => typeof member === "string" && member !== "");
109
+ }
110
+
111
+ return [];
112
+ }
113
+
114
+ function violates(constraint: InteractionConstraint, value: ResponseValue): boolean {
115
+ switch (constraint.kind) {
116
+ case "minChoices":
117
+ case "minAssociations":
118
+ case "minStrings":
119
+ return memberCount(value) < Number(constraint.bound);
120
+ case "maxChoices":
121
+ case "maxAssociations":
122
+ return memberCount(value) > Number(constraint.bound);
123
+ case "minPlays": {
124
+ // "Failure to play the media object the minimum number of times constitutes an
125
+ // invalid response." The media response variable counts the plays.
126
+ const plays = typeof value === "number" ? value : 0;
127
+
128
+ return plays < Number(constraint.bound);
129
+ }
130
+ case "patternMask": {
131
+ // "the pattern-mask specifies a regular expression that the candidate's
132
+ // response must match in order to be considered valid" — XSD regex dialect.
133
+ // An unanswered interaction is governed by the min* constraints, not the
134
+ // pattern, and an uncompilable pattern never blocks the candidate.
135
+ const members = stringMembers(value);
136
+
137
+ if (members.length === 0) {
138
+ return false;
139
+ }
140
+
141
+ try {
142
+ const matches = compileXsdPattern(String(constraint.bound), { language: "xsd" });
143
+
144
+ return !members.every((member) => matches(member));
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ /** The constraints the current responses fail; empty means the responses are valid. */
153
+ export function collectResponseViolations(
154
+ constraints: readonly InteractionConstraint[],
155
+ responses: Readonly<Record<string, ResponseValue>>,
156
+ ): ResponseViolation[] {
157
+ return constraints.filter((constraint) => {
158
+ const value = responses[constraint.responseIdentifier] ?? null;
159
+
160
+ // Record responses (PCI-style composites) carry no countable selection.
161
+ return !isResponseRecord(value) && violates(constraint, value);
162
+ });
163
+ }