@conform-ed/qti-react 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Headless capability gate (ADR-0003): "can a runtime that supports `supportedInteractions`
3
+ * deliver this item, and if not, why" — extracted from the React runtime's `canDeliver`
4
+ * so server-side callers (e.g. an ingest pipeline) can reach the *same* decision without
5
+ * importing React. The React runtime delegates to this, passing the interaction set its
6
+ * descriptors + skins cover; a headless caller passes the set its delivery supports.
7
+ *
8
+ * This module is React-free by construction (content-model + RP collectors only; the view
9
+ * shapes are type-only imports), so it ships through the `@conform-ed/qti-react/headless`
10
+ * entry alongside the normalize → view adapters.
11
+ */
12
+
13
+ import type { ZodType } from "zod";
14
+
15
+ import type { CapabilityIssue, CapabilityReport } from "./capability";
16
+ import { isAllowedFlowElement, v0ContentModel, type ContentModel } from "./content-model";
17
+ import { collectRpIssues, collectTemplateIssues } from "./rp";
18
+ import type {
19
+ AssessmentItemView,
20
+ AssessmentStimulusRefView,
21
+ BodyNode,
22
+ InteractionNode,
23
+ StimulusContentView,
24
+ XmlContentNode,
25
+ } from "./runtime";
26
+
27
+ const feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
28
+ const templateContentKinds = new Set(["templateInline", "templateBlock"]);
29
+
30
+ /** Body node kinds that render without a descriptor, skin, or content-model entry. */
31
+ const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
32
+
33
+ function isFeedbackNode(node: BodyNode): boolean {
34
+ return feedbackKinds.has(node.kind);
35
+ }
36
+
37
+ function isTemplateContentNode(node: BodyNode): boolean {
38
+ return templateContentKinds.has(node.kind);
39
+ }
40
+
41
+ /**
42
+ * An interaction node is any non-xml node carrying a `responseIdentifier` — including
43
+ * kinds this runtime has never heard of. The discriminator must not depend on the
44
+ * supported set, or unknown interactions would be indistinguishable from text.
45
+ */
46
+ function isInteractionNode(node: BodyNode): node is InteractionNode {
47
+ return node.kind !== "xml" && typeof (node as { responseIdentifier?: unknown }).responseIdentifier === "string";
48
+ }
49
+
50
+ export interface ItemCapabilityOptions {
51
+ /** Interaction kinds the target runtime can render (descriptor + skin both present). */
52
+ readonly supportedInteractions: ReadonlySet<string>;
53
+ /** Content model deciding the flow-element allowlist + math root; defaults to v0. */
54
+ readonly model?: ContentModel;
55
+ /** Custom-operator classes the target runtime registers (for RP capability). */
56
+ readonly customOperatorClasses?: ReadonlySet<string>;
57
+ /** Resolver for shared-stimulus refs; unresolved refs are not deliverable. */
58
+ readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
59
+ /**
60
+ * Optional per-kind schemas for the stricter `invalid-interaction` check. The React
61
+ * runtime supplies its descriptor schemas; a headless caller that has already validated
62
+ * structure (e.g. against the qti-xml contracts schema) can omit them.
63
+ */
64
+ readonly interactionSchemas?: ReadonlyMap<string, ZodType>;
65
+ }
66
+
67
+ /**
68
+ * Report whether `item` can be delivered by a runtime with the given capabilities, and
69
+ * every reason it cannot. Pure and React-free; the React runtime's `canDeliver` is a thin
70
+ * wrapper over this.
71
+ */
72
+ export function reportItemCapability(item: AssessmentItemView, options: ItemCapabilityOptions): CapabilityReport {
73
+ const model = options.model ?? v0ContentModel;
74
+ const customOperatorClasses = options.customOperatorClasses ?? new Set<string>();
75
+ const issues: CapabilityIssue[] = [];
76
+ const seen = new Set<string>();
77
+
78
+ function report(issue: CapabilityIssue): void {
79
+ const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
80
+
81
+ if (!seen.has(dedupeKey)) {
82
+ seen.add(dedupeKey);
83
+ issues.push(issue);
84
+ }
85
+ }
86
+
87
+ function walk(node: BodyNode): void {
88
+ if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
89
+ for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
90
+ walk(child);
91
+ }
92
+
93
+ return;
94
+ }
95
+
96
+ if (isInteractionNode(node)) {
97
+ if (!options.supportedInteractions.has(node.kind)) {
98
+ report({ type: "unsupported-interaction", name: node.kind, responseIdentifier: node.responseIdentifier });
99
+
100
+ return;
101
+ }
102
+
103
+ const schema = options.interactionSchemas?.get(node.kind);
104
+
105
+ if (schema) {
106
+ const parsed = schema.safeParse(node);
107
+
108
+ if (!parsed.success) {
109
+ const detail = parsed.error.issues[0]?.message;
110
+
111
+ report({
112
+ type: "invalid-interaction",
113
+ name: node.kind,
114
+ responseIdentifier: node.responseIdentifier,
115
+ ...(detail !== undefined ? { detail } : {}),
116
+ });
117
+ }
118
+ }
119
+
120
+ return;
121
+ }
122
+
123
+ if (node.kind === "xml") {
124
+ const xmlNode = node as XmlContentNode;
125
+
126
+ if (xmlNode.name === model.mathRoot) {
127
+ return;
128
+ }
129
+
130
+ if (!isAllowedFlowElement(model, xmlNode.name)) {
131
+ report({ type: "unsupported-element", name: xmlNode.name });
132
+ }
133
+
134
+ for (const child of xmlNode.children ?? []) {
135
+ walk(child);
136
+ }
137
+
138
+ return;
139
+ }
140
+
141
+ if (intrinsicLeafKinds.has(node.kind)) {
142
+ return;
143
+ }
144
+
145
+ report({ type: "unsupported-element", name: node.kind });
146
+ }
147
+
148
+ for (const node of item.itemBody.content ?? []) {
149
+ walk(node);
150
+ }
151
+
152
+ for (const ref of item.assessmentStimulusRefs ?? []) {
153
+ const stimulus = options.resolveStimulus?.(ref) ?? null;
154
+
155
+ if (stimulus === null) {
156
+ report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
157
+ continue;
158
+ }
159
+
160
+ for (const node of stimulus.content) {
161
+ walk(node);
162
+ }
163
+ }
164
+
165
+ for (const feedback of item.modalFeedbacks ?? []) {
166
+ for (const child of feedback.content ?? []) {
167
+ walk(child);
168
+ }
169
+ }
170
+
171
+ for (const issue of collectRpIssues(item.responseProcessing, {
172
+ customOperatorClasses,
173
+ outcomeDeclarations: item.outcomeDeclarations ?? [],
174
+ })) {
175
+ report(issue);
176
+ }
177
+
178
+ for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
179
+ report(issue);
180
+ }
181
+
182
+ return { deliverable: issues.length === 0, issues };
183
+ }
184
+
185
+ /**
186
+ * Interaction kinds the bundled reference skin renders — the default "supported set" for
187
+ * callers that deliver with the reference skin. Kept in parity with the reference skin by
188
+ * a test; pass an explicit set to `reportItemCapability` for a custom delivery surface.
189
+ */
190
+ export const referenceInteractionKinds: readonly string[] = [
191
+ "associateInteraction",
192
+ "choiceInteraction",
193
+ "drawingInteraction",
194
+ "endAttemptInteraction",
195
+ "extendedTextInteraction",
196
+ "gapMatchInteraction",
197
+ "graphicAssociateInteraction",
198
+ "graphicGapMatchInteraction",
199
+ "graphicOrderInteraction",
200
+ "hotspotInteraction",
201
+ "hottextInteraction",
202
+ "inlineChoiceInteraction",
203
+ "matchInteraction",
204
+ "mediaInteraction",
205
+ "orderInteraction",
206
+ "positionObjectStage",
207
+ "selectPointInteraction",
208
+ "sliderInteraction",
209
+ "textEntryInteraction",
210
+ "uploadInteraction",
211
+ ];
@@ -0,0 +1,40 @@
1
+ import type { ScoreResult } from "./types";
2
+
3
+ export interface EffectiveItemScore {
4
+ readonly raw: number;
5
+ readonly max: number;
6
+ /** True when SCORE came from the RP outcomes of record rather than per-variable scoring. */
7
+ readonly fromOutcomes: boolean;
8
+ }
9
+
10
+ function numericOutcome(value: unknown): number | null {
11
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
12
+ }
13
+
14
+ /**
15
+ * The item score of record (QTI): a numeric SCORE outcome from response processing is
16
+ * authoritative — PCI/RP-scored items (e.g. math-entry) have no per-variable
17
+ * correctResponse basis, so their standard scores read 0. Summed per-variable standard
18
+ * scoring is the fallback for items without RP. MAXSCORE follows the same precedence.
19
+ *
20
+ * Pure and framework-light: client and server (authoritative finalize) share it so the
21
+ * grade of record is derived identically on both sides.
22
+ */
23
+ export function effectiveItemScore(
24
+ scores: readonly ScoreResult[],
25
+ outcomes: Readonly<Record<string, unknown>>,
26
+ ): EffectiveItemScore {
27
+ const scoreOutcome = numericOutcome(outcomes["SCORE"]);
28
+ const maxOutcome = numericOutcome(outcomes["MAXSCORE"]);
29
+ const summedMax = scores.reduce((total, score) => total + score.maxScore, 0);
30
+
31
+ if (scoreOutcome !== null) {
32
+ return { raw: scoreOutcome, max: maxOutcome ?? summedMax, fromOutcomes: true };
33
+ }
34
+
35
+ return {
36
+ raw: scores.reduce((total, score) => total + score.score, 0),
37
+ max: maxOutcome ?? summedMax,
38
+ fromOutcomes: false,
39
+ };
40
+ }
package/src/runtime.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from "react";
21
21
  import type { ZodType } from "zod";
