@conform-ed/qti-react 0.0.12 → 0.0.14

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 (134) hide show
  1. package/dist/capability.d.ts +17 -0
  2. package/dist/content-model.d.ts +42 -0
  3. package/dist/graphic.d.ts +23 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.js +4556 -212
  6. package/dist/interactions/associate.d.ts +2 -0
  7. package/dist/interactions/choice.d.ts +2 -0
  8. package/dist/interactions/drawing.d.ts +2 -0
  9. package/dist/interactions/end-attempt.d.ts +2 -0
  10. package/dist/interactions/extended-text.d.ts +2 -0
  11. package/dist/interactions/gap-match.d.ts +2 -0
  12. package/dist/interactions/graphic.d.ts +13 -0
  13. package/dist/interactions/hottext.d.ts +2 -0
  14. package/dist/interactions/index.d.ts +18 -0
  15. package/dist/interactions/inline-choice.d.ts +2 -0
  16. package/dist/interactions/match.d.ts +2 -0
  17. package/dist/interactions/media.d.ts +2 -0
  18. package/dist/interactions/order.d.ts +2 -0
  19. package/dist/interactions/slider.d.ts +2 -0
  20. package/dist/interactions/text-entry.d.ts +2 -0
  21. package/dist/interactions/upload.d.ts +2 -0
  22. package/dist/normalized-item.d.ts +30 -0
  23. package/dist/pci/index.d.ts +6 -0
  24. package/dist/pci/interaction.d.ts +8 -0
  25. package/dist/pci/markup.d.ts +10 -0
  26. package/dist/pci/mount.d.ts +50 -0
  27. package/dist/pci/registry.d.ts +53 -0
  28. package/dist/pci/response.d.ts +11 -0
  29. package/dist/pci/skin.d.ts +12 -0
  30. package/dist/reference-skin/associate.d.ts +8 -0
  31. package/dist/reference-skin/choice.d.ts +8 -0
  32. package/dist/reference-skin/content.d.ts +6 -0
  33. package/dist/reference-skin/drawing.d.ts +9 -0
  34. package/dist/reference-skin/end-attempt.d.ts +7 -0
  35. package/dist/reference-skin/extended-text.d.ts +6 -0
  36. package/dist/reference-skin/gap-match.d.ts +8 -0
  37. package/dist/reference-skin/graphic-associate.d.ts +8 -0
  38. package/dist/reference-skin/graphic-base.d.ts +39 -0
  39. package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
  40. package/dist/reference-skin/graphic-order.d.ts +8 -0
  41. package/dist/reference-skin/hotspot.d.ts +8 -0
  42. package/dist/reference-skin/hottext.d.ts +8 -0
  43. package/dist/reference-skin/index.d.ts +30 -0
  44. package/dist/reference-skin/inline-choice.d.ts +7 -0
  45. package/dist/reference-skin/match.d.ts +8 -0
  46. package/dist/reference-skin/media.d.ts +9 -0
  47. package/dist/reference-skin/order.d.ts +8 -0
  48. package/dist/reference-skin/position-object.d.ts +9 -0
  49. package/dist/reference-skin/select-point.d.ts +8 -0
  50. package/dist/reference-skin/slider.d.ts +8 -0
  51. package/dist/reference-skin/text-entry.d.ts +6 -0
  52. package/dist/reference-skin/upload.d.ts +8 -0
  53. package/dist/response-processing.d.ts +48 -0
  54. package/dist/rp/evaluate.d.ts +35 -0
  55. package/dist/rp/index.d.ts +4 -0
  56. package/dist/rp/interpreter.d.ts +15 -0
  57. package/dist/rp/template-processing.d.ts +49 -0
  58. package/dist/rp/templates.d.ts +8 -0
  59. package/dist/rp/types.d.ts +158 -0
  60. package/dist/rp/values.d.ts +27 -0
  61. package/dist/runtime.d.ts +164 -0
  62. package/dist/store.d.ts +61 -0
  63. package/dist/test/controller.d.ts +11 -0
  64. package/dist/test/index.d.ts +3 -0
  65. package/dist/test/session-store.d.ts +46 -0
  66. package/dist/test/types.d.ts +194 -0
  67. package/dist/types.d.ts +58 -0
  68. package/package.json +8 -6
  69. package/src/capability.ts +24 -0
  70. package/src/content-model.ts +104 -5
  71. package/src/graphic.ts +103 -0
  72. package/src/index.ts +139 -3
  73. package/src/interactions/associate.ts +22 -0
  74. package/src/interactions/choice.ts +2 -2
  75. package/src/interactions/drawing.ts +24 -0
  76. package/src/interactions/end-attempt.ts +19 -0
  77. package/src/interactions/extended-text.ts +21 -0
  78. package/src/interactions/gap-match.ts +22 -0
  79. package/src/interactions/graphic.ts +104 -0
  80. package/src/interactions/hottext.ts +21 -0
  81. package/src/interactions/index.ts +57 -3
  82. package/src/interactions/inline-choice.ts +2 -2
  83. package/src/interactions/match.ts +27 -0
  84. package/src/interactions/media.ts +24 -0
  85. package/src/interactions/order.ts +21 -0
  86. package/src/interactions/slider.ts +24 -0
  87. package/src/interactions/text-entry.ts +2 -2
  88. package/src/interactions/upload.ts +19 -0
  89. package/src/normalized-item.ts +563 -0
  90. package/src/pci/index.ts +22 -0
  91. package/src/pci/interaction.ts +42 -0
  92. package/src/pci/markup.ts +102 -0
  93. package/src/pci/mount.ts +134 -0
  94. package/src/pci/registry.ts +240 -0
  95. package/src/pci/response.ts +138 -0
  96. package/src/pci/skin.ts +86 -0
  97. package/src/reference-skin/associate.ts +98 -0
  98. package/src/reference-skin/choice.ts +44 -0
  99. package/src/reference-skin/content.ts +30 -0
  100. package/src/reference-skin/drawing.ts +160 -0
  101. package/src/reference-skin/end-attempt.ts +27 -0
  102. package/src/reference-skin/extended-text.ts +35 -0
  103. package/src/reference-skin/gap-match.ts +69 -0
  104. package/src/reference-skin/graphic-associate.ts +123 -0
  105. package/src/reference-skin/graphic-base.ts +142 -0
  106. package/src/reference-skin/graphic-gap-match.ts +143 -0
  107. package/src/reference-skin/graphic-order.ts +76 -0
  108. package/src/reference-skin/hotspot.ts +43 -0
  109. package/src/reference-skin/hottext.ts +42 -0
  110. package/src/reference-skin/index.ts +74 -0
  111. package/src/reference-skin/inline-choice.ts +42 -0
  112. package/src/reference-skin/match.ts +80 -0
  113. package/src/reference-skin/media.ts +74 -0
  114. package/src/reference-skin/order.ts +79 -0
  115. package/src/reference-skin/position-object.ts +84 -0
  116. package/src/reference-skin/select-point.ts +87 -0
  117. package/src/reference-skin/slider.ts +41 -0
  118. package/src/reference-skin/text-entry.ts +31 -0
  119. package/src/reference-skin/upload.ts +46 -0
  120. package/src/response-processing.ts +178 -29
  121. package/src/rp/evaluate.ts +827 -0
  122. package/src/rp/index.ts +30 -0
  123. package/src/rp/interpreter.ts +254 -0
  124. package/src/rp/template-processing.ts +290 -0
  125. package/src/rp/templates.ts +190 -0
  126. package/src/rp/types.ts +167 -0
  127. package/src/rp/values.ts +211 -0
  128. package/src/runtime.ts +476 -28
  129. package/src/store.ts +161 -5
  130. package/src/test/controller.ts +809 -0
  131. package/src/test/index.ts +25 -0
  132. package/src/test/session-store.ts +243 -0
  133. package/src/test/types.ts +203 -0
  134. package/src/types.ts +27 -1
