@conform-ed/qti-react 0.0.14 → 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.
Files changed (42) hide show
  1. package/dist/index.d.ts +6 -4
  2. package/dist/index.js +2492 -408
  3. package/dist/normalized-item.d.ts +7 -5
  4. package/dist/pnp.d.ts +115 -0
  5. package/dist/response-validity.d.ts +28 -0
  6. package/dist/rp/evaluate.d.ts +6 -1
  7. package/dist/rp/index.d.ts +2 -2
  8. package/dist/rp/interpreter.d.ts +7 -1
  9. package/dist/rp/lookup-table.d.ts +17 -0
  10. package/dist/rp/template-processing.d.ts +5 -0
  11. package/dist/rp/types.d.ts +71 -7
  12. package/dist/runtime.d.ts +95 -0
  13. package/dist/store.d.ts +47 -0
  14. package/dist/test/controller.d.ts +22 -0
  15. package/dist/test/index.d.ts +2 -1
  16. package/dist/test/results.d.ts +102 -0
  17. package/dist/test/session-store.d.ts +32 -0
  18. package/dist/test/types.d.ts +173 -5
  19. package/dist/types.d.ts +5 -0
  20. package/package.json +5 -1
  21. package/src/content-model.ts +44 -4
  22. package/src/index.ts +43 -1
  23. package/src/normalized-item.ts +106 -4
  24. package/src/pci/mount.ts +11 -3
  25. package/src/pnp.ts +333 -0
  26. package/src/reference-skin/choice.ts +3 -0
  27. package/src/response-validity.ts +163 -0
  28. package/src/rp/evaluate.ts +280 -32
  29. package/src/rp/index.ts +5 -0
  30. package/src/rp/interpreter.ts +81 -1
  31. package/src/rp/lookup-table.ts +46 -0
  32. package/src/rp/template-processing.ts +41 -0
  33. package/src/rp/types.ts +75 -7
  34. package/src/runtime.ts +397 -20
  35. package/src/store.ts +146 -8
  36. package/src/test/controller.ts +856 -82
  37. package/src/test/index.ts +23 -0
  38. package/src/test/results.ts +378 -0
  39. package/src/test/session-store.ts +109 -1
  40. package/src/test/types.ts +172 -5
  41. package/src/types.ts +1 -0
  42. package/src/xspattern.d.ts +11 -0
@@ -14,6 +14,7 @@ import {
14
14
  evaluateExpression,
15
15
  type EvalEnv,
16
16
  } from "./evaluate";
17
+ import { hasLookupTable, lookupTableValue } from "./lookup-table";
17
18
  import { resolveTemplate } from "./templates";
