@longsightgroup/qti3-core 0.2.1 → 0.3.0

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.
package/src/session.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  QtiValue,
18
18
  QtiVariableDeclaration,
19
19
  } from "./types.js";
20
+ import { qtiScalarToString, qtiValueToString, qtiValueToStringList } from "./value-format.js";
20
21
 
21
22
  export interface QtiCustomOperatorContext {
22
23
  definition?: string | undefined;
@@ -64,8 +65,9 @@ export function visibleModalFeedback(
64
65
  return item.modalFeedback.filter((feedback) => {
65
66
  if (feedback.showHide === "hide") return false;
66
67
  const outcome = outcomes[feedback.outcomeIdentifier];
67
- if (Array.isArray(outcome)) return outcome.includes(feedback.identifier);
68
- return String(outcome ?? "") === feedback.identifier;
68
+ const outcomeValue: QtiValue = outcome === undefined ? null : outcome;
69
+ if (Array.isArray(outcomeValue)) return outcomeValue.includes(feedback.identifier);
70
+ return qtiValueToString(outcomeValue) === feedback.identifier;
69
71
  });
70
72
  }
71
73
 
@@ -84,6 +86,7 @@ export function createItemSession(
84
86
  );
85
87
  let validationMessages = cloneDiagnostics(priorState?.validationMessages ?? []);
86
88
  const responses: Record<string, QtiValue> = {};
89
+ const responseDefaults: Record<string, QtiValue> = {};
87
90
  const outcomes: Record<string, QtiValue> = {};
88
91
  const templateValues: Record<string, QtiValue> = {};
89
92
  const interactionStates: Record<string, QtiPortableCustomStateValue> = {};
@@ -100,8 +103,8 @@ export function createItemSession(
100
103
 
101
104
  for (const declaration of document.item.responseDeclarations) {
102
105
  correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
103
- if (declaration.defaultValue !== null && responses[declaration.identifier] === undefined) {
104
- responses[declaration.identifier] = cloneValue(declaration.defaultValue);
106
+ if (declaration.defaultValue !== null) {
107
+ responseDefaults[declaration.identifier] = cloneValue(declaration.defaultValue);
105
108
  }
106
109
  }
107
110
  for (const declaration of document.item.templateDeclarations) {
@@ -112,18 +115,21 @@ export function createItemSession(
112
115
  }
113
116
  outcomes[COMPLETION_STATUS] = COMPLETION_NOT_ATTEMPTED;
114
117
  const baseResponses = cloneValueRecord(responses);
118
+ const baseResponseDefaults = cloneValueRecord(responseDefaults);
115
119
  const baseOutcomes = cloneValueRecord(outcomes);
116
120
 
117
121
  applyTemplateProcessing(
118
122
  document,
119
123
  templateValues,
120
124
  responses,
125
+ responseDefaults,
121
126
  outcomes,
122
127
  correctResponses,
123
128
  random,
124
129
  customOperators,
125
130
  new Set(),
126
131
  baseResponses,
132
+ baseResponseDefaults,
127
133
  baseOutcomes,
128
134
  );
129
135
  if (priorState) {
@@ -133,12 +139,14 @@ export function createItemSession(
133
139
  document,
134
140
  templateValues,
135
141
  responses,
142
+ responseDefaults,
136
143
  outcomes,
137
144
  correctResponses,
138
145
  random,
139
146
  customOperators,
140
147
  new Set(Object.keys(priorTemplateValues)),
141
148
  baseResponses,
149
+ baseResponseDefaults,
142
150
  baseOutcomes,
143
151
  );
144
152
  }
@@ -174,7 +182,7 @@ export function createItemSession(
174
182
  },
175
183
  score() {
176
184
  const diagnostics: QtiDiagnostic[] = [];
177
- if (document.item.adaptive || status !== "initialized" || Object.keys(responses).length > 0) {
185
+ if (document.item.adaptive || status !== "initialized") {
178
186
  startAttempt();
179
187
  }
180
188
  const completionStatus = outcomes[COMPLETION_STATUS] ?? COMPLETION_NOT_ATTEMPTED;
@@ -218,6 +226,9 @@ export function createItemSession(
218
226
  };
219
227
 
220
228
  function startAttempt(): void {
229
+ for (const [identifier, value] of Object.entries(responseDefaults)) {
230
+ if (responses[identifier] === undefined) responses[identifier] = cloneValue(value);
231
+ }
221
232
  if (status === "initialized" || status === "suspended") status = "interacting";
222
233
  if (outcomes[COMPLETION_STATUS] === COMPLETION_NOT_ATTEMPTED) {
223
234
  outcomes[COMPLETION_STATUS] = COMPLETION_UNKNOWN;
@@ -297,7 +308,9 @@ function assertCompatiblePriorState(
297
308
  completionStatus !== COMPLETION_COMPLETED &&
298
309
  completionStatus !== "incomplete"
299
310
  ) {
300
- throw new Error(`Cannot restore unsupported completionStatus ${String(completionStatus)}.`);
311
+ throw new Error(
312
+ `Cannot restore unsupported completionStatus ${qtiValueToString(completionStatus)}.`,
313
+ );
301
314
  }
302
315
  }
303
316
 
@@ -431,12 +444,14 @@ function applyTemplateProcessing(
431
444
  document: QtiDocument,
432
445
  templateValues: Record<string, QtiValue>,
433
446
  responses: Record<string, QtiValue>,
447
+ responseDefaults: Record<string, QtiValue>,
434
448
  outcomes: Record<string, QtiValue>,
435
449
  correctResponses: Record<string, QtiValue>,
436
450
  random: () => number,
437
451
  customOperators: QtiCustomOperatorRegistry,
438
452
  preservedTemplateIdentifiers = new Set<string>(),
439
453
  baseResponses: Record<string, QtiValue> = cloneValueRecord(responses),
454
+ baseResponseDefaults: Record<string, QtiValue> = cloneValueRecord(responseDefaults),
440
455
  baseOutcomes: Record<string, QtiValue> = cloneValueRecord(outcomes),
441
456
  ): void {
442
457
  const rules = document.item.templateProcessing?.rules ?? [];
@@ -448,6 +463,7 @@ function applyTemplateProcessing(
448
463
  document,
449
464
  templateValues,
450
465
  responses,
466
+ responseDefaults,
451
467
  outcomes,
452
468
  correctResponses,
453
469
  random,
@@ -469,6 +485,7 @@ function applyTemplateProcessing(
469
485
  if (!satisfied) {
470
486
  resetTemplateValues(document, templateValues);
471
487
  resetRecord(responses, cloneValueRecord(baseResponses));
488
+ resetRecord(responseDefaults, cloneValueRecord(baseResponseDefaults));
472
489
  resetRecord(outcomes, cloneValueRecord(baseOutcomes));
473
490
  resetCorrectResponses(document, correctResponses);
474
491
  restarts += 1;
@@ -504,6 +521,7 @@ function applyTemplateRule(
504
521
  document: QtiDocument,
505
522
  templateValues: Record<string, QtiValue>,
506
523
  responses: Record<string, QtiValue>,
524
+ responseDefaults: Record<string, QtiValue>,
507
525
  outcomes: Record<string, QtiValue>,
508
526
  correctResponses: Record<string, QtiValue>,
509
527
  random: () => number,
@@ -552,6 +570,7 @@ function applyTemplateRule(
552
570
  document,
553
571
  templateValues,
554
572
  responses,
573
+ responseDefaults,
555
574
  outcomes,
556
575
  correctResponses,
557
576
  random,
@@ -583,7 +602,11 @@ function applyTemplateRule(
583
602
  const responseDeclaration = getResponseDeclaration(document, rule.identifier);
584
603
  if (responseDeclaration) {
585
604
  const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
586
- responses[rule.identifier] = normalized;
605
+ if (normalized === null) {
606
+ delete responseDefaults[rule.identifier];
607
+ } else {
608
+ responseDefaults[rule.identifier] = normalized;
609
+ }
587
610
  return false;
588
611
  }
589
612
  const outcomeDeclaration = document.item.outcomeDeclarations.find(
@@ -628,33 +651,45 @@ function applyResponseProcessing(
628
651
  return;
629
652
  }
630
653
 
631
- const template = processing?.template ?? "";
632
- if (template.includes("map_response")) {
633
- let score = 0;
634
- for (const declaration of document.item.responseDeclarations) {
635
- score += mapOrMatchResponse(
636
- declaration,
637
- responses[declaration.identifier] ?? null,
638
- correctResponses[declaration.identifier] ?? null,
639
- );
640
- }
641
- outcomes.SCORE = score;
654
+ const templateKind = responseProcessingTemplateKind(processing?.template);
655
+ if (templateKind === "unsupported") {
656
+ return;
657
+ }
658
+ if (templateKind === "mapResponse" || templateKind === "mapResponsePoint") {
659
+ const declaration = getResponseDeclaration(document, "RESPONSE");
660
+ outcomes.SCORE = declaration
661
+ ? mapOrMatchResponse(
662
+ declaration,
663
+ responses.RESPONSE ?? null,
664
+ correctResponses.RESPONSE ?? null,
665
+ )
666
+ : 0;
642
667
  return;
643
668
  }
644
669
 
645
- let score = 0;
646
- let scored = false;
647
- for (const declaration of document.item.responseDeclarations) {
648
- const response = responses[declaration.identifier] ?? null;
649
- const correctResponse = correctResponses[declaration.identifier] ?? null;
650
- if (correctResponse !== null) {
651
- score += valuesEqual(response, correctResponse, declaration.cardinality === "ordered")
652
- ? 1
653
- : 0;
654
- scored = true;
655
- }
670
+ if (templateKind === "matchCorrect") {
671
+ const declaration = getResponseDeclaration(document, "RESPONSE");
672
+ const matches = declaration
673
+ ? qtiMatchValues(
674
+ responses.RESPONSE ?? null,
675
+ correctResponses.RESPONSE ?? null,
676
+ declaration.cardinality === "ordered",
677
+ )
678
+ : null;
679
+ outcomes.SCORE = matches === true ? 1 : 0;
656
680
  }
657
- if (scored) outcomes.SCORE = score;
681
+ }
682
+
683
+ function responseProcessingTemplateKind(
684
+ template: string | undefined,
685
+ ): "matchCorrect" | "mapResponse" | "mapResponsePoint" | "unsupported" | undefined {
686
+ if (!template) return undefined;
687
+ const path = template.split(/[?#]/, 1)[0] ?? "";
688
+ const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
689
+ if (name === "match_correct") return "matchCorrect";
690
+ if (name === "map_response") return "mapResponse";
691
+ if (name === "map_response_point") return "mapResponsePoint";
692
+ return "unsupported";
658
693
  }
659
694
 
660
695
  function applyResponseRules(
@@ -823,7 +858,7 @@ function evaluateValue(
823
858
  if (expression.type === "matchCorrect") {
824
859
  const declaration = getResponseDeclaration(document, expression.correctIdentifier);
825
860
  return declaration
826
- ? valuesEqual(
861
+ ? qtiMatchValues(
827
862
  responses[expression.identifier] ?? null,
828
863
  correctResponses[expression.correctIdentifier] ?? null,
829
864
  declaration.cardinality === "ordered",
@@ -831,27 +866,29 @@ function evaluateValue(
831
866
  : false;
832
867
  }
833
868
  if (expression.type === "match") {
834
- return valuesEqual(
835
- evaluateValue(
836
- expression.left,
837
- document,
838
- responses,
839
- outcomes,
840
- templateValues,
841
- correctResponses,
842
- random,
843
- customOperators,
844
- ),
845
- evaluateValue(
846
- expression.right,
847
- document,
848
- responses,
849
- outcomes,
850
- templateValues,
851
- correctResponses,
852
- random,
853
- customOperators,
854
- ),
869
+ const left = evaluateValue(
870
+ expression.left,
871
+ document,
872
+ responses,
873
+ outcomes,
874
+ templateValues,
875
+ correctResponses,
876
+ random,
877
+ customOperators,
878
+ );
879
+ const right = evaluateValue(
880
+ expression.right,
881
+ document,
882
+ responses,
883
+ outcomes,
884
+ templateValues,
885
+ correctResponses,
886
+ random,
887
+ customOperators,
888
+ );
889
+ return qtiMatchValues(
890
+ left,
891
+ right,
855
892
  expressionIsOrdered(expression.left, document) ||
856
893
  expressionIsOrdered(expression.right, document),
857
894
  );
@@ -957,89 +994,57 @@ function evaluateValue(
957
994
  ).length;
958
995
  }
959
996
  if (expression.type === "sum") {
960
- return expression.expressions.reduce(
961
- (sum, item) =>
962
- sum +
963
- numericValue(
964
- evaluateValue(
965
- item,
966
- document,
967
- responses,
968
- outcomes,
969
- templateValues,
970
- correctResponses,
971
- random,
972
- customOperators,
973
- ),
974
- ),
975
- 0,
997
+ const values = evaluateNumericOperands(
998
+ expression.expressions,
999
+ document,
1000
+ responses,
1001
+ outcomes,
1002
+ templateValues,
1003
+ correctResponses,
1004
+ random,
1005
+ customOperators,
976
1006
  );
1007
+ return values ? values.reduce((sum, value) => sum + value, 0) : null;
977
1008
  }
978
1009
  if (expression.type === "product") {
979
- return expression.expressions.reduce(
980
- (product, item) =>
981
- product *
982
- numericValue(
983
- evaluateValue(
984
- item,
985
- document,
986
- responses,
987
- outcomes,
988
- templateValues,
989
- correctResponses,
990
- random,
991
- customOperators,
992
- ),
993
- ),
994
- 1,
1010
+ const values = evaluateNumericOperands(
1011
+ expression.expressions,
1012
+ document,
1013
+ responses,
1014
+ outcomes,
1015
+ templateValues,
1016
+ correctResponses,
1017
+ random,
1018
+ customOperators,
995
1019
  );
1020
+ return values ? values.reduce((product, value) => product * value, 1) : null;
996
1021
  }
997
1022
  if (expression.type === "min" || expression.type === "max") {
998
- const values = expression.expressions.flatMap((item) =>
999
- valueContainer(
1000
- evaluateValue(
1001
- item,
1002
- document,
1003
- responses,
1004
- outcomes,
1005
- templateValues,
1006
- correctResponses,
1007
- random,
1008
- customOperators,
1009
- ),
1010
- ),
1023
+ const values = evaluateNumericOperands(
1024
+ expression.expressions,
1025
+ document,
1026
+ responses,
1027
+ outcomes,
1028
+ templateValues,
1029
+ correctResponses,
1030
+ random,
1031
+ customOperators,
1011
1032
  );
1012
- if (values.length === 0) return null;
1013
- const numericValues = values.map((value) => numericValue(value));
1014
- return expression.type === "min" ? Math.min(...numericValues) : Math.max(...numericValues);
1033
+ if (!values || values.length === 0) return null;
1034
+ return expression.type === "min" ? Math.min(...values) : Math.max(...values);
1015
1035
  }
1016
1036
  if (expression.type === "subtract") {
1017
- return (
1018
- numericValue(
1019
- evaluateValue(
1020
- expression.left,
1021
- document,
1022
- responses,
1023
- outcomes,
1024
- templateValues,
1025
- correctResponses,
1026
- random,
1027
- customOperators,
1028
- ),
1029
- ) -
1030
- numericValue(
1031
- evaluateValue(
1032
- expression.right,
1033
- document,
1034
- responses,
1035
- outcomes,
1036
- templateValues,
1037
- correctResponses,
1038
- random,
1039
- customOperators,
1040
- ),
1041
- )
1037
+ const values = evaluateNumericOperands(
1038
+ [expression.left, expression.right],
1039
+ document,
1040
+ responses,
1041
+ outcomes,
1042
+ templateValues,
1043
+ correctResponses,
1044
+ random,
1045
+ customOperators,
1042
1046
  );
1047
+ return values && values.length === 2 ? values[0]! - values[1]! : null;
1043
1048
  }
1044
1049
  if (expression.type === "divide") {
1045
1050
  const dividendValue = evaluateValue(
@@ -1063,38 +1068,25 @@ function evaluateValue(
1063
1068
  customOperators,
1064
1069
  );
1065
1070
  if (dividendValue === null || divisorValue === null) return null;
1066
- const divisor = numericValue(divisorValue);
1067
- if (divisor === 0) return null;
1068
- const quotient = numericValue(dividendValue) / divisor;
1071
+ const dividend = numericValueOrNull(dividendValue);
1072
+ const divisor = numericValueOrNull(divisorValue);
1073
+ if (dividend === null || divisor === null || divisor === 0) return null;
1074
+ const quotient = dividend / divisor;
1069
1075
  return Number.isFinite(quotient) ? quotient : null;
1070
1076
  }
1071
1077
  if (expression.type === "power") {
1072
- const value = Math.pow(
1073
- numericValue(
1074
- evaluateValue(
1075
- expression.left,
1076
- document,
1077
- responses,
1078
- outcomes,
1079
- templateValues,
1080
- correctResponses,
1081
- random,
1082
- customOperators,
1083
- ),
1084
- ),
1085
- numericValue(
1086
- evaluateValue(
1087
- expression.right,
1088
- document,
1089
- responses,
1090
- outcomes,
1091
- templateValues,
1092
- correctResponses,
1093
- random,
1094
- customOperators,
1095
- ),
1096
- ),
1078
+ const values = evaluateNumericOperands(
1079
+ [expression.left, expression.right],
1080
+ document,
1081
+ responses,
1082
+ outcomes,
1083
+ templateValues,
1084
+ correctResponses,
1085
+ random,
1086
+ customOperators,
1097
1087
  );
1088
+ if (!values || values.length !== 2) return null;
1089
+ const value = Math.pow(values[0]!, values[1]!);
1098
1090
  return Number.isFinite(value) ? value : null;
1099
1091
  }
1100
1092
  if (expression.type === "integerDivide") {
@@ -1119,9 +1111,10 @@ function evaluateValue(
1119
1111
  customOperators,
1120
1112
  );
1121
1113
  if (dividendValue === null || divisorValue === null) return null;
1122
- const divisor = numericValue(divisorValue);
1123
- if (divisor === 0) return null;
1124
- return Math.floor(numericValue(dividendValue) / divisor);
1114
+ const dividend = numericValueOrNull(dividendValue);
1115
+ const divisor = numericValueOrNull(divisorValue);
1116
+ if (dividend === null || divisor === null || divisor === 0) return null;
1117
+ return Math.floor(dividend / divisor);
1125
1118
  }
1126
1119
  if (expression.type === "integerModulus") {
1127
1120
  const dividendValue = evaluateValue(
@@ -1145,29 +1138,28 @@ function evaluateValue(
1145
1138
  customOperators,
1146
1139
  );
1147
1140
  if (dividendValue === null || divisorValue === null) return null;
1148
- const divisor = numericValue(divisorValue);
1149
- if (divisor === 0) return null;
1150
- const dividend = numericValue(dividendValue);
1141
+ const dividend = numericValueOrNull(dividendValue);
1142
+ const divisor = numericValueOrNull(divisorValue);
1143
+ if (dividend === null || divisor === null || divisor === 0) return null;
1151
1144
  return dividend - Math.floor(dividend / divisor) * divisor;
1152
1145
  }
1153
1146
  if (expression.type === "round") {
1154
- return Math.round(
1155
- numericValue(
1156
- evaluateValue(
1157
- expression.expression,
1158
- document,
1159
- responses,
1160
- outcomes,
1161
- templateValues,
1162
- correctResponses,
1163
- random,
1164
- customOperators,
1165
- ),
1147
+ const value = numericValueOrNull(
1148
+ evaluateValue(
1149
+ expression.expression,
1150
+ document,
1151
+ responses,
1152
+ outcomes,
1153
+ templateValues,
1154
+ correctResponses,
1155
+ random,
1156
+ customOperators,
1166
1157
  ),
1167
1158
  );
1159
+ return value === null ? null : Math.round(value);
1168
1160
  }
1169
1161
  if (expression.type === "roundTo") {
1170
- const value = numericValue(
1162
+ const value = numericValueOrNull(
1171
1163
  evaluateValue(
1172
1164
  expression.expression,
1173
1165
  document,
@@ -1179,25 +1171,25 @@ function evaluateValue(
1179
1171
  customOperators,
1180
1172
  ),
1181
1173
  );
1174
+ if (value === null) return null;
1182
1175
  return expression.roundingMode === "decimalPlaces"
1183
1176
  ? roundToDecimalPlaces(value, expression.figures)
1184
1177
  : roundToSignificantFigures(value, expression.figures);
1185
1178
  }
1186
1179
  if (expression.type === "truncate") {
1187
- return Math.trunc(
1188
- numericValue(
1189
- evaluateValue(
1190
- expression.expression,
1191
- document,
1192
- responses,
1193
- outcomes,
1194
- templateValues,
1195
- correctResponses,
1196
- random,
1197
- customOperators,
1198
- ),
1180
+ const value = numericValueOrNull(
1181
+ evaluateValue(
1182
+ expression.expression,
1183
+ document,
1184
+ responses,
1185
+ outcomes,
1186
+ templateValues,
1187
+ correctResponses,
1188
+ random,
1189
+ customOperators,
1199
1190
  ),
1200
1191
  );
1192
+ return value === null ? null : Math.trunc(value);
1201
1193
  }
1202
1194
  if (expression.type === "integerToFloat") {
1203
1195
  const value = evaluateValue(
@@ -1210,11 +1202,12 @@ function evaluateValue(
1210
1202
  random,
1211
1203
  customOperators,
1212
1204
  );
1213
- return value === null ? null : numericValue(value);
1205
+ return numericValueOrNull(value);
1214
1206
  }
1215
1207
  if (expression.type === "and") {
1216
- return expression.expressions.every((item) =>
1217
- booleanValue(
1208
+ let sawNull = false;
1209
+ for (const item of expression.expressions) {
1210
+ const value = booleanValueOrNull(
1218
1211
  evaluateValue(
1219
1212
  item,
1220
1213
  document,
@@ -1225,8 +1218,11 @@ function evaluateValue(
1225
1218
  random,
1226
1219
  customOperators,
1227
1220
  ),
1228
- ),
1229
- );
1221
+ );
1222
+ if (value === false) return false;
1223
+ if (value === null) sawNull = true;
1224
+ }
1225
+ return sawNull ? null : true;
1230
1226
  }
1231
1227
  if (expression.type === "anyN") {
1232
1228
  const min = indexValue(expression.min, outcomes, templateValues) ?? 0;
@@ -1250,8 +1246,9 @@ function evaluateValue(
1250
1246
  return null;
1251
1247
  }
1252
1248
  if (expression.type === "or") {
1253
- return expression.expressions.some((item) =>
1254
- booleanValue(
1249
+ let sawNull = false;
1250
+ for (const item of expression.expressions) {
1251
+ const value = booleanValueOrNull(
1255
1252
  evaluateValue(
1256
1253
  item,
1257
1254
  document,
@@ -1262,11 +1259,14 @@ function evaluateValue(
1262
1259
  random,
1263
1260
  customOperators,
1264
1261
  ),
1265
- ),
1266
- );
1262
+ );
1263
+ if (value === true) return true;
1264
+ if (value === null) sawNull = true;
1265
+ }
1266
+ return sawNull ? null : false;
1267
1267
  }
1268
1268
  if (expression.type === "not") {
1269
- return !booleanValue(
1269
+ const value = booleanValueOrNull(
1270
1270
  evaluateValue(
1271
1271
  expression.expression,
1272
1272
  document,
@@ -1278,30 +1278,30 @@ function evaluateValue(
1278
1278
  customOperators,
1279
1279
  ),
1280
1280
  );
1281
+ return value === null ? null : !value;
1281
1282
  }
1282
1283
  if (expression.type === "equal") {
1283
- return valuesEqual(
1284
- evaluateValue(
1285
- expression.left,
1286
- document,
1287
- responses,
1288
- outcomes,
1289
- templateValues,
1290
- correctResponses,
1291
- random,
1292
- customOperators,
1293
- ),
1294
- evaluateValue(
1295
- expression.right,
1296
- document,
1297
- responses,
1298
- outcomes,
1299
- templateValues,
1300
- correctResponses,
1301
- random,
1302
- customOperators,
1303
- ),
1284
+ const left = evaluateValue(
1285
+ expression.left,
1286
+ document,
1287
+ responses,
1288
+ outcomes,
1289
+ templateValues,
1290
+ correctResponses,
1291
+ random,
1292
+ customOperators,
1304
1293
  );
1294
+ const right = evaluateValue(
1295
+ expression.right,
1296
+ document,
1297
+ responses,
1298
+ outcomes,
1299
+ templateValues,
1300
+ correctResponses,
1301
+ random,
1302
+ customOperators,
1303
+ );
1304
+ return left === null || right === null ? null : valuesEqual(left, right);
1305
1305
  }
1306
1306
  if (expression.type === "equalRounded") {
1307
1307
  const left = evaluateValue(
@@ -1325,16 +1325,11 @@ function evaluateValue(
1325
1325
  customOperators,
1326
1326
  );
1327
1327
  if (left === null || right === null) return null;
1328
- const roundedLeft = roundWithMode(
1329
- numericValue(left),
1330
- expression.roundingMode,
1331
- expression.figures,
1332
- );
1333
- const roundedRight = roundWithMode(
1334
- numericValue(right),
1335
- expression.roundingMode,
1336
- expression.figures,
1337
- );
1328
+ const leftNumber = numericValueOrNull(left);
1329
+ const rightNumber = numericValueOrNull(right);
1330
+ if (leftNumber === null || rightNumber === null) return null;
1331
+ const roundedLeft = roundWithMode(leftNumber, expression.roundingMode, expression.figures);
1332
+ const roundedRight = roundWithMode(rightNumber, expression.roundingMode, expression.figures);
1338
1333
  return roundedLeft === null || roundedRight === null ? null : roundedLeft === roundedRight;
1339
1334
  }
1340
1335
  if (expression.type === "numericCompare") {
@@ -1359,8 +1354,9 @@ function evaluateValue(
1359
1354
  customOperators,
1360
1355
  );
1361
1356
  if (leftValue === null || rightValue === null) return null;
1362
- const left = numericValue(leftValue);
1363
- const right = numericValue(rightValue);
1357
+ const left = numericValueOrNull(leftValue);
1358
+ const right = numericValueOrNull(rightValue);
1359
+ if (left === null || right === null) return null;
1364
1360
  if (expression.operator === "lt") return left < right;
1365
1361
  if (expression.operator === "lte") return left <= right;
1366
1362
  if (expression.operator === "gt") return left > right;
@@ -1462,7 +1458,9 @@ function evaluateValue(
1462
1458
  templateValues[expression.pattern] ??
1463
1459
  expression.pattern;
1464
1460
  try {
1465
- return new RegExp(String(patternValue)).test(String(value));
1461
+ return new RegExp(
1462
+ typeof patternValue === "string" ? patternValue : qtiValueToString(patternValue),
1463
+ ).test(qtiValueToString(value));
1466
1464
  } catch {
1467
1465
  return null;
1468
1466
  }
@@ -1501,8 +1499,9 @@ function evaluateValue(
1501
1499
  random,
1502
1500
  customOperators,
1503
1501
  );
1502
+ if (value === null || collection === null) return null;
1504
1503
  const values = valueContainer(collection);
1505
- return value === null ? null : values.some((item) => valuesEqual(item, value));
1504
+ return values.some((item) => valuesEqual(item, value));
1506
1505
  }
1507
1506
  if (expression.type === "delete") {
1508
1507
  const value = evaluateValue(
@@ -1515,47 +1514,46 @@ function evaluateValue(
1515
1514
  random,
1516
1515
  customOperators,
1517
1516
  );
1518
- const collection = valueContainer(
1519
- evaluateValue(
1520
- expression.collection,
1521
- document,
1522
- responses,
1523
- outcomes,
1524
- templateValues,
1525
- correctResponses,
1526
- random,
1527
- customOperators,
1528
- ),
1517
+ const collectionValue = evaluateValue(
1518
+ expression.collection,
1519
+ document,
1520
+ responses,
1521
+ outcomes,
1522
+ templateValues,
1523
+ correctResponses,
1524
+ random,
1525
+ customOperators,
1529
1526
  );
1530
- if (value === null || collection.length === 0) return null;
1527
+ if (value === null || collectionValue === null) return null;
1528
+ const collection = valueContainer(collectionValue);
1529
+ if (collection.length === 0) return null;
1531
1530
  const filtered = collection.filter((item) => !valuesEqual(item, value));
1532
1531
  return filtered.length > 0 ? filtered : null;
1533
1532
  }
1534
1533
  if (expression.type === "contains") {
1535
- const collection = valueContainer(
1536
- evaluateValue(
1537
- expression.collection,
1538
- document,
1539
- responses,
1540
- outcomes,
1541
- templateValues,
1542
- correctResponses,
1543
- random,
1544
- customOperators,
1545
- ),
1534
+ const collectionValue = evaluateValue(
1535
+ expression.collection,
1536
+ document,
1537
+ responses,
1538
+ outcomes,
1539
+ templateValues,
1540
+ correctResponses,
1541
+ random,
1542
+ customOperators,
1546
1543
  );
1547
- const values = valueContainer(
1548
- evaluateValue(
1549
- expression.values,
1550
- document,
1551
- responses,
1552
- outcomes,
1553
- templateValues,
1554
- correctResponses,
1555
- random,
1556
- customOperators,
1557
- ),
1544
+ const valuesValue = evaluateValue(
1545
+ expression.values,
1546
+ document,
1547
+ responses,
1548
+ outcomes,
1549
+ templateValues,
1550
+ correctResponses,
1551
+ random,
1552
+ customOperators,
1558
1553
  );
1554
+ if (collectionValue === null || valuesValue === null) return null;
1555
+ const collection = valueContainer(collectionValue);
1556
+ const values = valueContainer(valuesValue);
1559
1557
  if (collection.length === 0 || values.length === 0) return null;
1560
1558
  return containsValues(collection, values);
1561
1559
  }
@@ -1769,7 +1767,11 @@ function scoreAreaMapping(
1769
1767
  response: QtiValue,
1770
1768
  areaMapping: NonNullable<QtiResponseDeclaration["areaMapping"]>,
1771
1769
  ): number {
1772
- const points = Array.isArray(response) ? response : response === null ? [] : [String(response)];
1770
+ const points = Array.isArray(response)
1771
+ ? response.map(qtiScalarToString)
1772
+ : response === null
1773
+ ? []
1774
+ : qtiValueToStringList(response);
1773
1775
  let score = 0;
1774
1776
  for (const point of points) {
1775
1777
  const parsed = parsePoint(String(point));
@@ -1907,7 +1909,10 @@ function scoreMapping(
1907
1909
  );
1908
1910
  return clampMappedScore(score, mapping.attributes);
1909
1911
  }
1910
- const score = typeof response === "string" ? (values[response] ?? mapping.defaultValue) : 0;
1912
+ const score =
1913
+ response === null || isRecordValue(response)
1914
+ ? 0
1915
+ : (values[String(response)] ?? mapping.defaultValue);
1911
1916
  return clampMappedScore(score, mapping.attributes);
1912
1917
  }
1913
1918
 
@@ -1950,6 +1955,11 @@ function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boo
1950
1955
  return scalarValuesEqual(actual, expected);
1951
1956
  }
1952
1957
 
1958
+ function qtiMatchValues(actual: QtiValue, expected: QtiValue, ordered = false): boolean | null {
1959
+ if (actual === null || expected === null) return null;
1960
+ return valuesEqual(actual, expected, ordered);
1961
+ }
1962
+
1953
1963
  function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
1954
1964
  if (typeof actual === "boolean" && typeof expected === "string") {
1955
1965
  return String(actual) === expected;
@@ -2052,8 +2062,41 @@ function indexValue(
2052
2062
  const parsed = Number(n);
2053
2063
  if (Number.isInteger(parsed)) return parsed;
2054
2064
  const value = outcomes[n] ?? templateValues[n] ?? null;
2055
- const numeric = numericValue(value);
2056
- return Number.isInteger(numeric) ? numeric : undefined;
2065
+ const numeric = numericValueOrNull(value);
2066
+ return numeric !== null && Number.isInteger(numeric) ? numeric : undefined;
2067
+ }
2068
+
2069
+ function evaluateNumericOperands(
2070
+ expressions: QtiProcessingExpression[],
2071
+ document: QtiDocument,
2072
+ responses: Record<string, QtiValue>,
2073
+ outcomes: Record<string, QtiValue>,
2074
+ templateValues: Record<string, QtiValue>,
2075
+ correctResponses: Record<string, QtiValue>,
2076
+ random: () => number,
2077
+ customOperators: QtiCustomOperatorRegistry,
2078
+ ): number[] | null {
2079
+ const numericValues: number[] = [];
2080
+ for (const expression of expressions) {
2081
+ const value = evaluateValue(
2082
+ expression,
2083
+ document,
2084
+ responses,
2085
+ outcomes,
2086
+ templateValues,
2087
+ correctResponses,
2088
+ random,
2089
+ customOperators,
2090
+ );
2091
+ if (value === null || isRecordValue(value)) return null;
2092
+ const values = Array.isArray(value) ? value : [value];
2093
+ for (const item of values) {
2094
+ const numeric = numericValueOrNull(item);
2095
+ if (numeric === null) return null;
2096
+ numericValues.push(numeric);
2097
+ }
2098
+ }
2099
+ return numericValues;
2057
2100
  }
2058
2101
 
2059
2102
  function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
@@ -2077,6 +2120,16 @@ function numericValue(value: QtiValue): number {
2077
2120
  return 0;
2078
2121
  }
2079
2122
 
2123
+ function numericValueOrNull(value: QtiValue): number | null {
2124
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
2125
+ if (typeof value === "boolean") return value ? 1 : 0;
2126
+ if (typeof value === "string") {
2127
+ const parsed = Number(value);
2128
+ return Number.isFinite(parsed) ? parsed : null;
2129
+ }
2130
+ return null;
2131
+ }
2132
+
2080
2133
  function durationSeconds(value: QtiValue): number | null {
2081
2134
  if (value === null || Array.isArray(value) || isRecordValue(value)) return null;
2082
2135
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
@@ -2107,6 +2160,11 @@ function booleanValue(value: QtiValue): boolean {
2107
2160
  return false;
2108
2161
  }
2109
2162
 
2163
+ function booleanValueOrNull(value: QtiValue): boolean | null {
2164
+ if (value === null) return null;
2165
+ return booleanValue(value);
2166
+ }
2167
+
2110
2168
  function roundToDecimalPlaces(value: number, figures: number): number {
2111
2169
  const factor = 10 ** figures;
2112
2170
  return Math.round(value * factor) / factor;
@@ -2256,9 +2314,10 @@ function stringMatch(
2256
2314
  right: QtiValue,
2257
2315
  caseSensitive: boolean,
2258
2316
  substring: boolean,
2259
- ): boolean {
2260
- let actual = String(left ?? "");
2261
- let expected = String(right ?? "");
2317
+ ): boolean | null {
2318
+ if (left === null || right === null) return null;
2319
+ let actual = qtiValueToString(left);
2320
+ let expected = qtiValueToString(right);
2262
2321
  if (!caseSensitive) {
2263
2322
  actual = actual.toLocaleLowerCase();
2264
2323
  expected = expected.toLocaleLowerCase();