@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Response validity (ItemSessionControl validate-responses): "An invalid response is
3
+ * defined to be a response which does not satisfy the constraints imposed by the
4
+ * interaction with which it is associated." The constraint vocabulary is what the
5
+ * view model carries on interaction nodes — min/max-choices, min/max-associations,
6
+ * min-strings, pattern-mask, min-plays. Only authored attributes are validated:
7
+ * rendering defaults (e.g. a radio group's single choice) are interaction behavior,
8
+ * not submission constraints.
9
+ */
10
+
11
+ import { compile as compileXsdPattern } from "xspattern";
12
+
13
+ import { isInteractionKind, v0ContentModel } from "./content-model";
14
+ import type { BodyNode } from "./runtime";
15
+ import { isResponseRecord } from "./types";
16
+ import type { ResponseValue } from "./types";
17
+
18
+ export type ResponseConstraintKind =
19
+ | "minChoices"
20
+ | "maxChoices"
21
+ | "minAssociations"
22
+ | "maxAssociations"
23
+ | "minStrings"
24
+ | "patternMask"
25
+ | "minPlays";
26
+
27
+ export interface InteractionConstraint {
28
+ readonly responseIdentifier: string;
29
+ readonly kind: ResponseConstraintKind;
30
+ /** The declared bound: a count for the min/max constraints, the XSD regex for patternMask. */
31
+ readonly bound: number | string;
32
+ }
33
+
34
+ /** A constraint the current response fails — the reason a submission is invalid. */
35
+ export type ResponseViolation = InteractionConstraint;
36
+
37
+ const countConstraintKinds = ["minChoices", "maxChoices", "minAssociations", "maxAssociations", "minStrings"] as const;
38
+
39
+ /**
40
+ * Walk the item body and collect the constraint attributes its interactions carry.
41
+ * Zero bounds impose nothing: "If max-choices is 0 then there is no restriction";
42
+ * "If min-choices is 0 then the candidate is not required to select any choices."
43
+ */
44
+ export function collectInteractionConstraints(content: readonly BodyNode[] | undefined): InteractionConstraint[] {
45
+ const constraints: InteractionConstraint[] = [];
46
+
47
+ function walk(node: BodyNode): void {
48
+ const record = node as unknown as Record<string, unknown>;
49
+
50
+ if (isInteractionKind(v0ContentModel, node.kind) && typeof record["responseIdentifier"] === "string") {
51
+ const responseIdentifier = record["responseIdentifier"];
52
+
53
+ for (const kind of [...countConstraintKinds, "minPlays"] as const) {
54
+ const bound = record[kind];
55
+
56
+ if (typeof bound === "number" && bound > 0) {
57
+ constraints.push({ responseIdentifier, kind, bound });
58
+ }
59
+ }
60
+
61
+ const patternMask = record["patternMask"];
62
+
63
+ if (typeof patternMask === "string" && patternMask !== "") {
64
+ constraints.push({ responseIdentifier, kind: "patternMask", bound: patternMask });
65
+ }
66
+
67
+ return; // interactions do not nest
68
+ }
69
+
70
+ for (const key of ["content", "children"] as const) {
71
+ const nested = record[key];
72
+
73
+ if (Array.isArray(nested)) {
74
+ for (const child of nested as readonly BodyNode[]) {
75
+ walk(child);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const node of content ?? []) {
82
+ walk(node);
83
+ }
84
+
85
+ return constraints;
86
+ }
87
+
88
+ /** Selected members of a response: choices picked, associations made, strings entered. */
89
+ function memberCount(value: ResponseValue): number {
90
+ if (value === null || value === undefined || value === "") {
91
+ return 0;
92
+ }
93
+
94
+ if (Array.isArray(value)) {
95
+ return value.filter((member) => member !== null && member !== "").length;
96
+ }
97
+
98
+ return 1;
99
+ }
100
+
101
+ /** The non-empty strings a pattern mask applies to (each container member must match). */
102
+ function stringMembers(value: ResponseValue): string[] {
103
+ if (typeof value === "string") {
104
+ return value === "" ? [] : [value];
105
+ }
106
+
107
+ if (Array.isArray(value)) {
108
+ return value.filter((member): member is string => typeof member === "string" && member !== "");
109
+ }
110
+
111
+ return [];
112
+ }
113
+
114
+ function violates(constraint: InteractionConstraint, value: ResponseValue): boolean {
115
+ switch (constraint.kind) {
116
+ case "minChoices":
117
+ case "minAssociations":
118
+ case "minStrings":
119
+ return memberCount(value) < Number(constraint.bound);
120
+ case "maxChoices":
121
+ case "maxAssociations":
122
+ return memberCount(value) > Number(constraint.bound);
123
+ case "minPlays": {
124
+ // "Failure to play the media object the minimum number of times constitutes an
125
+ // invalid response." The media response variable counts the plays.
126
+ const plays = typeof value === "number" ? value : 0;
127
+
128
+ return plays < Number(constraint.bound);
129
+ }
130
+ case "patternMask": {
131
+ // "the pattern-mask specifies a regular expression that the candidate's
132
+ // response must match in order to be considered valid" — XSD regex dialect.
133
+ // An unanswered interaction is governed by the min* constraints, not the
134
+ // pattern, and an uncompilable pattern never blocks the candidate.
135
+ const members = stringMembers(value);
136
+
137
+ if (members.length === 0) {
138
+ return false;
139
+ }
140
+
141
+ try {
142
+ const matches = compileXsdPattern(String(constraint.bound), { language: "xsd" });
143
+
144
+ return !members.every((member) => matches(member));
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ /** The constraints the current responses fail; empty means the responses are valid. */
153
+ export function collectResponseViolations(
154
+ constraints: readonly InteractionConstraint[],
155
+ responses: Readonly<Record<string, ResponseValue>>,
156
+ ): ResponseViolation[] {
157
+ return constraints.filter((constraint) => {
158
+ const value = responses[constraint.responseIdentifier] ?? null;
159
+
160
+ // Record responses (PCI-style composites) carry no countable selection.
161
+ return !isResponseRecord(value) && violates(constraint, value);
162
+ });
163
+ }
@@ -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,