18
19
  import type {
19
20
  OutcomeDeclarationView,
@@ -35,7 +36,13 @@ import {
35
36
  type MaybeRpValue,
36
37
  } from "./values";
37
38
 
38
- const supportedRuleKinds = new Set(["responseCondition", "setOutcomeValue", "exitResponse"]);
39
+ const supportedRuleKinds = new Set([
40
+ "responseCondition",
41
+ "setOutcomeValue",
42
+ "lookupOutcomeValue",
43
+ "responseProcessingFragment",
44
+ "exitResponse",
45
+ ]);
39
46
 
40
47
  /**
41
48
  * RP additionally supports the random operators: the attempt store always provides a
@@ -83,6 +90,13 @@ export function executeResponseProcessing(
83
90
  function initialOutcomes(): Map<string, MaybeRpValue> {
84
91
  const outcomes = defaultOutcomes(context.outcomeDeclarations);
85
92
 
93
+ // The built-in completionStatus is "declared implicitly" and starts at
94
+ // "not_attempted" (§2.2.2.3); seeding it into the map lets setOutcomeValue
95
+ // change it. Explicit declarations (legacy content) keep the declared path.
96
+ if (!outcomes.has("completionStatus")) {
97
+ outcomes.set("completionStatus", rpValue("single", [context.completionStatus ?? "not_attempted"], "identifier"));
98
+ }
99
+
86
100
  // Adaptive carry-over: prior outcome values (from earlier attempts in the same
87
101
  // item session) replace the declared defaults.
88
102
  for (const [identifier, prior] of Object.entries(context.priorOutcomes ?? {})) {
@@ -110,6 +124,16 @@ export function executeResponseProcessing(
110
124
 
111
125
  const env: EvalEnv = {
112
126
  lookupVariable: (identifier) => {
127
+ // Built-in session variables (reserved identifiers; items must not declare
128
+ // them): duration in seconds, numAttempts including the current attempt.
129
+ if (identifier === "duration") {
130
+ return context.duration === undefined ? null : rpValue("single", [context.duration], "duration");
131
+ }
132
+
133
+ if (identifier === "numAttempts") {
134
+ return context.numAttempts === undefined ? null : rpValue("single", [context.numAttempts], "integer");
135
+ }
136
+
113
137
  const declaration = declarationsById.get(identifier);
114
138
 
115
139
  if (declaration) {
@@ -130,6 +154,22 @@ export function executeResponseProcessing(
130
154
  },
131
155
  responseDeclaration: (identifier) => declarationsById.get(identifier),
132
156
  responseValue: (identifier) => context.responses[identifier] ?? null,
157
+ variableDefault: (identifier) => {
158
+ const declaration =
159
+ declarationsById.get(identifier) ??
160
+ context.outcomeDeclarations.find((entry) => entry.identifier === identifier) ??
161
+ templateDeclarationsById.get(identifier);
162
+
163
+ if (!declaration?.defaultValue) {
164
+ return null; // "NULL if no default value was declared" (§2.11.1.3)
165
+ }
166
+
167
+ return rpValue(
168
+ declaration.cardinality,
169
+ declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
170
+ declaration.baseType,
171
+ );
172
+ },
133
173
  normalization: context.normalization,
134
174
  random: context.random,
135
175
  customOperators: context.customOperators,
@@ -162,6 +202,28 @@ export function executeResponseProcessing(
162
202
  continue;
163
203
  }
164
204
 
205
+ // "A simple group of responseRules" (§5.118): executes inline, in order, over
206
+ // the same outcome state; exitResponse propagates through the recursion.
207
+ if (rule.kind === "responseProcessingFragment") {
208
+ executeRules(rule.rules ?? []);
209
+ continue;
210
+ }
211
+
212
+ if (rule.kind === "lookupOutcomeValue") {
213
+ if (rule.identifier !== undefined && rule.expression !== undefined) {
214
+ const declaration = context.outcomeDeclarations.find((entry) => entry.identifier === rule.identifier);
215
+
216
+ if (!hasLookupTable(declaration)) {
217
+ // §5.87 presumes "the lookupTable associated with the outcome's
218
+ // declaration" — no table, no spec-defined value: refuse, never guess.
219
+ throw new RpUnsupportedError("lookupOutcomeValue");
220
+ }
221
+
222
+ outcomes.set(rule.identifier, lookupTableValue(declaration, evaluateExpression(rule.expression, env)));
223
+ }
224
+ continue;
225
+ }
226
+
165
227
  // responseCondition
166
228
  if (rule.responseIf && branchTaken(rule.responseIf)) {
167
229
  continue;
@@ -195,6 +257,12 @@ export function executeResponseProcessing(
195
257
  export interface RpIssueOptions {
196
258
  /** `customOperator` classes the consumer has registered implementations for. */
197
259
  readonly customOperatorClasses?: ReadonlySet<string>;
260
+ /**
261
+ * The item's outcome declarations; when supplied, a `lookupOutcomeValue` rule whose
262
+ * declaration carries no lookupTable is reported statically (gate parity with the
263
+ * runtime refusal).
264
+ */
265
+ readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
198
266
  }
199
267
 
200
268
  /** Static coverage walk for `canDeliver`: reports constructs the interpreter lacks without executing. */
@@ -223,10 +291,22 @@ export function collectRpIssues(
223
291
  continue;
224
292
  }
225
293
 
294
+ if (rule.kind === "lookupOutcomeValue" && options?.outcomeDeclarations !== undefined) {
295
+ const declaration = options.outcomeDeclarations.find((entry) => entry.identifier === rule.identifier);
296
+
297
+ if (!hasLookupTable(declaration)) {
298
+ report("lookupOutcomeValue", "Outcome declaration has no matchTable/interpolationTable.");
299
+ }
300
+ }
301
+
226
302
  if (rule.expression) {
227
303
  collectExpressionIssues(rule.expression, rpExpressionKinds, report, options?.customOperatorClasses);
228
304
  }
