@conform-ed/qti-react 0.0.15 → 0.0.17

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.
@@ -6,6 +6,8 @@
6
6
  * response processing must stay deterministic and replayable.
7
7
  */
8
8
 
9
+ import { compile as compileXsdPattern } from "xspattern";
10
+
9
11
  import { parseCoords, parsePoint, pointInShape } from "../graphic";
10
12
  import { mapResponse, mapResponsePoint } from "../response-processing";
11
13
  import type { ResponseDeclarationView, ResponseValue } from "../types";
@@ -32,8 +34,13 @@ export interface EvalEnv {
32
34
  readonly random?: (() => number) | undefined;
33
35
  /** `testVariables` aggregation; present only in test-level outcome processing. */
34
36
  readonly testVariables?: (expression: RpExpressionView) => MaybeRpValue;
35
- /** The `number*` item-session aggregates; present only in test-level outcome processing. */
37
+ /**
38
+ * The test-level subset aggregates (`number*`, `outcomeMinimum`/`outcomeMaximum`);
39
+ * present only in test-level outcome processing.
40
+ */
36
41
  readonly testAggregate?: (expression: RpExpressionView) => MaybeRpValue;
42
+ /** Declared default of any item variable, for the `default` expression (§2.11.1.3). */
43
+ readonly variableDefault?: (identifier: string) => MaybeRpValue;
37
44
  /** Registered vendor operators by class; unregistered classes stay unsupported. */
38
45
  readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
39
46
  }