22
22
 
23
- import type { CapabilityIssue, CapabilityReport } from "./capability";
23
+ import type { CapabilityReport } from "./capability";
24
24
  import {
25
25
  isAllowedFlowElement,
26
26
  sanitizeAttributes,
@@ -28,9 +28,9 @@ import {
28
28
  v0ContentModel,
29
29
  type ContentModel,
30
30
  } from "./content-model";
31
+ import { reportItemCapability } from "./item-capability";
31
32
  import { resolveCatalogSupports, type CatalogView, type PnpView, type ResolvedCatalogSupport } from "./pnp";
32
33
  import { collectInteractionConstraints } from "./response-validity";
33
- import { collectRpIssues, collectTemplateIssues } from "./rp";
34
34
  import type {
35
35
  CustomOperatorImplementation,
36
36
  OutcomeDeclarationView,
@@ -391,9 +391,6 @@ function templateVisible(value: OutcomeValue, view: TemplateContentView): boolea
391
391
  return matched !== (view.showHide === "hide");
392
392
  }
393
393
 
394
- /** Body node kinds that render without a descriptor, skin, or content-model entry. */
395
- const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
396
-
397
394
  /** A read-only, already-"submitted" store: backs content rendered outside an attempt. */
398
395
  function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): AttemptStore {
399
396
  const snapshot: AttemptSnapshot = {
@@ -1015,125 +1012,22 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
1015
1012
  }
1016
1013
 
1017
1014
  function canDeliver(item: AssessmentItemView): CapabilityReport {
1018
- const issues: CapabilityIssue[] = [];
1019
- const seen = new Set<string>();
1020
-
1021
- function report(issue: CapabilityIssue): void {
1022
- const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
1023
-
1024
- if (!seen.has(dedupeKey)) {
1025
- seen.add(dedupeKey);
1026
- issues.push(issue);
1027
- }
1028
- }
1029
-
1030
- function walk(node: BodyNode): void {
1031
- if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
1032
- for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
1033
- walk(child);
1034
- }
1035
-
1036
- return;
1037
- }
1038
-
1039
- if (isInteractionNode(node)) {
1040
- const descriptor = descriptorsByKind.get(node.kind);
1041
-
1042
- if (!descriptor || !config.skin[node.kind]) {
1043
- report({
1044
- type: "unsupported-interaction",
1045
- name: node.kind,
1046
- responseIdentifier: node.responseIdentifier,
1047
- });
1048
-
1049
- return;
1050
- }
1051
-
1052
- const parsed = descriptor.schema.safeParse(node);
1053
-
1054
- if (!parsed.success) {
1055
- const detail = parsed.error.issues[0]?.message;
1056
-
1057
- report({
1058
- type: "invalid-interaction",
1059
- name: node.kind,
1060
- responseIdentifier: node.responseIdentifier,
1061
- ...(detail !== undefined ? { detail } : {}),
1062
- });
1063
- }
1064
-
1065
- // Interaction-internal content (prompt, choice bodies) is structurally
1066
- // validated by the descriptor schema; its flow elements are walked when the
1067
- // descriptor surfaces them. Generic field-sniffing is deliberately avoided.
1068
- return;
1069
- }
1070
-
1071
- if (node.kind === "xml") {
1072
- const xmlNode = node as XmlContentNode;
1073
-
1074
- if (xmlNode.name === model.mathRoot) {
1075
- return; // MathML renders structurally; its subtree is not flow content
1076
- }
1077
-
1078
- if (!isAllowedFlowElement(model, xmlNode.name)) {
1079
- report({ type: "unsupported-element", name: xmlNode.name });
1080
- }
1081
-
1082
- for (const child of xmlNode.children ?? []) {
1083
- walk(child);
1084
- }
1085
-
1086
- return;
1087
- }
1088
-
1089
- if (intrinsicLeafKinds.has(node.kind)) {
1090
- return;
1091
- }
1092
-
1093
- // Any other kind (include, multi-stage groups, future vocabulary) has no
1094
- // rendering path: report it rather than let the renderer drop it (ADR-0003).
1095
- report({ type: "unsupported-element", name: node.kind });
1096
- }
1097
-
1098
- for (const node of item.itemBody.content ?? []) {
1099
- walk(node);
1100
- }
1101
-
1102
- // Shared stimulus refs must resolve to be deliverable; resolved content passes
1103
- // through the same content-model gate as the body.
1104
- for (const ref of item.assessmentStimulusRefs ?? []) {
1105
- const stimulus = config.resolveStimulus?.(ref) ?? null;
1106
-
1107
- if (stimulus === null) {
1108
- report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
1109
- continue;
1110
- }
1111
-
1112
- for (const node of stimulus.content) {
1113
- walk(node);
1114
- }
1115
- }
1116
-
1117
- for (const feedback of item.modalFeedbacks ?? []) {
1118
- for (const child of feedback.content ?? []) {
1119
- walk(child);
1120
- }
1121
- }
1122
-
1123
- const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
1124
-
1125
- for (const issue of collectRpIssues(item.responseProcessing, {
1126
- customOperatorClasses,
1127
- outcomeDeclarations: item.outcomeDeclarations ?? [],
1128
- })) {
1129
- report(issue);
1130
- }
1131
-
1132
- for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
1133
- report(issue);
1134
- }
1015
+ // Reduce this runtime's React config to the headless capability inputs: an interaction
1016
+ // is supported when it has both a descriptor and a skin; descriptor schemas drive the
1017
+ // stricter invalid-interaction check. The walk itself lives in ./item-capability so a
1018
+ // server-side caller reaches the same verdict without importing React.
1019
+ const supportedInteractions = new Set([...descriptorsByKind.keys()].filter((kind) => Boolean(config.skin[kind])));
1020
+ const interactionSchemas = new Map(
1021
+ [...descriptorsByKind].map(([kind, descriptor]) => [kind, descriptor.schema] as const),
1022
+ );
1135
1023
 
1136
- return { deliverable: issues.length === 0, issues };
1024
+ return reportItemCapability(item, {
1025
+ supportedInteractions,
1026
+ interactionSchemas,
1027
+ model,
1028
+ customOperatorClasses: new Set(Object.keys(config.customOperators ?? {})),
1029
+ ...(config.resolveStimulus !== undefined ? { resolveStimulus: config.resolveStimulus } : {}),
1030
+ });
1137
1031
  }
1138
1032
 
1139
1033
  return { ItemRenderer, ContentRenderer, useAttempt, useCatalogSupports, canDeliver };