@conform-ed/qti-react 0.0.13 → 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 (99) 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 +149 -159
  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 +6 -6
  69. package/src/interactions/associate.ts +2 -2
  70. package/src/interactions/choice.ts +2 -2
  71. package/src/interactions/drawing.ts +2 -2
  72. package/src/interactions/end-attempt.ts +2 -2
  73. package/src/interactions/extended-text.ts +2 -2
  74. package/src/interactions/gap-match.ts +2 -2
  75. package/src/interactions/graphic.ts +7 -7
  76. package/src/interactions/hottext.ts +2 -2
  77. package/src/interactions/index.ts +0 -1
  78. package/src/interactions/inline-choice.ts +2 -2
  79. package/src/interactions/match.ts +2 -2
  80. package/src/interactions/media.ts +2 -2
  81. package/src/interactions/order.ts +2 -2
  82. package/src/interactions/slider.ts +2 -2
  83. package/src/interactions/text-entry.ts +2 -2
  84. package/src/interactions/upload.ts +2 -2
  85. package/src/normalized-item.ts +6 -4
  86. package/src/pci/interaction.ts +2 -2
  87. package/src/pci/mount.ts +1 -2
  88. package/src/pci/skin.ts +0 -1
  89. package/src/reference-skin/drawing.ts +25 -15
  90. package/src/reference-skin/index.ts +2 -3
  91. package/src/rp/evaluate.ts +22 -23
  92. package/src/rp/interpreter.ts +9 -6
  93. package/src/rp/template-processing.ts +8 -13
  94. package/src/rp/types.ts +12 -6
  95. package/src/rp/values.ts +19 -6
  96. package/src/runtime.ts +9 -7
  97. package/src/store.ts +14 -8
  98. package/src/test/controller.ts +10 -7
  99. package/src/test/session-store.ts +0 -1
package/src/pci/mount.ts CHANGED
@@ -8,7 +8,6 @@
8
8
 
9
9
  import type { BodyNode } from "../runtime";
10
10
  import type { ResponseDeclarationView, ResponseValue } from "../types";
11
-
12
11
  import { serializePciMarkup } from "./markup";
13
12
  import type { PciConfiguration, PciInstance, PciModule, PciModuleRegistry } from "./registry";
14
13
  import { pciResponseToValue, valueToPciResponse } from "./response";
@@ -110,7 +109,7 @@ export async function mountPci(options: PciMountOptions): Promise<PciMountHandle
110
109
  : {}),
111
110
  status: "interacting",
112
111
  onready: (instance) => resolveReady(instance),
113
- ondone: (instance, response, state) => options.ondone?.(pciResponseToValue(response), state),
112
+ ondone: (_instance, response, state) => options.ondone?.(pciResponseToValue(response), state),
114
113
  };
115
114
 
116
115
  // The spec delivers the instance via onready; implementations commonly also return
package/src/pci/skin.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  import { createElement, useEffect, useRef, useState, type ReactNode } from "react";
9
9
 
10
10
  import type { InteractionRenderProps, InteractionSkin } from "../runtime";
11
-
12
11
  import { mountPci, type PciInteractionNode, type PciMountHandle } from "./mount";
13
12
  import type { PciModuleRegistry } from "./registry";
14
13
 
@@ -5,10 +5,16 @@
5
5
  * Items using drawing are typically scored externally, not by client RP.
6
6
  */
7
7
 