229
305
 
306
+ if (rule.rules) {
307
+ walkRules(rule.rules); // responseProcessingFragment nesting (§5.118)
308
+ }
309
+
230
310
  for (const branch of [rule.responseIf, ...(rule.responseElseIfs ?? [])]) {
231
311
  if (branch) {
232
312
  collectExpressionIssues(branch.expression, rpExpressionKinds, report, options?.customOperatorClasses);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * lookupTable evaluation for the `lookupOutcomeValue` rule (§5.87): "sets the value
3
+ * of an outcome variable to the value obtained by looking up the value of the
4
+ * associated expression in the lookupTable associated with the outcome's
5
+ * declaration." Shared by the item interpreter and the test controller — the test's
6
+ * own outcome declarations use the same view type.
7
+ */
8
+
9
+ import type { OutcomeDeclarationView } from "./types";
10
+ import { coerceScalar, rpValue, singleNumber, type MaybeRpValue } from "./values";
11
+
12
+ export function hasLookupTable(declaration: OutcomeDeclarationView | undefined): declaration is OutcomeDeclarationView {
13
+ return declaration?.matchTable !== undefined || declaration?.interpolationTable !== undefined;
14
+ }
15
+
16
+ /**
17
+ * Look `source` up in the declaration's lookupTable. A NULL or non-numeric source is
18
+ * read as "no matching table entry is found" (§5.90.1) — an interpretive call the
19
+ * spec leaves open — so it takes the defaultValue path, never a refusal. "If
20
+ * omitted, the NULL value is used." (§5.90.1/§5.78.1)
21
+ */
22
+ export function lookupTableValue(declaration: OutcomeDeclarationView, source: MaybeRpValue): MaybeRpValue {
23
+ const value = singleNumber(source);
24
+ const table = declaration.matchTable ?? declaration.interpolationTable;
25
+ const target = value === null ? undefined : matchedTarget(declaration, value);
26
+ const result = target ?? table?.defaultValue;
27
+
28
+ if (result === undefined) {
29
+ return null;
30
+ }
31
+
32
+ return rpValue("single", [coerceScalar(result, declaration.baseType)], declaration.baseType);
33
+ }
34
+
35
+ function matchedTarget(declaration: OutcomeDeclarationView, value: number) {
36
+ if (declaration.matchTable) {
37
+ // "the first qti-match-table-entry with an exact match to the source" (§5.90)
38
+ return declaration.matchTable.matchTableEntries.find((entry) => entry.sourceValue === value)?.targetValue;
39
+ }
40
+
41
+ // "the first interpolationTableEntry with a sourceValue that is less than or equal
42
+ // to (subject to includeBoundary) the source value" (§5.78); document order decides.
43
+ return declaration.interpolationTable?.interpolationTableEntries.find((entry) =>
44
+ (entry.includeBoundary ?? true) ? entry.sourceValue <= value : entry.sourceValue < value,
45
+ )?.targetValue;
46
+ }
@@ -118,6 +118,19 @@ export function executeTemplateProcessing(
118
118
  lookupVariable: (identifier) => templateValues.get(identifier) ?? null,
119
119
  responseDeclaration: (identifier) => responseDeclarationsById.get(identifier),
120
120
  responseValue: () => null, // no candidate responses exist at template-processing time
121
+ variableDefault: (identifier) => {
122
+ const declaration = declarationsById.get(identifier) ?? responseDeclarationsById.get(identifier);
123
+
124
+ if (!declaration?.defaultValue) {
125
+ return null; // "NULL if no default value was declared" (§2.11.1.3)
126
+ }
127
+
128
+ return rpValue(
129
+ declaration.cardinality,
130
+ declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
131
+ declaration.baseType,
132
+ );
133
+ },
121
134
  random: mulberry32(context.seed),
122
135
  customOperators: context.customOperators,
123
136
  };
@@ -229,6 +242,34 @@ export function executeTemplateProcessing(
229
242
  };
230
243
  }
231
244
 
245
+ /**
246
+ * The effective template declarations for a clone: test-level `templateDefault`
247
+ * values (§5.152) replace the declared defaults. A NULL value clears the default.
248
+ */
249
+ export function applyTemplateDefaultOverrides(
250
+ declarations: readonly TemplateDeclarationView[],
251
+ overrides: Readonly<Record<string, OutcomeValue>>,
252
+ ): readonly TemplateDeclarationView[] {
253
+ return declarations.map((declaration) => {
254
+ if (!(declaration.identifier in overrides)) {
255
+ return declaration;
256
+ }
257
+
258
+ const value = overrides[declaration.identifier];
259
+
260
+ if (value === null || value === undefined) {
261
+ const { defaultValue: _cleared, ...rest } = declaration;
262
+
263
+ return rest;
264
+ }
265
+
266
+ return {
267
+ ...declaration,
268
+ defaultValue: { values: (Array.isArray(value) ? value : [value]).map((member) => ({ value: member })) },
269
+ };
270
+ });
271
+ }
272
+
232
273
  /** The effective response declarations for a clone: setCorrectResponse overrides applied. */
233
274
  export function applyCorrectResponseOverrides(
234
275
  declarations: readonly ResponseDeclarationView[],
package/src/rp/types.ts CHANGED
@@ -32,11 +32,53 @@ export interface RpValue {
32
32
 
33
33
  export type MaybeRpValue = RpValue | null;
34
34
 
35
+ /** One matchTable row: exact integer source → target (§7.23). */
36
+ export interface MatchTableEntryView {
37
+ readonly sourceValue: number;
38
+ readonly targetValue: RpScalar;
39
+ }
40
+
41
+ /**
42
+ * "A matchTable transforms a source integer by finding the first
43
+ * qti-match-table-entry with an exact match to the source." (§5.90)
44
+ */
45
+ export interface MatchTableView {
46
+ /** "The default outcome value to be used when no matching table entry is found.
47
+ * If omitted, the NULL value is used." (§5.90.1) */
48
+ readonly defaultValue?: RpScalar;
49
+ readonly matchTableEntries: readonly MatchTableEntryView[];
50
+ }
51
+
52
+ /** One interpolationTable row; sourceValue is "the lower bound … to match this entry" (§7.18.1). */
53
+ export interface InterpolationTableEntryView {
54
+ readonly sourceValue: number;
55
+ readonly targetValue: RpScalar;
56
+ /** "If 'true', the default, then an exact match of the value is considered a match
57
+ * of this entry." (§7.18.2) */
58
+ readonly includeBoundary?: boolean;
59
+ }
60
+
61
+ /**
62
+ * "An interpolationTable transforms a source float (or integer) by finding the first
63
+ * interpolationTableEntry with a sourceValue that is less than or equal to (subject
64
+ * to includeBoundary) the source value." (§5.78)
65
+ */
66
+ export interface InterpolationTableView {
67
+ readonly defaultValue?: RpScalar;
68
+ readonly interpolationTableEntries: readonly InterpolationTableEntryView[];
69
+ }
70
+
35
71
  export interface OutcomeDeclarationView {
36
72
  readonly identifier: string;
37
73
  readonly cardinality: Cardinality;
38
74
  readonly baseType?: string;
39
75
  readonly defaultValue?: { readonly values: ReadonlyArray<{ readonly value: RpScalar }> };
76
+ /** The declaration's lookupTable (at most one of the two), read by `lookupOutcomeValue` (§5.87). */
77
+ readonly matchTable?: MatchTableView;
78
+ readonly interpolationTable?: InterpolationTableView;
79
+ /** Declared score bounds, aggregated by `outcomeMaximum`/`outcomeMinimum` (§2.11.2.6-7). */
80
+ readonly normalMaximum?: number;
81
+ readonly normalMinimum?: number;
40
82
  }
41
83
 
42
84
  /**
@@ -49,11 +91,14 @@ export interface RpExpressionView {
49
91
  readonly baseType?: string;
50
92
  readonly value?: RpScalar;
51
93
  readonly expressions?: readonly RpExpressionView[];
52
- /** Bounds/step for the random operators (template processing). */
53
- readonly min?: number;
54
- readonly max?: number;
55
- readonly step?: number;
56
- /** `equal` tolerance window; string entries are template references (unsupported). */
94
+ /**
95
+ * Bounds/step for the random operators and `anyN`; string values are variable
96
+ * references resolved at runtime (§2.11.3.6), bare or brace-enclosed (§7.13).
97
+ */
98
+ readonly min?: number | string;
99
+ readonly max?: number | string;
100
+ readonly step?: number | string;
101
+ /** `equal` tolerance window; string entries are variable references. */
57
102
  readonly toleranceMode?: "exact" | "absolute" | "relative";
58
103
  readonly tolerance?: ReadonlyArray<number | string>;
59
104
  readonly includeLowerBound?: boolean;
@@ -67,14 +112,18 @@ export interface RpExpressionView {
67
112
  readonly figures?: number | string;
68
113
  /** Pass count for `repeat`. */
69
114
  readonly numberRepeats?: number | string;
115
+ /** XSD-dialect pattern for `patternMatch`; "{ref}" resolves from a variable (§7.13). */
116
+ readonly pattern?: string;
70
117
  /** String comparison controls for `stringMatch` and `substring`. */
71
118
  readonly caseSensitive?: boolean;
72
119
  readonly substring?: boolean;
73
120
  /** Area for `inside` (QTI shape + coords string). */
74
121
  readonly shape?: string;
75
122
  readonly coords?: string;
76
- /** Test-level subset selection (`testVariables` and the `number*` aggregates). */
123
+ /** Test-level subset selection (`testVariables`, `outcomeMinimum`/`outcomeMaximum`, `number*`). */
77
124
  readonly variableIdentifier?: string;
125
+ /** Outcome variable whose declared bounds `outcomeMinimum`/`outcomeMaximum` look up (§7.28.4). */
126
+ readonly outcomeIdentifier?: string;
78
127
  readonly weightIdentifier?: string;
79
128
  readonly sectionIdentifier?: string;
80
129
  readonly includeCategory?: string | readonly string[];
@@ -103,11 +152,16 @@ export interface RpConditionBranch {
103
152
  readonly rules: readonly RpRuleView[];
104
153
  }
105
154
 
106
- /** One response rule: responseCondition, setOutcomeValue, or exitResponse. */
155
+ /**
156
+ * One response rule: responseCondition, setOutcomeValue, lookupOutcomeValue,
157
+ * responseProcessingFragment, or exitResponse.
158
+ */
107
159
  export interface RpRuleView {
108
160
  readonly kind: string;
109
161
  readonly identifier?: string;
110
162
  readonly expression?: RpExpressionView;
163
+ /** Nested rules of a `responseProcessingFragment` (§5.118). */
164
+ readonly rules?: readonly RpRuleView[];
111
165
  readonly responseIf?: RpConditionBranch;
112
166
  readonly responseElseIfs?: readonly RpConditionBranch[];
113
167
  readonly responseElse?: { readonly rules: readonly RpRuleView[] };
@@ -156,6 +210,20 @@ export interface ResponseProcessingContext {
156
210
  * submission sequence then replays the exact same outcomes (ADR-0004 determinism).
157
211
  */
158
212
  readonly random?: (() => number) | undefined;
213
+ /**
214
+ * Built-in session variables (reserved identifiers; items must not declare them).
215
+ * `duration` is the item session's elapsed seconds; `numAttempts` "increases by 1
216
+ * at the start of each attempt", so it includes the attempt being scored.
217
+ */
218
+ readonly duration?: number | undefined;
219
+ readonly numAttempts?: number | undefined;
220
+ /**
221
+ * The session's current `completionStatus` — the third built-in, "declared
222
+ * implicitly"; it enters the outcome map (defaulting to "not_attempted") so
223
+ * `setOutcomeValue` can change it (§2.2.2.3). An explicit declaration (legacy
224
+ * content) wins over this value.
225
+ */
226
+ readonly completionStatus?: string | undefined;
159
227
  /** Registered vendor `customOperator` implementations by class (opt-in). */
160
228
  readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
161
229
  }