@@ -1,15 +1,23 @@
1
1
  /**
2
- * Client-side response processing for the v0 standard scoring templates.
3
- *
4
- * Implements `match_correct` and `map_response` (QTI's two standard RP templates),
5
- * which cover the v0 interactions (choice, textEntry, inlineChoice). Pure functions:
6
- * deterministic given (declaration, response), so scoring is replayable and runs
7
- * fully offline in the headless core (ADR-0003 / ADR-0006).
2
+ * Standard-template scoring helpers, spec-strict by default (ADR-0004). `match_correct`
3
+ * is an exact match and mapping entries default to caseSensitive=true, per spec. The
4
+ * optional `normalize` parameter is the Response Normalization hook: a consumer-
5
+ * configured transform applied to both sides of string comparisons (off by default,
6
+ * always off in conformance runs). The RP interpreter (`src/rp/`) reuses these for its
7
+ * `mapResponse` operator; `scoreResponse` also backs the per-interaction feedback
8
+ * chrome in the runtime. Pure functions: deterministic given (declaration, response),
9
+ * so scoring is replayable and runs fully offline in the headless core.
8
10
  */
9
11
 
12
+ import { parsePoint, pointInShape } from "./graphic";
13
+ import type { ResponseNormalization } from "./rp/types";
10
14
  import type { ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
11
15
 
12
- /** Lowercase + strip combining diacritics, for non-case/accent-sensitive comparison. */
16
+ /**
17
+ * Lowercase + strip combining diacritics. Exported as a ready-made Response
18
+ * Normalization for language-learning leniency ("cafe" ≈ "Café") — a documented
19
+ * deviation a consumer must opt into.
20
+ */
13
21
  export function foldString(value: string): string {
14
22
  return value
15
23
  .normalize("NFD")
@@ -22,30 +30,86 @@ function asList(response: ResponseValue): string[] {
22
30
  return [];
23
31
  }
24
32
 
25
- return typeof response === "string" ? [response] : [...response];
33
+ // Records are not list-shaped; heuristic scoring treats them as empty.
34
+ return typeof response === "string" ? [response] : Array.isArray(response) ? [...response] : [];
26
35
  }
27
36
 
28
37
  function isStringBaseType(declaration: ResponseDeclarationView): boolean {
29
38
  return declaration.baseType === "string" || declaration.baseType === undefined;
30
39
  }
31
40
 
32
- function valuesEqual(a: string, b: string, fold: boolean): boolean {
33
- return fold ? foldString(a) === foldString(b) : a === b;
41
+ /**
42
+ * Pair values serialize as two space-separated identifiers ("A B"). A `pair` is
43
+ * unordered within the pair ("A B" ≡ "B A"); a `directedPair` is ordered.
44
+ */
45
+ function pairsEqual(a: string, b: string, directed: boolean): boolean {
46
+ const [a1, a2] = a.trim().split(/\s+/u);
47
+ const [b1, b2] = b.trim().split(/\s+/u);
48
+
49
+ if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined) {
50
+ return false;
51
+ }
52
+
53
+ if (a1 === b1 && a2 === b2) {
54
+ return true;
55
+ }
56
+
57
+ return !directed && a1 === b2 && a2 === b1;
58
+ }
59
+
60
+ const numericBaseTypes = new Set(["float", "integer"]);
61
+
62
+ /** Value equality for one declared baseType, used by both match_correct and map_response. */
63
+ function makeValueComparator(
64
+ declaration: ResponseDeclarationView,
65
+ normalize?: ResponseNormalization,
66
+ ): (a: string, b: string) => boolean {
67
+ if (declaration.baseType === "pair") {
68
+ return (a, b) => pairsEqual(a, b, false);
69
+ }
70
+
71
+ if (declaration.baseType === "directedPair") {
72
+ return (a, b) => pairsEqual(a, b, true);
73
+ }
74
+
75
+ if (declaration.baseType !== undefined && numericBaseTypes.has(declaration.baseType)) {
76
+ return (a, b) => a.trim() !== "" && b.trim() !== "" && Number(a) === Number(b);
77
+ }
78
+
79
+ if (declaration.baseType === "point") {
80
+ return (a, b) => {
81
+ const pointA = parsePoint(a);
82
+ const pointB = parsePoint(b);
83
+
84
+ return pointA !== null && pointB !== null && pointA.x === pointB.x && pointA.y === pointB.y;
85
+ };
86
+ }
87
+
88
+ if (normalize && isStringBaseType(declaration)) {
89
+ return (a, b) => normalize(a, declaration) === normalize(b, declaration);
90
+ }
91
+
92
+ return (a, b) => a === b;
34
93
  }
35
94
 
36
95
  /**
37
96
  * `match_correct`: true when the response exactly matches `correctResponse`, respecting
38
- * cardinality. String base types fold case/diacritics (textEntry friendliness);
39
- * identifier base types compare exactly. Returns false when no correctResponse exists.
97
+ * cardinality and baseType. Spec-strict: no case or diacritic folding unless the
98
+ * consumer passes a Response Normalization. Returns false when no correctResponse
99
+ * exists.
40
100
  */
41
- export function matchCorrect(declaration: ResponseDeclarationView, response: ResponseValue): boolean {
101
+ export function matchCorrect(
102
+ declaration: ResponseDeclarationView,
103
+ response: ResponseValue,
104
+ normalize?: ResponseNormalization,
105
+ ): boolean {
42
106
  const correct = declaration.correctResponse;
43
107
 
44
108
  if (!correct) {
45
109
  return false;
46
110
  }
47
111
 
48
- const fold = isStringBaseType(declaration);
112
+ const equals = makeValueComparator(declaration, normalize);
49
113
  const expected = correct.values.map((entry) => entry.value);
50
114
  const actual = asList(response);
51
115
 
@@ -54,14 +118,14 @@ export function matchCorrect(declaration: ResponseDeclarationView, response: Res
54
118
  }
55
119
 
56
120
  if (declaration.cardinality === "ordered") {
57
- return expected.every((value, index) => valuesEqual(value, actual[index] ?? "", fold));
121
+ return expected.every((value, index) => equals(value, actual[index] ?? ""));
58
122
  }
59
123
 
60
124
  // single + multiple: order-independent set match
61
125
  const remaining = [...actual];
62
126
 
63
127
  for (const value of expected) {
64
- const matchIndex = remaining.findIndex((candidate) => valuesEqual(candidate, value, fold));
128
+ const matchIndex = remaining.findIndex((candidate) => equals(candidate, value));
65
129
 
66
130
  if (matchIndex === -1) {
67
131
  return false;
@@ -89,24 +153,48 @@ function clamp(value: number, lower: number | undefined, upper: number | undefin
89
153
 
90
154
  /**
91
155
  * `map_response`: sum the mapped values of the response's members, each member mapped at
92
- * most once. Honors per-entry `caseSensitive` (default: case/diacritic-insensitive),
93
- * applies the mapping's `defaultValue` to unmatched members, and clamps to
94
- * [lowerBound, upperBound]. Returns 0 when no mapping exists.
156
+ * most once. Per spec, entries default to caseSensitive=true; `caseSensitive: false`
157
+ * lowercases both sides. A Response Normalization, when configured, applies on top for
158
+ * string base types. Applies the mapping's `defaultValue` to unmatched members and
159
+ * clamps to [lowerBound, upperBound]. Returns 0 when no mapping exists.
95
160
  */
96
- export function mapResponse(declaration: ResponseDeclarationView, response: ResponseValue): number {
161
+ export function mapResponse(
162
+ declaration: ResponseDeclarationView,
163
+ response: ResponseValue,
164
+ normalize?: ResponseNormalization,
165
+ ): number {
97
166
  const mapping = declaration.mapping;
98
167
 
99
168
  if (!mapping) {
100
169
  return 0;
101
170
  }
102
171
 
172
+ const isPairType = declaration.baseType === "pair" || declaration.baseType === "directedPair";
173
+ const applyNormalize = normalize && isStringBaseType(declaration) ? normalize : undefined;
103
174
  const defaultValue = mapping.defaultValue ?? 0;
104
175
  let total = 0;
105
176
 
106
177
  for (const member of asList(response)) {
107
- const entry = mapping.mapEntries.find((candidate) =>
108
- valuesEqual(candidate.mapKey, member, !candidate.caseSensitive),
109
- );
178
+ const entry = mapping.mapEntries.find((candidate) => {
179
+ if (isPairType) {
180
+ return pairsEqual(candidate.mapKey, member, declaration.baseType === "directedPair");
181
+ }
182
+
183
+ let key = candidate.mapKey;
184
+ let candidateMember = member;
185
+
186
+ if (candidate.caseSensitive === false) {
187
+ key = key.toLocaleLowerCase();
188
+ candidateMember = candidateMember.toLocaleLowerCase();
189
+ }
190
+
191
+ if (applyNormalize) {
192
+ key = applyNormalize(key, declaration);
193
+ candidateMember = applyNormalize(candidateMember, declaration);
194
+ }
195
+
196
+ return key === candidateMember;
197
+ });
110
198
 
111
199
  total += entry ? entry.mappedValue : defaultValue;
112
200
  }
@@ -115,13 +203,74 @@ export function mapResponse(declaration: ResponseDeclarationView, response: Resp
115
203
  }
116
204
 
117
205
  /**
118
- * Apply the appropriate standard template: `map_response` when a mapping is declared,
119
- * otherwise `match_correct`. `maxScore` is the mapping upper bound (or the sum of
120
- * positive mapped values) for mapped items, else 1 for match_correct.
206
+ * `map_response_point`: sum the mapped values of the areas hit by the response's point
207
+ * members. Per spec each area counts at most once regardless of how many points land in
208
+ * it; points hitting no area add the mapping's `defaultValue`. Clamps to
209
+ * [lowerBound, upperBound]. Returns 0 when no areaMapping exists.
121
210
  */
122
- export function scoreResponse(declaration: ResponseDeclarationView, response: ResponseValue): ScoreResult {
211
+ export function mapResponsePoint(declaration: ResponseDeclarationView, response: ResponseValue): number {
212
+ const areaMapping = declaration.areaMapping;
213
+
214
+ if (!areaMapping) {
215
+ return 0;
216
+ }
217
+
218
+ const defaultValue = areaMapping.defaultValue ?? 0;
219
+ const usedAreas = new Set<number>();
220
+ let total = 0;
221
+
222
+ for (const member of asList(response)) {
223
+ const point = parsePoint(member);
224
+
225
+ if (!point) {
226
+ continue;
227
+ }
228
+
229
+ const areaIndex = areaMapping.areaMapEntries.findIndex(
230
+ (entry, index) => !usedAreas.has(index) && pointInShape(entry.shape, entry.coords, point),
231
+ );
232
+
233
+ if (areaIndex === -1) {
234
+ total += defaultValue;
235
+ } else {
236
+ usedAreas.add(areaIndex);
237
+ total += areaMapping.areaMapEntries[areaIndex]!.mappedValue;
238
+ }
239
+ }
240
+
241
+ return clamp(total, areaMapping.lowerBound, areaMapping.upperBound);
242
+ }
243
+
244
+ /**
245
+ * Apply the appropriate standard template: `map_response_point` when an areaMapping is
246
+ * declared, `map_response` when a mapping is declared, otherwise `match_correct`. `maxScore` is the mapping upper bound (or the sum of
247
+ * positive mapped values) for mapped items, else 1 for match_correct. This heuristic
248
+ * backs the per-interaction feedback chrome; item outcomes of record come from the RP
249
+ * interpreter when the item declares `responseProcessing`.
250
+ */
251
+ export function scoreResponse(
252
+ declaration: ResponseDeclarationView,
253
+ response: ResponseValue,
254
+ normalize?: ResponseNormalization,
255
+ ): ScoreResult {
256
+ if (declaration.areaMapping) {
257
+ const score = mapResponsePoint(declaration, response);
258
+ const positiveSum = declaration.areaMapping.areaMapEntries.reduce(
259
+ (sum, entry) => sum + Math.max(entry.mappedValue, 0),
260
+ 0,
261
+ );
262
+ const maxScore = declaration.areaMapping.upperBound ?? positiveSum;
263
+
264
+ return {
265
+ identifier: declaration.identifier,
266
+ score,
267
+ maxScore,
268
+ correct: maxScore > 0 && score >= maxScore,
269
+ };
270
+ }
271
+
123
272
  if (declaration.mapping) {
124
- const score = mapResponse(declaration, response);
273
+ const score = mapResponse(declaration, response, normalize);
125
274
  const positiveSum = declaration.mapping.mapEntries.reduce((sum, entry) => sum + Math.max(entry.mappedValue, 0), 0);
126
275
  const maxScore = declaration.mapping.upperBound ?? positiveSum;
127
276
 
@@ -133,7 +282,7 @@ export function scoreResponse(declaration: ResponseDeclarationView, response: Re
133
282
  };
134
283
  }
135
284
 
136
- const correct = matchCorrect(declaration, response);
285
+ const correct = matchCorrect(declaration, response, normalize);
137
286
 
138
287
  return {
139
288
  identifier: declaration.identifier,