8
- import { createElement, useEffect, useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
8
+ import {
9
+ createElement,
10
+ useCallback,
11
+ useEffect,
12
+ useRef,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type ReactNode,
15
+ } from "react";
9
16
 
10
17
  import type { BodyNode, InteractionRenderProps } from "../runtime";
11
-
12
18
  import type { ObjectView } from "./graphic-base";
13
19
 
14
20
  interface DrawingNodeView {
@@ -30,28 +36,32 @@ export function DrawingReferenceSkin(props: InteractionRenderProps): ReactNode {
30
36
  propsRef.current = props;
31
37
 
32
38
  // Paint the stage image as the drawing background (and again after a clear).
33
- const paintBackground = (canvas: HTMLCanvasElement): void => {
34
- const context = canvas.getContext("2d");
39
+ const stageData = node.object.data;
40
+ const paintBackground = useCallback(
41
+ (canvas: HTMLCanvasElement): void => {
42
+ const context = canvas.getContext("2d");
35
43
 
36
- if (!context) {
37
- return;
38
- }
44
+ if (!context) {
45
+ return;
46
+ }
39
47
 
40
- context.clearRect(0, 0, canvas.width, canvas.height);
48
+ context.clearRect(0, 0, canvas.width, canvas.height);
41
49
 
42
- const image = new Image();
50
+ const image = new Image();
43
51
 
44
- image.onload = () => {
45
- context.drawImage(image, 0, 0, canvas.width, canvas.height);
46
- };
47
- image.src = propsRef.current.resolveAsset(node.object.data);
48
- };
52
+ image.onload = () => {
53
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
54
+ };
55
+ image.src = propsRef.current.resolveAsset(stageData);
56
+ },
57
+ [stageData],
58
+ );
49
59
 
50
60
  useEffect(() => {
51
61
  if (canvasRef.current) {
52
62
  paintBackground(canvasRef.current);
53
63
  }
54
- }, [node.object.data]);
64
+ }, [paintBackground]);
55
65
 
56
66
  const pointerPosition = (event: ReactPointerEvent<HTMLCanvasElement>): { x: number; y: number } => {
57
67
  const canvas = event.currentTarget;
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import type { SkinRegistry } from "../runtime";
9
-
10
9
  import { AssociateReferenceSkin } from "./associate";
11
10
  import { ChoiceReferenceSkin } from "./choice";
12
11
  import { DrawingReferenceSkin } from "./drawing";
@@ -17,13 +16,13 @@ import { GraphicAssociateReferenceSkin } from "./graphic-associate";
17
16
  import { GraphicGapMatchReferenceSkin } from "./graphic-gap-match";
18
17
  import { GraphicOrderReferenceSkin } from "./graphic-order";
19
18
  import { HotspotReferenceSkin } from "./hotspot";
20
- import { PositionObjectReferenceSkin } from "./position-object";
21
- import { SelectPointReferenceSkin } from "./select-point";
22
19
  import { HottextReferenceSkin } from "./hottext";
23
20
  import { InlineChoiceReferenceSkin } from "./inline-choice";
24
21
  import { MatchReferenceSkin } from "./match";
25
22
  import { MediaReferenceSkin } from "./media";
26
23
  import { OrderReferenceSkin } from "./order";
24
+ import { PositionObjectReferenceSkin } from "./position-object";
25
+ import { SelectPointReferenceSkin } from "./select-point";
27
26
  import { SliderReferenceSkin } from "./slider";
28
27
  import { TextEntryReferenceSkin } from "./text-entry";
29
28
  import { UploadReferenceSkin } from "./upload";
@@ -9,12 +9,12 @@
9
9
  import { parseCoords, parsePoint, pointInShape } from "../graphic";
10
10
  import { mapResponse, mapResponsePoint } from "../response-processing";
11
11
  import type { ResponseDeclarationView, ResponseValue } from "../types";
12
-
13
12
  import type { CustomOperatorImplementation, ResponseNormalization, RpExpressionView } from "./types";
14
13
  import {
15
14
  booleanValue,
16
15
  coerceScalar,
17
16
  floatValue,
17
+ rpValue,
18
18
  scalarsEqual,
19
19
  singleBoolean,
20
20
  singleNumber,
@@ -27,19 +27,19 @@ export interface EvalEnv {
27
27
  readonly lookupVariable: (identifier: string) => MaybeRpValue;
28
28
  readonly responseDeclaration: (identifier: string) => ResponseDeclarationView | undefined;
29
29
  readonly responseValue: (identifier: string) => ResponseValue;
30
- readonly normalization?: ResponseNormalization;
30
+ readonly normalization?: ResponseNormalization | undefined;
31
31
  /** Seeded PRNG in [0, 1); present only in template processing. */
32
- readonly random?: () => number;
32
+ readonly random?: (() => number) | undefined;
33
33
  /** `testVariables` aggregation; present only in test-level outcome processing. */
34
34
  readonly testVariables?: (expression: RpExpressionView) => MaybeRpValue;
35
35
  /** The `number*` item-session aggregates; present only in test-level outcome processing. */
36
36
  readonly testAggregate?: (expression: RpExpressionView) => MaybeRpValue;
37
37
  /** Registered vendor operators by class; unregistered classes stay unsupported. */
38
- readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
38
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
39
39
  }
40
40
 
41
41
  /** Expression kinds legal everywhere (deterministic). */
42
- export const deterministicExpressionKinds = new Set([
42
+ export const deterministicExpressionKinds: ReadonlySet<string> = new Set([
43
43
  "and",
44
44
  "baseValue",
45
45
  "correct",
@@ -86,11 +86,14 @@ export const deterministicExpressionKinds = new Set([
86
86
  ]);
87
87
 
88
88
  /** Expression kinds requiring the seeded PRNG (template processing only). */
89
- export const randomExpressionKinds = new Set(["random", "randomFloat", "randomInteger"]);
89
+ export const randomExpressionKinds: ReadonlySet<string> = new Set(["random", "randomFloat", "randomInteger"]);
90
90
 
91
91
  export class RpUnsupportedError extends Error {
92
- constructor(readonly kindName: string) {
92
+ readonly kindName: string;
93
+
94
+ constructor(kindName: string) {
93
95
  super(`Unsupported response-processing construct: ${kindName}`);
96
+ this.kindName = kindName;
94
97
  }
95
98
  }
96
99
 
@@ -190,7 +193,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
190
193
  const baseType = expression.baseType;
191
194
  const value = expression.value;
192
195
 
193
- return value === undefined ? null : { cardinality: "single", baseType, values: [coerceScalar(value, baseType)] };
196
+ return value === undefined ? null : rpValue("single", [coerceScalar(value, baseType)], baseType);
194
197
  }
195
198
 
196
199
  case "variable":
@@ -203,11 +206,11 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
203
206
  return null;
204
207
  }
205
208
 
206
- return {
207
- cardinality: declaration.cardinality,
208
- baseType: declaration.baseType,
209
- values: declaration.correctResponse.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
210
- };
209
+ return rpValue(
210
+ declaration.cardinality,
211
+ declaration.correctResponse.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
212
+ declaration.baseType,
213
+ );
211
214
  }
212
215
 
213
216
  case "mapResponse": {
@@ -260,9 +263,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
260
263
  const value = operand === undefined ? null : evaluate(operand);
261
264
  const field = value?.fields?.find((entry) => entry.name === expression.fieldIdentifier);
262
265
 
263
- return field === undefined
264
- ? null
265
- : { cardinality: "single", ...(field.baseType ? { baseType: field.baseType } : {}), values: [field.value] };
266
+ return field === undefined ? null : rpValue("single", [field.value], field.baseType);
266
267
  }
267
268
 
268
269
  case "and":
@@ -392,7 +393,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
392
393
  return null; // out of range is null, per spec
393
394
  }
394
395
 
395
- return { cardinality: "single", baseType: container.baseType, values: [member] };
396
+ return rpValue("single", [member], container.baseType);
396
397
  }
397
398
 
398
399
  case "mathConstant": {
@@ -603,9 +604,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
603
604
  const remaining = container.values.filter((member) => !scalarsEqual(member, scalar, baseType, env.normalization));
604
605
 
605
606
  // An empty container is NULL, per the QTI value model.
606
- return remaining.length === 0
607
- ? null
608
- : { cardinality: container.cardinality, baseType: container.baseType, values: remaining };
607
+ return remaining.length === 0 ? null : rpValue(container.cardinality, remaining, container.baseType);
609
608
  }
610
609
 
611
610
  case "repeat": {
@@ -633,7 +632,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
633
632
  }
634
633
  }
635
634
 
636
- return members.length === 0 ? null : { cardinality: "ordered", baseType, values: members };
635
+ return members.length === 0 ? null : rpValue("ordered", members, baseType);
637
636
  }
638
637
 
639
638
  case "stringMatch":
@@ -728,7 +727,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
728
727
  return null;
729
728
  }
