@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.
- package/dist/headless.d.ts +25 -0
- package/dist/headless.js +4804 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +160 -96
- package/dist/item-capability.d.ts +43 -0
- package/dist/item-score.d.ts +17 -0
- package/package.json +10 -5
- package/src/headless.ts +58 -0
- package/src/index.ts +4 -0
- package/src/item-capability.ts +211 -0
- package/src/item-score.ts +40 -0
- package/src/runtime.ts +17 -123
|
@@ -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 {
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
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 };
|