@@ -41,10 +48,16 @@ export interface EvalEnv {
41
48
  /** Expression kinds legal everywhere (deterministic). */
42
49
  export const deterministicExpressionKinds: ReadonlySet<string> = new Set([
43
50
  "and",
51
+ "anyN",
44
52
  "baseValue",
53
+ "containerSize",
54
+ "contains",
45
55
  "correct",
56
+ "default",
46
57
  "delete",
47
58
  "divide",
59
+ "durationGte",
60
+ "durationLt",
48
61
  "equal",
49
62
  "equalRounded",
50
63
  "fieldValue",
@@ -70,8 +83,11 @@ export const deterministicExpressionKinds: ReadonlySet<string> = new Set([
70
83
  "min",
71
84
  "multiple",
72
85
  "not",
86
+ "null",
73
87
  "or",
74
88
  "ordered",
89
+ "patternMatch",
90
+ "power",
75
91
  "product",
76
92
  "repeat",
77
93
  "round",
@@ -99,6 +115,36 @@ export class RpUnsupportedError extends Error {
99
115
 
100
116
  const mathConstants: Readonly<Record<string, number>> = { pi: Math.PI, e: Math.E };
101
117
 
118
+ /**
119
+ * Compiled XSD-dialect pattern matchers (`patternMatch` uses the regular expression
120
+ * language of Appendix F of XML Schema, not ECMAScript). null caches a pattern that
121
+ * failed to compile — invalid patterns are refused, never guessed.
122
+ */
123
+ const xsdPatternMatchers = new Map<string, ((value: string) => boolean) | null>();
124
+
125
+ function xsdPatternMatcher(pattern: string): ((value: string) => boolean) | null {
126
+ const cached = xsdPatternMatchers.get(pattern);
127
+
128
+ if (cached !== undefined) {
129
+ return cached;
130
+ }
131
+
132
+ let matcher: ((value: string) => boolean) | null;
133
+
134
+ try {
135
+ matcher = compileXsdPattern(pattern);
136
+ } catch {
137
+ matcher = null;
138
+ }
139
+
140
+ xsdPatternMatchers.set(pattern, matcher);
141
+
142
+ return matcher;
143
+ }
144
+
145
+ /** The brace-enclosed variable-reference form of string attributes (§7.13). */
146
+ const encVariableStringPattern = /^\{[^{}]+\}$/u;
147
+
102
148
  /** The named functions of `mathOperator`; undefined means the name is unknown. */
103
149
  function applyMathOperator(name: string, x: number, y: number): number | undefined {
104
150
  switch (name) {
@@ -163,6 +209,28 @@ function applyMathOperator(name: string, x: number, y: number): number | undefin
163
209
  }
164
210
  }
165
211
 
212
+ /**
213
+ * A numeric attribute that is either a literal or a variable reference (the spec's
214
+ * IntOrIdentifier / FloatOrVariableRef): "If n is an identifier, it is the value of n
215
+ * at runtime that is used" (QTI 3 info model §2.11.3.6). The brace-enclosed
216
+ * EncVariableString form (§7.13) is accepted alongside bare identifiers. An
217
+ * unresolvable or NULL-valued reference yields null — the operator then results in
218
+ * NULL, never a refusal.
219
+ */
220
+ function resolveNumericAttribute(raw: number | string | undefined, env: EvalEnv): number | null | undefined {
221
+ if (raw === undefined) {
222
+ return undefined;
223
+ }
224
+
225
+ if (typeof raw === "number") {
226
+ return raw;
227
+ }
228
+
229
+ const identifier = raw.startsWith("{") && raw.endsWith("}") ? raw.slice(1, -1) : raw;
230
+
231
+ return singleNumber(env.lookupVariable(identifier));
232
+ }
233
+
166
234
  function roundToFigures(value: number, mode: "decimalPlaces" | "significantFigures", figures: number): number | null {
167
235
  if (mode === "decimalPlaces") {
168
236
  const scale = 10 ** figures;
@@ -270,12 +338,21 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
270
338
  case "or": {
271
339
  const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
272
340
 
273
- // NULL operands are treated as false; sufficient for the supported coverage.
274
- return booleanValue(
275
- expression.kind === "and"
276
- ? members.every((member) => member === true)
277
- : members.some((member) => member === true),
278
- );
341
+ // Three-valued logic (§2.11.3.10/.15): a decisive operand wins outright; an
342
+ // undecided one ("NULL and all others are true/false") makes the result NULL.
343
+ if (expression.kind === "and") {
344
+ if (members.some((member) => member === false)) {
345
+ return booleanValue(false);
346
+ }
347
+
348
+ return members.some((member) => member === null) ? null : booleanValue(true);
349
+ }
350
+
351
+ if (members.some((member) => member === true)) {
352
+ return booleanValue(true);
353
+ }
354
+
355
+ return members.some((member) => member === null) ? null : booleanValue(false);
279
356
  }
280
357
 
281
358
  case "sum":
@@ -344,12 +421,12 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
344
421
  return booleanValue(a === b);
345
422
  }
346
423
 
347
- const t0 = expression.tolerance?.[0];
348
- const t1 = expression.tolerance?.[1] ?? t0;
424
+ const t0 = resolveNumericAttribute(expression.tolerance?.[0], env);
425
+ const t1raw = expression.tolerance?.[1];
426
+ const t1 = t1raw === undefined ? t0 : resolveNumericAttribute(t1raw, env);
349
427
 
350
428
  if (typeof t0 !== "number" || typeof t1 !== "number") {
351
- // Template-variable tolerances (and missing ones) are out of the staged scope.
352
- throw new RpUnsupportedError("equal");
429
+ return null; // missing or unresolvable tolerance NULL
353
430
  }
354
431
 
355
432
  const lower = mode === "absolute" ? a - t0 : a * (1 - t0 / 100);
@@ -376,8 +453,10 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
376
453
  }
377
454
 
378
455
  case "index": {
379
- if (typeof expression.n !== "number") {
380
- throw new RpUnsupportedError("index"); // template-variable n is out of scope
456
+ const n = resolveNumericAttribute(expression.n, env);
457
+
458
+ if (typeof n !== "number") {
459
+ return null; // missing or unresolvable n → NULL
381
460
  }
382
461
 
383
462
  const operand = expression.expressions?.[0];
@@ -387,7 +466,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
387
466
  return null;
388
467
  }
389
468
 
390
- const member = container.values[expression.n - 1];
469
+ const member = container.values[n - 1];
391
470
 
392
471
  if (member === undefined) {
393
472
  return null; // out of range is null, per spec
@@ -503,8 +582,10 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
503
582
  }
504
583
 
505
584
  case "roundTo": {
506
- if (typeof expression.figures !== "number") {
507
- throw new RpUnsupportedError("roundTo"); // template-variable figures
585
+ const figures = resolveNumericAttribute(expression.figures, env);
586
+
587
+ if (typeof figures !== "number") {
588
+ return null; // missing or unresolvable figures → NULL
508
589
  }
509
590
 
510
591
  const operand = expression.expressions?.[0];
@@ -514,14 +595,16 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
514
595
  return null;
515
596
  }
516
597
 
517
- const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures", expression.figures);
598
+ const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures", figures);
518
599
 
519
600
  return rounded === null ? null : floatValue(rounded);
520
601
  }
521
602
 
522
603
  case "equalRounded": {
523
- if (typeof expression.figures !== "number") {
524
- throw new RpUnsupportedError("equalRounded");
604
+ const figures = resolveNumericAttribute(expression.figures, env);
605
+
606
+ if (typeof figures !== "number") {
607
+ return null; // missing or unresolvable figures → NULL
525
608
  }
526
609
 
527
610
  const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
@@ -531,8 +614,8 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
531
614
  }
532
615
 
533
616
  const mode = expression.roundingMode ?? "significantFigures";
534
- const roundedA = roundToFigures(a, mode, expression.figures);
535
- const roundedB = roundToFigures(b, mode, expression.figures);
617
+ const roundedA = roundToFigures(a, mode, figures);
618
+ const roundedB = roundToFigures(b, mode, figures);
536
619
 
537
620
  return roundedA === null || roundedB === null ? null : booleanValue(roundedA === roundedB);
538
621
  }
@@ -608,18 +691,18 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
608
691
  }
609
692
 
610
693
  case "repeat": {
611
- if (typeof expression.numberRepeats !== "number") {
612
- throw new RpUnsupportedError("repeat"); // template-variable count
613
- }
694
+ const numberRepeats = resolveNumericAttribute(expression.numberRepeats, env);
614
695
 
615
- if (expression.numberRepeats < 1) {
696
+ // "If qti-number-repeats refers to a variable whose value is less than 1, the
697
+ // value of the whole expression is NULL" (§2.11.3.42); unresolvable refs too.
698
+ if (typeof numberRepeats !== "number" || numberRepeats < 1) {
616
699
  return null;
617
700
  }
618
701
 
619
702
  const members: RpValue["values"][number][] = [];
620
703
  let baseType: string | undefined;
621
704
 
622
- for (let pass = 0; pass < expression.numberRepeats; pass += 1) {
705
+ for (let pass = 0; pass < numberRepeats; pass += 1) {
623
706
  for (const child of expression.expressions ?? []) {
624
707
  const value = evaluate(child);
625
708
 
@@ -635,6 +718,160 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
635
718
  return members.length === 0 ? null : rpValue("ordered", members, baseType);
636
719
  }
637
720
 
721
+ case "null":
722
+ return null;
723
+
724
+ case "durationGte":
725
+ case "durationLt": {
726
+ // Durations are compared as elapsed seconds; "longer (or equal, within the
727
+ // limits imposed by truncation …)" (§2.11.3.20/.21). NULL operands propagate.
728
+ const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
729
+
730
+ if (a === undefined || b === undefined || a === null || b === null) {
731
+ return null;
732
+ }
733
+
734
+ return booleanValue(expression.kind === "durationGte" ? a >= b : a < b);
735
+ }
736
+
737
+ case "default":
738
+ // "Returns the associated qti-default-value or NULL if no default value was
739
+ // declared" (§2.11.1.3).
740
+ return env.variableDefault?.(expression.identifier ?? "") ?? null;
741
+
742
+ case "patternMatch": {
743
+ const operand = expression.expressions?.[0];
744
+ const value = operand === undefined ? null : evaluate(operand);
745
+ const member = value?.values[0];
746
+
747
+ if (value === null || typeof member !== "string") {
748
+ return null; // "If the sub-expression is NULL then the operator results in NULL" (§2.11.3.41)
749
+ }
750
+
751
+ if (expression.pattern === undefined) {
752
+ throw new RpUnsupportedError("patternMatch");
753
+ }
754
+
755
+ // EncVariableString (§7.13): a brace-enclosed reference resolves the pattern
756
+ // from a variable at runtime; anything else is a literal XSD pattern.
757
+ const pattern = encVariableStringPattern.test(expression.pattern)
758
+ ? env.lookupVariable(expression.pattern.slice(1, -1))?.values[0]
759
+ : expression.pattern;
760
+
761
+ if (typeof pattern !== "string") {
762
+ return null; // unresolvable pattern reference → NULL
763
+ }
764
+
765
+ const matcher = xsdPatternMatcher(pattern);
766
+
767
+ if (matcher === null) {
768
+ throw new RpUnsupportedError("patternMatch"); // invalid pattern: refuse, never guess
769
+ }
770
+
771
+ return booleanValue(matcher(member));
772
+ }
773
+
774
+ case "power": {
775
+ const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
776
+
777
+ if (a === undefined || b === undefined || a === null || b === null) {
778
+ return null;
779
+ }
780
+
781
+ const result = a ** b;
782
+
783
+ // "If the resulting value is outside the value set defined by float (not
784
+ // including positive and negative infinity) then the operator shall result in
785
+ // NULL" (§2.11.3.30).
786
+ return Number.isFinite(result) ? floatValue(result) : null;
787
+ }
788
+
789
+ case "containerSize": {
790
+ const operand = expression.expressions?.[0];
791
+ const container = operand === undefined ? null : evaluate(operand);
792
+
793
+ // "If the sub-expression is NULL the result is 0" (§2.11.3.32) — the spec's
794
+ // exception to NULL propagation; an empty container is NULL in this model.
795
+ return {
796
+ cardinality: "single",
797
+ baseType: "integer",
798
+ values: [container === null ? 0 : container.values.length],
799
+ };
800
+ }
801
+
802
+ case "contains": {
803
+ const [firstExpression, secondExpression] = expression.expressions ?? [];
804
+
805
+ if (firstExpression === undefined || secondExpression === undefined) {
806
+ return null;
807
+ }
808
+
809
+ const first = evaluate(firstExpression);
810
+ const second = evaluate(secondExpression);
811
+
812
+ if (first === null || second === null || first.cardinality === "record" || second.cardinality === "record") {
813
+ return null;
814
+ }
815
+
816
+ const baseType = first.baseType ?? second.baseType;
817
+
818
+ if (first.cardinality === "ordered") {
819
+ // "For ordered containers the second sub-expression must be a strict
820
+ // sub-sequence within the first" (§2.11.3.17): a contiguous in-order run.
821
+ const found = first.values.some(
822
+ (_, start) =>
823
+ start + second.values.length <= first.values.length &&
824
+ second.values.every((member, offset) =>
825
+ scalarsEqual(first.values[start + offset]!, member, baseType, env.normalization),
826
+ ),
827
+ );
828
+
829
+ return booleanValue(found);
830
+ }
831
+
832
+ // Unordered: multiset semantics — "[A,B,C] does not contain [B,B] but
833
+ // [A,B,B,C] does" (§2.11.3.17).
834
+ const remaining = [...first.values];
835
+
836
+ for (const member of second.values) {
837
+ const at = remaining.findIndex((candidate) => scalarsEqual(candidate, member, baseType, env.normalization));
838
+
839
+ if (at === -1) {
840
+ return booleanValue(false);
841
+ }
842
+
843
+ remaining.splice(at, 1);
844
+ }
845
+
846
+ return booleanValue(true);
847
+ }
848
+
849
+ case "anyN": {
850
+ const min = resolveNumericAttribute(expression.min, env);
851
+ const max = resolveNumericAttribute(expression.max, env);
852
+
853
+ if (typeof min !== "number" || typeof max !== "number") {
854
+ return null;
855
+ }
856
+
857
+ const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
858
+ const trueCount = members.filter((member) => member === true).length;
859
+ const nullCount = members.filter((member) => member === null).length;
860
+
861
+ // The actual count of true lies in [trueCount, trueCount + nullCount]; the
862
+ // result is decided only when that whole interval is inside or outside
863
+ // [min, max] — this reproduces the spec's worked examples (§2.11.3, anyN).
864
+ if (trueCount >= min && trueCount + nullCount <= max) {
865
+ return booleanValue(true);
866
+ }
867
+
868
+ if (trueCount + nullCount < min || trueCount > max) {
869
+ return booleanValue(false);
870
+ }
871
+
872
+ return null;
873
+ }
874
+
638
875
  case "stringMatch":
639
876
  case "substring": {
640
877
  const [a, b] = (expression.expressions ?? []).map((child) => {
@@ -735,9 +972,9 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
735
972
  throw new RpUnsupportedError(expression.kind);
736
973
  }
737
974
 
738
- const min = expression.min ?? 0;
739
- const max = expression.max ?? min;
740
- const step = expression.step ?? 1;
975
+ const min = resolveNumericAttribute(expression.min, env) ?? 0;
976
+ const max = resolveNumericAttribute(expression.max, env) ?? min;
977
+ const step = resolveNumericAttribute(expression.step, env) ?? 1;
741
978
  const count = Math.max(1, Math.floor((max - min) / step) + 1);
742
979
 
743
980
  return { cardinality: "single", baseType: "integer", values: [min + Math.floor(env.random() * count) * step] };
@@ -748,8 +985,8 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
748
985
  throw new RpUnsupportedError(expression.kind);
749
986
  }
750
987
 
751
- const min = expression.min ?? 0;
752
- const max = expression.max ?? min;
988
+ const min = resolveNumericAttribute(expression.min, env) ?? 0;
989
+ const max = resolveNumericAttribute(expression.max, env) ?? min;
753
990
 
754
991
  return floatValue(min + env.random() * (max - min));
755
992
  }
@@ -776,7 +1013,9 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
776
1013
  case "numberIncorrect":
777
1014
  case "numberPresented":
778
1015
  case "numberResponded":
779
- case "numberSelected": {
1016
+ case "numberSelected":
1017
+ case "outcomeMinimum":
1018
+ case "outcomeMaximum": {
780
1019
  if (!env.testAggregate) {
781
1020
  throw new RpUnsupportedError(expression.kind);
782
1021
  }
@@ -819,6 +1058,15 @@ export function collectExpressionIssues(
819
1058
  }
820
1059
  } else if (!allowedKinds.has(expression.kind)) {
821
1060
  report(expression.kind);
1061
+ } else if (
1062
+ expression.kind === "patternMatch" &&
1063
+ expression.pattern !== undefined &&
1064
+ !encVariableStringPattern.test(expression.pattern) &&
1065
+ xsdPatternMatcher(expression.pattern) === null
1066
+ ) {
1067
+ // A literal pattern that does not compile can never evaluate: surface it at
1068
+ // gate time instead of as a runtime abort.
1069
+ report(expression.kind);
822
1070
  }
823
1071
 
824
1072
  for (const child of expression.expressions ?? []) {
package/src/rp/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { collectRpIssues, executeResponseProcessing } from "./interpreter";
2
2
  export {
3
3
  applyCorrectResponseOverrides,
4
+ applyTemplateDefaultOverrides,
4
5
  collectTemplateIssues,
5
6
  executeTemplateProcessing,
6
7
  mulberry32,
@@ -13,6 +14,10 @@ export {
13
14
  export { resolveTemplate } from "./templates";
14
15
  export type {
15
16
  CustomOperatorImplementation,
17
+ InterpolationTableEntryView,
18
+ InterpolationTableView,
19
+ MatchTableEntryView,
20
+ MatchTableView,
16
21
  MaybeRpValue,
17
22
  OutcomeDeclarationView,
18
23
  OutcomeValue,
@@ -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
+ }