730
729
 
731
- return { cardinality: expression.kind, baseType, values: members };
730
+ return rpValue(expression.kind, members, baseType);
732
731
  }
733
732
 
734
733
  case "randomInteger": {
@@ -798,7 +797,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
798
797
 
799
798
  const pick = container.values[Math.floor(env.random() * container.values.length)]!;
800
799
 
801
- return { cardinality: "single", baseType: container.baseType, values: [pick] };
800
+ return rpValue("single", [pick], container.baseType);
802
801
  }
803
802
 
804
803
  default:
@@ -7,7 +7,6 @@
7
7
 
8
8
  import type { CapabilityIssue } from "../capability";
9
9
  import type { ResponseDeclarationView } from "../types";
10
-
11
10
  import {
12
11
  RpUnsupportedError,
13
12
  collectExpressionIssues,
@@ -30,6 +29,7 @@ import {
30
29
  fromFlatValue,
31
30
  fromResponse,
32
31
  isNumericBaseType,
32
+ rpValue,
33
33
  singleBoolean,
34
34
  toOutcomeValue,
35
35
  type MaybeRpValue,
@@ -50,11 +50,14 @@ function defaultOutcomes(declarations: readonly OutcomeDeclarationView[]): Map<s
50
50
 
51
51
  for (const declaration of declarations) {
52
52
  if (declaration.defaultValue) {
53
- outcomes.set(declaration.identifier, {
54
- cardinality: declaration.cardinality,
55
- baseType: declaration.baseType,
56
- values: declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
57
- });
53
+ outcomes.set(
54
+ declaration.identifier,
55
+ rpValue(
56
+ declaration.cardinality,
57
+ declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
58
+ declaration.baseType,
59
+ ),
60
+ );
58
61
  continue;
59
62
  }
60
63
 
@@ -8,7 +8,6 @@
8
8
 
9
9
  import type { CapabilityIssue } from "../capability";
10
10
  import type { CorrectResponseView, ResponseDeclarationView } from "../types";
11
-
12
11
  import {
13
12
  RpUnsupportedError,
14
13
  collectExpressionIssues,
@@ -18,7 +17,7 @@ import {
18
17
  type EvalEnv,
19
18
  } from "./evaluate";
20
19
  import type { CustomOperatorImplementation, OutcomeValue, RpExpressionView, TemplateDeclarationView } from "./types";
21
- import { coerceScalar, singleBoolean, toOutcomeValue, type MaybeRpValue } from "./values";
20
+ import { coerceScalar, rpValue, singleBoolean, toOutcomeValue, type MaybeRpValue } from "./values";
22
21
 
23
22
  export interface TemplateConditionBranch {
24
23
  readonly expression: RpExpressionView;
@@ -43,7 +42,7 @@ export interface TemplateProcessingContext {
43
42
  readonly responseDeclarations: readonly ResponseDeclarationView[];
44
43
  readonly seed: number;
45
44
  /** Registered vendor `customOperator` implementations by class (opt-in). */
46
- readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
45
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
47
46
  }
48
47
 
49
48
  export interface TemplateProcessingResult {
@@ -101,11 +100,11 @@ export function executeTemplateProcessing(
101
100
  values.set(
102
101
  declaration.identifier,
103
102
  declaration.defaultValue
104
- ? {
105
- cardinality: declaration.cardinality,
106
- baseType: declaration.baseType,
107
- values: declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
108
- }
103
+ ? rpValue(
104
+ declaration.cardinality,
105
+ declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
106
+ declaration.baseType,
107
+ )
109
108
  : null,
110
109
  );
111
110
  }
@@ -153,11 +152,7 @@ export function executeTemplateProcessing(
153
152
  rule.identifier,
154
153
  value === null || !declaration
155
154
  ? value
156
- : {
157
- cardinality: declaration.cardinality,
158
- baseType: declaration.baseType ?? value.baseType,
159
- values: value.values,
160
- },
155
+ : rpValue(declaration.cardinality, value.values, declaration.baseType ?? value.baseType),
161
156
  );
162
157
  }
163
158
  continue;
package/src/rp/types.ts CHANGED
@@ -134,24 +134,30 @@ export interface TemplateDeclarationView {
134
134
  readonly defaultValue?: { readonly values: ReadonlyArray<{ readonly value: RpScalar }> };
135
135
  }
136
136
 
137
+ /**
138
+ * Consumer-supplied input. Optional members deliberately admit explicit
139
+ * `undefined` ("undefined means not provided"): callers pass maybe-undefined
140
+ * values straight through, and the interpreter reads every member field-wise
141
+ * (`??`/`?.`) — option bags are never spread-merged over defaults.
142
+ */
137
143
  export interface ResponseProcessingContext {
138
144
  readonly responseDeclarations: readonly ResponseDeclarationView[];
139
145
  readonly outcomeDeclarations: readonly OutcomeDeclarationView[];
140
146
  readonly responses: Readonly<Record<string, ResponseValue>>;
141
- readonly normalization?: ResponseNormalization;
147
+ readonly normalization?: ResponseNormalization | undefined;
142
148
  /** Template variables for this clone (read by `variable` lookups). */
143
- readonly templateDeclarations?: readonly TemplateDeclarationView[];
144
- readonly templateValues?: Readonly<Record<string, OutcomeValue>>;
149
+ readonly templateDeclarations?: readonly TemplateDeclarationView[] | undefined;
150
+ readonly templateValues?: Readonly<Record<string, OutcomeValue>> | undefined;
145
151
  /** Adaptive carry-over: outcome values from earlier attempts replace declared defaults. */
146
- readonly priorOutcomes?: Readonly<Record<string, OutcomeValue>>;
152
+ readonly priorOutcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
147
153
  /**
148
154
  * Random source for `random`/`randomInteger`/`randomFloat` in RP (adaptive items,
149
155
  * e.g. Monty Hall door reveals). Seed it from the attempt seed: the seed plus the
150
156
  * submission sequence then replays the exact same outcomes (ADR-0004 determinism).
151
157
  */
152
- readonly random?: () => number;
158
+ readonly random?: (() => number) | undefined;
153
159
  /** Registered vendor `customOperator` implementations by class (opt-in). */
154
- readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
160
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
155
161
  }
156
162
 
157
163
  export interface ResponseProcessingResult {
package/src/rp/values.ts CHANGED
@@ -61,11 +61,11 @@ export function fromResponse(declaration: ResponseDeclarationView, response: Res
61
61
  return null;
62
62
  }
63
63
 
64
- return {
65
- cardinality: declaration.cardinality,
66
- baseType: declaration.baseType,
67
- values: raw.map((value) => coerceScalar(value, declaration.baseType)),
68
- };
64
+ return rpValue(
65
+ declaration.cardinality,
66
+ raw.map((value) => coerceScalar(value, declaration.baseType)),
67
+ declaration.baseType,
68
+ );
69
69
  }
70
70
 
71
71
  export function singleNumber(value: MaybeRpValue): number | null {
@@ -88,6 +88,15 @@ export function singleBoolean(value: MaybeRpValue): boolean | null {
88
88
  return typeof member === "boolean" ? member : null;
89
89
  }
90
90
 
91
+ /** Construct a typed value; an unknown baseType is omitted, never stored as undefined. */
92
+ export function rpValue(cardinality: Cardinality, values: readonly RpScalar[], baseType?: string): RpValue {
93
+ return {
94
+ cardinality,
95
+ values,
96
+ ...(baseType !== undefined ? { baseType } : {}),
97
+ };
98
+ }
99
+
91
100
  export function booleanValue(value: boolean): RpValue {
92
101
  return { cardinality: "single", baseType: "boolean", values: [value] };
93
102
  }
@@ -182,7 +191,11 @@ export function fromFlatValue(value: OutcomeValue, cardinality: Cardinality, bas
182
191
  return null;
183
192
  }
184
193
 
185
- return { cardinality, baseType, values: values.map((member) => coerceScalar(member, baseType)) };
194
+ return rpValue(
195
+ cardinality,
196
+ values.map((member) => coerceScalar(member, baseType)),
197
+ baseType,
198
+ );
186
199
  }
187
200
 
188
201
  export function toOutcomeValue(value: MaybeRpValue): OutcomeValue {
package/src/runtime.ts CHANGED
@@ -175,9 +175,9 @@ export interface ItemRendererProps {
175
175
  * per-mount store; passing one enables review/replay modes (rehydrate a stored,
176
176
  * already-submitted attempt) and server-side rendering of submitted states.
177
177
  */
178
- store?: AttemptStore;
178
+ store?: AttemptStore | undefined;
179
179
  /** Clone seed for template processing; store it to replay the same clone. */
180
- seed?: number;
180
+ seed?: number | undefined;
181
181
  // Rendered inside the same runtime context as the item body, after it. Lets a consumer
182
182
  // drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
183
183
  // the attempt store is per-item and scoped to this subtree.
@@ -185,9 +185,9 @@ export interface ItemRendererProps {
185
185
  }
186
186
 
187
187
  export interface ContentRendererProps {
188
- nodes?: readonly BodyNode[];
188
+ nodes?: readonly BodyNode[] | undefined;
189
189
  /** Values for printedVariable (and showHide-gated feedback) inside the content. */
190
- outcomes?: Readonly<Record<string, OutcomeValue>>;
190
+ outcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
191
191
  }
192
192
 
193
193
  export interface QtiRuntime {
@@ -437,7 +437,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
437
437
  }: {
438
438
  feedback: FeedbackView;
439
439
  element: "span" | "div";
440
- overrides?: NodeOverrides;
440
+ overrides?: NodeOverrides | undefined;
441
441
  }): ReactNode {
442
442
  const { store } = useRuntimeContext();
443
443
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
@@ -461,7 +461,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
461
461
  }: {
462
462
  view: TemplateContentView;
463
463
  element: "span" | "div";
464
- overrides?: NodeOverrides;
464
+ overrides?: NodeOverrides | undefined;
465
465
  }): ReactNode {
466
466
  const { store } = useRuntimeContext();
467
467
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
@@ -693,11 +693,13 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
693
693
  const parsed = descriptor.schema.safeParse(node);
694
694
 
695
695
  if (!parsed.success) {
696
+ const detail = parsed.error.issues[0]?.message;
697
+
696
698
  report({
697
699
  type: "invalid-interaction",
698
700
  name: node.kind,
699
701
  responseIdentifier: node.responseIdentifier,
700
- detail: parsed.error.issues[0]?.message,
702
+ ...(detail !== undefined ? { detail } : {}),
701
703
  });
702
704
  }
703
705
 
package/src/store.ts CHANGED
@@ -38,19 +38,25 @@ export interface AttemptSnapshot {
38
38
  readonly attemptCount: number;
39
39
  }
40
40
 
41
+ /**
42
+ * Consumer-supplied input. Optional members deliberately admit explicit
43
+ * `undefined` ("undefined means not provided"), so callers can pass
44
+ * maybe-undefined values straight through; the store reads every member
45
+ * field-wise (`??`/`?.`) and never spread-merges options over defaults.
46
+ */
41
47
  export interface AttemptStoreOptions {
42
- readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
43
- readonly responseProcessing?: ResponseProcessingView;
48
+ readonly outcomeDeclarations?: readonly OutcomeDeclarationView[] | undefined;
49
+ readonly responseProcessing?: ResponseProcessingView | undefined;
44
50
  /** The Response Normalization hook (ADR-0004); applies to scores and outcomes alike. */
45
- readonly normalization?: ResponseNormalization;
46
- readonly templateDeclarations?: readonly TemplateDeclarationView[];
47
- readonly templateProcessing?: TemplateProcessingView;
51
+ readonly normalization?: ResponseNormalization | undefined;
52
+ readonly templateDeclarations?: readonly TemplateDeclarationView[] | undefined;
53
+ readonly templateProcessing?: TemplateProcessingView | undefined;
48
54
  /** Clone seed for template processing; store it to replay the same clone. */
49
- readonly seed?: number;
55
+ readonly seed?: number | undefined;
50
56
  /** QTI adaptive item: multiple attempts, outcome carry-over, completionStatus lock. */
51
- readonly adaptive?: boolean;
57
+ readonly adaptive?: boolean | undefined;
52
58
  /** Registered vendor `customOperator` implementations by class (opt-in). */
53
- readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
59
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
54
60
  }
55
61
 
56
62
  export interface AttemptStore {
@@ -20,12 +20,12 @@ import {
20
20
  floatValue,
21
21
  fromFlatValue,
22
22
  isNumericBaseType,
23
+ rpValue,
23
24
  singleBoolean,
24
25
  toOutcomeValue,
25
26
  type MaybeRpValue,
26
27
  type RpValue,
27
28
  } from "../rp/values";
28
-
29
29
  import type {
30
30
  AssessmentItemRefView,
31
31
  AssessmentSectionView,
@@ -262,11 +262,14 @@ export function createTestController(view: AssessmentTestView, options: TestCont
262
262
 
263
263
  for (const declaration of view.outcomeDeclarations ?? []) {
264
264
  if (declaration.defaultValue) {
265
- outcomes.set(declaration.identifier, {
266
- cardinality: declaration.cardinality,
267
- baseType: declaration.baseType,
268
- values: declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
269
- });
265
+ outcomes.set(
266
+ declaration.identifier,
267
+ rpValue(
268
+ declaration.cardinality,
269
+ declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
270
+ declaration.baseType,
271
+ ),
272
+ );
270
273
  continue;
271
274
  }
272
275
 
@@ -331,7 +334,7 @@ export function createTestController(view: AssessmentTestView, options: TestCont
331
334
  members.push(...lifted.values);
332
335
  }
333
336
 
334
- return members.length === 0 ? null : { cardinality: "multiple", baseType, values: members };
337
+ return members.length === 0 ? null : rpValue("multiple", members, baseType);
335
338
  },
336
339
  testAggregate: (expression) => {
337
340
  const subset = subsetItems(expression);
@@ -12,7 +12,6 @@ import type { AssessmentItemView } from "../runtime";
12
12
  import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "../store";
13
13
  import { isResponseRecord } from "../types";
14
14
  import type { ResponseValue } from "../types";
15
-
16
15
  import type { AssessmentItemRefView, TestController, TestFeedbackView, TestPlanItem, TestSessionState } from "./types";
17
16
 
18
17
  export interface TestSessionStoreOptions {