@longsightgroup/qti3-core 0.2.1 → 0.4.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,13 @@ import type {
17
17
  QtiValue,
18
18
  QtiVariableDeclaration,
19
19
  } from "./types.js";
20
+ import {
21
+ isQtiPortableCustomStateValue,
22
+ isQtiValue,
23
+ qtiScalarToString,
24
+ qtiValueToString,
25
+ qtiValueToStringList,
26
+ } from "./value-format.js";
20
27
 
21
28
  export interface QtiCustomOperatorContext {
22
29
  definition?: string | undefined;
@@ -64,8 +71,9 @@ export function visibleModalFeedback(
64
71
  return item.modalFeedback.filter((feedback) => {
65
72
  if (feedback.showHide === "hide") return false;
66
73
  const outcome = outcomes[feedback.outcomeIdentifier];
67
- if (Array.isArray(outcome)) return outcome.includes(feedback.identifier);
68
- return String(outcome ?? "") === feedback.identifier;
74
+ const outcomeValue: QtiValue = outcome === undefined ? null : outcome;
75
+ if (Array.isArray(outcomeValue)) return outcomeValue.includes(feedback.identifier);
76
+ return qtiValueToString(outcomeValue) === feedback.identifier;
69
77
  });
70
78
  }
71
79
 
@@ -84,6 +92,7 @@ export function createItemSession(
84
92
  );
85
93
  let validationMessages = cloneDiagnostics(priorState?.validationMessages ?? []);
86
94
  const responses: Record<string, QtiValue> = {};
95
+ const responseDefaults: Record<string, QtiValue> = {};
87
96
  const outcomes: Record<string, QtiValue> = {};
88
97
  const templateValues: Record<string, QtiValue> = {};
89
98
  const interactionStates: Record<string, QtiPortableCustomStateValue> = {};
@@ -100,8 +109,8 @@ export function createItemSession(
100
109
 
101
110
  for (const declaration of document.item.responseDeclarations) {
102
111
  correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
103
- if (declaration.defaultValue !== null && responses[declaration.identifier] === undefined) {
104
- responses[declaration.identifier] = cloneValue(declaration.defaultValue);
112
+ if (declaration.defaultValue !== null) {
113
+ responseDefaults[declaration.identifier] = cloneValue(declaration.defaultValue);
105
114
  }
106
115
  }
107
116
  for (const declaration of document.item.templateDeclarations) {
@@ -112,18 +121,21 @@ export function createItemSession(
112
121
  }
113
122
  outcomes[COMPLETION_STATUS] = COMPLETION_NOT_ATTEMPTED;
114
123
  const baseResponses = cloneValueRecord(responses);
124
+ const baseResponseDefaults = cloneValueRecord(responseDefaults);
115
125
  const baseOutcomes = cloneValueRecord(outcomes);
116
126
 
117
127
  applyTemplateProcessing(
118
128
  document,
119
129
  templateValues,
120
130
  responses,
131
+ responseDefaults,
121
132
  outcomes,
122
133
  correctResponses,
123
134
  random,
124
135
  customOperators,
125
136
  new Set(),
126
137
  baseResponses,
138
+ baseResponseDefaults,
127
139
  baseOutcomes,
128
140
  );
129
141
  if (priorState) {
@@ -133,12 +145,14 @@ export function createItemSession(
133
145
  document,
134
146
  templateValues,
135
147
  responses,
148
+ responseDefaults,
136
149
  outcomes,
137
150
  correctResponses,
138
151
  random,
139
152
  customOperators,
140
153
  new Set(Object.keys(priorTemplateValues)),
141
154
  baseResponses,
155
+ baseResponseDefaults,
142
156
  baseOutcomes,
143
157
  );
144
158
  }
@@ -174,7 +188,7 @@ export function createItemSession(
174
188
  },
175
189
  score() {
176
190
  const diagnostics: QtiDiagnostic[] = [];
177
- if (document.item.adaptive || status !== "initialized" || Object.keys(responses).length > 0) {
191
+ if (document.item.adaptive || status !== "initialized") {
178
192
  startAttempt();
179
193
  }
180
194
  const completionStatus = outcomes[COMPLETION_STATUS] ?? COMPLETION_NOT_ATTEMPTED;
@@ -218,6 +232,9 @@ export function createItemSession(
218
232
  };
219
233
 
220
234
  function startAttempt(): void {
235
+ for (const [identifier, value] of Object.entries(responseDefaults)) {
236
+ if (responses[identifier] === undefined) responses[identifier] = cloneValue(value);
237
+ }
221
238
  if (status === "initialized" || status === "suspended") status = "interacting";
222
239
  if (outcomes[COMPLETION_STATUS] === COMPLETION_NOT_ATTEMPTED) {
223
240
  outcomes[COMPLETION_STATUS] = COMPLETION_UNKNOWN;
@@ -297,7 +314,9 @@ function assertCompatiblePriorState(
297
314
  completionStatus !== COMPLETION_COMPLETED &&
298
315
  completionStatus !== "incomplete"
299
316
  ) {
300
- throw new Error(`Cannot restore unsupported completionStatus ${String(completionStatus)}.`);
317
+ throw new Error(
318
+ `Cannot restore unsupported completionStatus ${qtiValueToString(completionStatus)}.`,
319
+ );
301
320
  }
302
321
  }
303
322
 
@@ -431,12 +450,14 @@ function applyTemplateProcessing(
431
450
  document: QtiDocument,
432
451
  templateValues: Record<string, QtiValue>,
433
452
  responses: Record<string, QtiValue>,
453
+ responseDefaults: Record<string, QtiValue>,
434
454
  outcomes: Record<string, QtiValue>,
435
455
  correctResponses: Record<string, QtiValue>,
436
456
  random: () => number,
437
457
  customOperators: QtiCustomOperatorRegistry,
438
458
  preservedTemplateIdentifiers = new Set<string>(),
439
459
  baseResponses: Record<string, QtiValue> = cloneValueRecord(responses),
460
+ baseResponseDefaults: Record<string, QtiValue> = cloneValueRecord(responseDefaults),
440
461
  baseOutcomes: Record<string, QtiValue> = cloneValueRecord(outcomes),
441
462
  ): void {
442
463
  const rules = document.item.templateProcessing?.rules ?? [];
@@ -448,6 +469,7 @@ function applyTemplateProcessing(
448
469
  document,
449
470
  templateValues,
450
471
  responses,
472
+ responseDefaults,
451
473
  outcomes,
452
474
  correctResponses,
453
475
  random,
@@ -469,6 +491,7 @@ function applyTemplateProcessing(
469
491
  if (!satisfied) {
470
492
  resetTemplateValues(document, templateValues);
471
493
  resetRecord(responses, cloneValueRecord(baseResponses));
494
+ resetRecord(responseDefaults, cloneValueRecord(baseResponseDefaults));
472
495
  resetRecord(outcomes, cloneValueRecord(baseOutcomes));
473
496
  resetCorrectResponses(document, correctResponses);
474
497
  restarts += 1;
@@ -504,6 +527,7 @@ function applyTemplateRule(
504
527
  document: QtiDocument,
505
528
  templateValues: Record<string, QtiValue>,
506
529
  responses: Record<string, QtiValue>,
530
+ responseDefaults: Record<string, QtiValue>,
507
531
  outcomes: Record<string, QtiValue>,
508
532
  correctResponses: Record<string, QtiValue>,
509
533
  random: () => number,
@@ -552,6 +576,7 @@ function applyTemplateRule(
552
576
  document,
553
577
  templateValues,
554
578
  responses,
579
+ responseDefaults,
555
580
  outcomes,
556
581
  correctResponses,
557
582
  random,
@@ -583,7 +608,11 @@ function applyTemplateRule(
583
608
  const responseDeclaration = getResponseDeclaration(document, rule.identifier);
584
609
  if (responseDeclaration) {
585
610
  const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
586
- responses[rule.identifier] = normalized;
611
+ if (normalized === null) {
612
+ delete responseDefaults[rule.identifier];
613
+ } else {
614
+ responseDefaults[rule.identifier] = normalized;
615
+ }
587
616
  return false;
588
617
  }
589
618
  const outcomeDeclaration = document.item.outcomeDeclarations.find(
@@ -628,33 +657,45 @@ function applyResponseProcessing(
628
657
  return;
629
658
  }
630
659
 
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;
660
+ const templateKind = responseProcessingTemplateKind(processing?.template);
661
+ if (templateKind === "unsupported") {
662
+ return;
663
+ }
664
+ if (templateKind === "mapResponse" || templateKind === "mapResponsePoint") {
665
+ const declaration = getResponseDeclaration(document, "RESPONSE");
666
+ outcomes.SCORE = declaration
667
+ ? mapOrMatchResponse(
668
+ declaration,
669
+ responses.RESPONSE ?? null,
670
+ correctResponses.RESPONSE ?? null,
671
+ )
672
+ : 0;
642
673
  return;
643
674
  }
644
675
 
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
- }
676
+ if (templateKind === "matchCorrect") {
677
+ const declaration = getResponseDeclaration(document, "RESPONSE");
678
+ const matches = declaration
679
+ ? qtiMatchValues(
680
+ responses.RESPONSE ?? null,
681
+ correctResponses.RESPONSE ?? null,
682
+ declaration.cardinality === "ordered",
683
+ )
684
+ : null;
685
+ outcomes.SCORE = matches === true ? 1 : 0;
656
686
  }
657
- if (scored) outcomes.SCORE = score;
687
+ }
688
+
689
+ function responseProcessingTemplateKind(
690
+ template: string | undefined,
691
+ ): "matchCorrect" | "mapResponse" | "mapResponsePoint" | "unsupported" | undefined {
692
+ if (!template) return undefined;
693
+ const path = template.split(/[?#]/, 1)[0] ?? "";
694
+ const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
695
+ if (name === "match_correct") return "matchCorrect";
696
+ if (name === "map_response") return "mapResponse";
697
+ if (name === "map_response_point") return "mapResponsePoint";
698
+ return "unsupported";
658
699
  }
659
700
 
660
701
  function applyResponseRules(
@@ -823,7 +864,7 @@ function evaluateValue(
823
864
  if (expression.type === "matchCorrect") {
824
865
  const declaration = getResponseDeclaration(document, expression.correctIdentifier);
825
866
  return declaration
826
- ? valuesEqual(
867
+ ? qtiMatchValues(
827
868
  responses[expression.identifier] ?? null,
828
869
  correctResponses[expression.correctIdentifier] ?? null,
829
870
  declaration.cardinality === "ordered",
@@ -831,27 +872,29 @@ function evaluateValue(
831
872
  : false;
832
873
  }
833
874
  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
- ),
875
+ const left = evaluateValue(
876
+ expression.left,
877
+ document,
878
+ responses,
879
+ outcomes,
880
+ templateValues,
881
+ correctResponses,
882
+ random,
883
+ customOperators,
884
+ );
885
+ const right = evaluateValue(
886
+ expression.right,
887
+ document,
888
+ responses,
889
+ outcomes,
890
+ templateValues,
891
+ correctResponses,
892
+ random,
893
+ customOperators,
894
+ );
895
+ return qtiMatchValues(
896
+ left,
897
+ right,
855
898
  expressionIsOrdered(expression.left, document) ||
856
899
  expressionIsOrdered(expression.right, document),
857
900
  );
@@ -957,89 +1000,57 @@ function evaluateValue(
957
1000
  ).length;
958
1001
  }
959
1002
  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,
1003
+ const values = evaluateNumericOperands(
1004
+ expression.expressions,
1005
+ document,
1006
+ responses,
1007
+ outcomes,
1008
+ templateValues,
1009
+ correctResponses,
1010
+ random,
1011
+ customOperators,
976
1012
  );
1013
+ return values ? values.reduce((sum, value) => sum + value, 0) : null;
977
1014
  }
978
1015
  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,
1016
+ const values = evaluateNumericOperands(
1017
+ expression.expressions,
1018
+ document,
1019
+ responses,
1020
+ outcomes,
1021
+ templateValues,
1022
+ correctResponses,
1023
+ random,
1024
+ customOperators,
995
1025
  );
1026
+ return values ? values.reduce((product, value) => product * value, 1) : null;
996
1027
  }
997
1028
  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
- ),
1029
+ const values = evaluateNumericOperands(
1030
+ expression.expressions,
1031
+ document,
1032
+ responses,
1033
+ outcomes,
1034
+ templateValues,
1035
+ correctResponses,
1036
+ random,
1037
+ customOperators,
1011
1038
  );
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);
1039
+ if (!values || values.length === 0) return null;
1040
+ return expression.type === "min" ? Math.min(...values) : Math.max(...values);
1015
1041
  }
1016
1042
  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
- )
1043
+ const values = evaluateNumericOperands(
1044
+ [expression.left, expression.right],
1045
+ document,
1046
+ responses,
1047
+ outcomes,
1048
+ templateValues,
1049
+ correctResponses,
1050
+ random,
1051
+ customOperators,
1042
1052
  );
1053
+ return values && values.length === 2 ? values[0]! - values[1]! : null;
1043
1054
  }
1044
1055
  if (expression.type === "divide") {
1045
1056
  const dividendValue = evaluateValue(
@@ -1063,38 +1074,25 @@ function evaluateValue(
1063
1074
  customOperators,
1064
1075
  );
1065
1076
  if (dividendValue === null || divisorValue === null) return null;
1066
- const divisor = numericValue(divisorValue);
1067
- if (divisor === 0) return null;
1068
- const quotient = numericValue(dividendValue) / divisor;
1077
+ const dividend = numericValueOrNull(dividendValue);
1078
+ const divisor = numericValueOrNull(divisorValue);
1079
+ if (dividend === null || divisor === null || divisor === 0) return null;
1080
+ const quotient = dividend / divisor;
1069
1081
  return Number.isFinite(quotient) ? quotient : null;
1070
1082
  }
1071
1083
  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
- ),
1084
+ const values = evaluateNumericOperands(
1085
+ [expression.left, expression.right],
1086
+ document,
1087
+ responses,
1088
+ outcomes,
1089
+ templateValues,
1090
+ correctResponses,
1091
+ random,
1092
+ customOperators,
1097
1093
  );
1094
+ if (!values || values.length !== 2) return null;
1095
+ const value = Math.pow(values[0]!, values[1]!);
1098
1096
  return Number.isFinite(value) ? value : null;
1099
1097
  }
1100
1098
  if (expression.type === "integerDivide") {
@@ -1119,9 +1117,10 @@ function evaluateValue(
1119
1117
  customOperators,
1120
1118
  );
1121
1119
  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);
1120
+ const dividend = numericValueOrNull(dividendValue);
1121
+ const divisor = numericValueOrNull(divisorValue);
1122
+ if (dividend === null || divisor === null || divisor === 0) return null;
1123
+ return Math.floor(dividend / divisor);
1125
1124
  }
1126
1125
  if (expression.type === "integerModulus") {
1127
1126
  const dividendValue = evaluateValue(
@@ -1145,29 +1144,28 @@ function evaluateValue(
1145
1144
  customOperators,
1146
1145
  );
1147
1146
  if (dividendValue === null || divisorValue === null) return null;
1148
- const divisor = numericValue(divisorValue);
1149
- if (divisor === 0) return null;
1150
- const dividend = numericValue(dividendValue);
1147
+ const dividend = numericValueOrNull(dividendValue);
1148
+ const divisor = numericValueOrNull(divisorValue);
1149
+ if (dividend === null || divisor === null || divisor === 0) return null;
1151
1150
  return dividend - Math.floor(dividend / divisor) * divisor;
1152
1151
  }
1153
1152
  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
- ),
1153
+ const value = numericValueOrNull(
1154
+ evaluateValue(
1155
+ expression.expression,
1156
+ document,
1157
+ responses,
1158
+ outcomes,
1159
+ templateValues,
1160
+ correctResponses,
1161
+ random,
1162
+ customOperators,
1166
1163
  ),
1167
1164
  );
1165
+ return value === null ? null : Math.round(value);
1168
1166
  }
1169
1167
  if (expression.type === "roundTo") {
1170
- const value = numericValue(
1168
+ const value = numericValueOrNull(
1171
1169
  evaluateValue(
1172
1170
  expression.expression,
1173
1171
  document,
@@ -1179,25 +1177,25 @@ function evaluateValue(
1179
1177
  customOperators,
1180
1178
  ),
1181
1179
  );
1180
+ if (value === null) return null;
1182
1181
  return expression.roundingMode === "decimalPlaces"
1183
1182
  ? roundToDecimalPlaces(value, expression.figures)
1184
1183
  : roundToSignificantFigures(value, expression.figures);
1185
1184
  }
1186
1185
  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
- ),
1186
+ const value = numericValueOrNull(
1187
+ evaluateValue(
1188
+ expression.expression,
1189
+ document,
1190
+ responses,
1191
+ outcomes,
1192
+ templateValues,
1193
+ correctResponses,
1194
+ random,
1195
+ customOperators,
1199
1196
  ),
1200
1197
  );
1198
+ return value === null ? null : Math.trunc(value);
1201
1199
  }
1202
1200
  if (expression.type === "integerToFloat") {
1203
1201
  const value = evaluateValue(
@@ -1210,11 +1208,12 @@ function evaluateValue(
1210
1208
  random,
1211
1209
  customOperators,
1212
1210
  );
1213
- return value === null ? null : numericValue(value);
1211
+ return numericValueOrNull(value);
1214
1212
  }
1215
1213
  if (expression.type === "and") {
1216
- return expression.expressions.every((item) =>
1217
- booleanValue(
1214
+ let sawNull = false;
1215
+ for (const item of expression.expressions) {
1216
+ const value = booleanValueOrNull(
1218
1217
  evaluateValue(
1219
1218
  item,
1220
1219
  document,
@@ -1225,8 +1224,11 @@ function evaluateValue(
1225
1224
  random,
1226
1225
  customOperators,
1227
1226
  ),
1228
- ),
1229
- );
1227
+ );
1228
+ if (value === false) return false;
1229
+ if (value === null) sawNull = true;
1230
+ }
1231
+ return sawNull ? null : true;
1230
1232
  }
1231
1233
  if (expression.type === "anyN") {
1232
1234
  const min = indexValue(expression.min, outcomes, templateValues) ?? 0;
@@ -1250,8 +1252,9 @@ function evaluateValue(
1250
1252
  return null;
1251
1253
  }
1252
1254
  if (expression.type === "or") {
1253
- return expression.expressions.some((item) =>
1254
- booleanValue(
1255
+ let sawNull = false;
1256
+ for (const item of expression.expressions) {
1257
+ const value = booleanValueOrNull(
1255
1258
  evaluateValue(
1256
1259
  item,
1257
1260
  document,
@@ -1262,11 +1265,14 @@ function evaluateValue(
1262
1265
  random,
1263
1266
  customOperators,
1264
1267
  ),
1265
- ),
1266
- );
1268
+ );
1269
+ if (value === true) return true;
1270
+ if (value === null) sawNull = true;
1271
+ }
1272
+ return sawNull ? null : false;
1267
1273
  }
1268
1274
  if (expression.type === "not") {
1269
- return !booleanValue(
1275
+ const value = booleanValueOrNull(
1270
1276
  evaluateValue(
1271
1277
  expression.expression,
1272
1278
  document,
@@ -1278,30 +1284,30 @@ function evaluateValue(
1278
1284
  customOperators,
1279
1285
  ),
1280
1286
  );
1287
+ return value === null ? null : !value;
1281
1288
  }
1282
1289
  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
- ),
1290
+ const left = evaluateValue(
1291
+ expression.left,
1292
+ document,
1293
+ responses,
1294
+ outcomes,
1295
+ templateValues,
1296
+ correctResponses,
1297
+ random,
1298
+ customOperators,
1304
1299
  );
1300
+ const right = evaluateValue(
1301
+ expression.right,
1302
+ document,
1303
+ responses,
1304
+ outcomes,
1305
+ templateValues,
1306
+ correctResponses,
1307
+ random,
1308
+ customOperators,
1309
+ );
1310
+ return left === null || right === null ? null : valuesEqual(left, right);
1305
1311
  }
1306
1312
  if (expression.type === "equalRounded") {
1307
1313
  const left = evaluateValue(
@@ -1325,16 +1331,11 @@ function evaluateValue(
1325
1331
  customOperators,
1326
1332
  );
1327
1333
  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
- );
1334
+ const leftNumber = numericValueOrNull(left);
1335
+ const rightNumber = numericValueOrNull(right);
1336
+ if (leftNumber === null || rightNumber === null) return null;
1337
+ const roundedLeft = roundWithMode(leftNumber, expression.roundingMode, expression.figures);
1338
+ const roundedRight = roundWithMode(rightNumber, expression.roundingMode, expression.figures);
1338
1339
  return roundedLeft === null || roundedRight === null ? null : roundedLeft === roundedRight;
1339
1340
  }
1340
1341
  if (expression.type === "numericCompare") {
@@ -1359,8 +1360,9 @@ function evaluateValue(
1359
1360
  customOperators,
1360
1361
  );
1361
1362
  if (leftValue === null || rightValue === null) return null;
1362
- const left = numericValue(leftValue);
1363
- const right = numericValue(rightValue);
1363
+ const left = numericValueOrNull(leftValue);
1364
+ const right = numericValueOrNull(rightValue);
1365
+ if (left === null || right === null) return null;
1364
1366
  if (expression.operator === "lt") return left < right;
1365
1367
  if (expression.operator === "lte") return left <= right;
1366
1368
  if (expression.operator === "gt") return left > right;
@@ -1462,7 +1464,9 @@ function evaluateValue(
1462
1464
  templateValues[expression.pattern] ??
1463
1465
  expression.pattern;
1464
1466
  try {
1465
- return new RegExp(String(patternValue)).test(String(value));
1467
+ return new RegExp(
1468
+ typeof patternValue === "string" ? patternValue : qtiValueToString(patternValue),
1469
+ ).test(qtiValueToString(value));
1466
1470
  } catch {
1467
1471
  return null;
1468
1472
  }
@@ -1501,8 +1505,9 @@ function evaluateValue(
1501
1505
  random,
1502
1506
  customOperators,
1503
1507
  );
1508
+ if (value === null || collection === null) return null;
1504
1509
  const values = valueContainer(collection);
1505
- return value === null ? null : values.some((item) => valuesEqual(item, value));
1510
+ return values.some((item) => valuesEqual(item, value));
1506
1511
  }
1507
1512
  if (expression.type === "delete") {
1508
1513
  const value = evaluateValue(
@@ -1515,47 +1520,46 @@ function evaluateValue(
1515
1520
  random,
1516
1521
  customOperators,
1517
1522
  );
1518
- const collection = valueContainer(
1519
- evaluateValue(
1520
- expression.collection,
1521
- document,
1522
- responses,
1523
- outcomes,
1524
- templateValues,
1525
- correctResponses,
1526
- random,
1527
- customOperators,
1528
- ),
1523
+ const collectionValue = evaluateValue(
1524
+ expression.collection,
1525
+ document,
1526
+ responses,
1527
+ outcomes,
1528
+ templateValues,
1529
+ correctResponses,
1530
+ random,
1531
+ customOperators,
1529
1532
  );
1530
- if (value === null || collection.length === 0) return null;
1533
+ if (value === null || collectionValue === null) return null;
1534
+ const collection = valueContainer(collectionValue);
1535
+ if (collection.length === 0) return null;
1531
1536
  const filtered = collection.filter((item) => !valuesEqual(item, value));
1532
1537
  return filtered.length > 0 ? filtered : null;
1533
1538
  }
1534
1539
  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
- ),
1540
+ const collectionValue = evaluateValue(
1541
+ expression.collection,
1542
+ document,
1543
+ responses,
1544
+ outcomes,
1545
+ templateValues,
1546
+ correctResponses,
1547
+ random,
1548
+ customOperators,
1546
1549
  );
1547
- const values = valueContainer(
1548
- evaluateValue(
1549
- expression.values,
1550
- document,
1551
- responses,
1552
- outcomes,
1553
- templateValues,
1554
- correctResponses,
1555
- random,
1556
- customOperators,
1557
- ),
1550
+ const valuesValue = evaluateValue(
1551
+ expression.values,
1552
+ document,
1553
+ responses,
1554
+ outcomes,
1555
+ templateValues,
1556
+ correctResponses,
1557
+ random,
1558
+ customOperators,
1558
1559
  );
1560
+ if (collectionValue === null || valuesValue === null) return null;
1561
+ const collection = valueContainer(collectionValue);
1562
+ const values = valueContainer(valuesValue);
1559
1563
  if (collection.length === 0 || values.length === 0) return null;
1560
1564
  return containsValues(collection, values);
1561
1565
  }
@@ -1769,7 +1773,11 @@ function scoreAreaMapping(
1769
1773
  response: QtiValue,
1770
1774
  areaMapping: NonNullable<QtiResponseDeclaration["areaMapping"]>,
1771
1775
  ): number {
1772
- const points = Array.isArray(response) ? response : response === null ? [] : [String(response)];
1776
+ const points = Array.isArray(response)
1777
+ ? response.map(qtiScalarToString)
1778
+ : response === null
1779
+ ? []
1780
+ : qtiValueToStringList(response);
1773
1781
  let score = 0;
1774
1782
  for (const point of points) {
1775
1783
  const parsed = parsePoint(String(point));
@@ -1907,7 +1915,10 @@ function scoreMapping(
1907
1915
  );
1908
1916
  return clampMappedScore(score, mapping.attributes);
1909
1917
  }
1910
- const score = typeof response === "string" ? (values[response] ?? mapping.defaultValue) : 0;
1918
+ const score =
1919
+ response === null || isRecordValue(response)
1920
+ ? 0
1921
+ : (values[String(response)] ?? mapping.defaultValue);
1911
1922
  return clampMappedScore(score, mapping.attributes);
1912
1923
  }
1913
1924
 
@@ -1950,6 +1961,11 @@ function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boo
1950
1961
  return scalarValuesEqual(actual, expected);
1951
1962
  }
1952
1963
 
1964
+ function qtiMatchValues(actual: QtiValue, expected: QtiValue, ordered = false): boolean | null {
1965
+ if (actual === null || expected === null) return null;
1966
+ return valuesEqual(actual, expected, ordered);
1967
+ }
1968
+
1953
1969
  function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
1954
1970
  if (typeof actual === "boolean" && typeof expected === "string") {
1955
1971
  return String(actual) === expected;
@@ -1985,33 +2001,13 @@ function isRecordValue(value: QtiValue): value is QtiRecordValue {
1985
2001
  return typeof value === "object" && value !== null && !Array.isArray(value);
1986
2002
  }
1987
2003
 
1988
- function isQtiValue(value: unknown): value is QtiValue {
1989
- if (value === null) return true;
1990
- if (isQtiScalarValue(value)) return true;
1991
- if (Array.isArray(value)) return value.every(isQtiScalarValue);
1992
- return isQtiValueRecord(value);
1993
- }
1994
-
1995
- function isQtiScalarValue(value: unknown): value is QtiScalarValue {
1996
- return (
1997
- typeof value === "string" ||
1998
- typeof value === "boolean" ||
1999
- (typeof value === "number" && Number.isFinite(value))
2000
- );
2001
- }
2002
-
2003
2004
  function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
2004
2005
  if (!isRecord(value)) return false;
2005
2006
  return Object.values(value).every(isQtiValue);
2006
2007
  }
2007
2008
 
2008
2009
  function isPortableCustomState(value: unknown): value is QtiPortableCustomStateValue {
2009
- if (value === null) return true;
2010
- if (typeof value === "string" || typeof value === "boolean") return true;
2011
- if (typeof value === "number") return Number.isFinite(value);
2012
- if (Array.isArray(value)) return value.every(isPortableCustomState);
2013
- if (isRecord(value)) return Object.values(value).every(isPortableCustomState);
2014
- return false;
2010
+ return isQtiPortableCustomStateValue(value);
2015
2011
  }
2016
2012
 
2017
2013
  function isPortableCustomStateObject(
@@ -2052,8 +2048,41 @@ function indexValue(
2052
2048
  const parsed = Number(n);
2053
2049
  if (Number.isInteger(parsed)) return parsed;
2054
2050
  const value = outcomes[n] ?? templateValues[n] ?? null;
2055
- const numeric = numericValue(value);
2056
- return Number.isInteger(numeric) ? numeric : undefined;
2051
+ const numeric = numericValueOrNull(value);
2052
+ return numeric !== null && Number.isInteger(numeric) ? numeric : undefined;
2053
+ }
2054
+
2055
+ function evaluateNumericOperands(
2056
+ expressions: QtiProcessingExpression[],
2057
+ document: QtiDocument,
2058
+ responses: Record<string, QtiValue>,
2059
+ outcomes: Record<string, QtiValue>,
2060
+ templateValues: Record<string, QtiValue>,
2061
+ correctResponses: Record<string, QtiValue>,
2062
+ random: () => number,
2063
+ customOperators: QtiCustomOperatorRegistry,
2064
+ ): number[] | null {
2065
+ const numericValues: number[] = [];
2066
+ for (const expression of expressions) {
2067
+ const value = evaluateValue(
2068
+ expression,
2069
+ document,
2070
+ responses,
2071
+ outcomes,
2072
+ templateValues,
2073
+ correctResponses,
2074
+ random,
2075
+ customOperators,
2076
+ );
2077
+ if (value === null || isRecordValue(value)) return null;
2078
+ const values = Array.isArray(value) ? value : [value];
2079
+ for (const item of values) {
2080
+ const numeric = numericValueOrNull(item);
2081
+ if (numeric === null) return null;
2082
+ numericValues.push(numeric);
2083
+ }
2084
+ }
2085
+ return numericValues;
2057
2086
  }
2058
2087
 
2059
2088
  function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
@@ -2077,6 +2106,16 @@ function numericValue(value: QtiValue): number {
2077
2106
  return 0;
2078
2107
  }
2079
2108
 
2109
+ function numericValueOrNull(value: QtiValue): number | null {
2110
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
2111
+ if (typeof value === "boolean") return value ? 1 : 0;
2112
+ if (typeof value === "string") {
2113
+ const parsed = Number(value);
2114
+ return Number.isFinite(parsed) ? parsed : null;
2115
+ }
2116
+ return null;
2117
+ }
2118
+
2080
2119
  function durationSeconds(value: QtiValue): number | null {
2081
2120
  if (value === null || Array.isArray(value) || isRecordValue(value)) return null;
2082
2121
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
@@ -2107,6 +2146,11 @@ function booleanValue(value: QtiValue): boolean {
2107
2146
  return false;
2108
2147
  }
2109
2148
 
2149
+ function booleanValueOrNull(value: QtiValue): boolean | null {
2150
+ if (value === null) return null;
2151
+ return booleanValue(value);
2152
+ }
2153
+
2110
2154
  function roundToDecimalPlaces(value: number, figures: number): number {
2111
2155
  const factor = 10 ** figures;
2112
2156
  return Math.round(value * factor) / factor;
@@ -2256,9 +2300,10 @@ function stringMatch(
2256
2300
  right: QtiValue,
2257
2301
  caseSensitive: boolean,
2258
2302
  substring: boolean,
2259
- ): boolean {
2260
- let actual = String(left ?? "");
2261
- let expected = String(right ?? "");
2303
+ ): boolean | null {
2304
+ if (left === null || right === null) return null;
2305
+ let actual = qtiValueToString(left);
2306
+ let expected = qtiValueToString(right);
2262
2307
  if (!caseSensitive) {
2263
2308
  actual = actual.toLocaleLowerCase();
2264
2309
  expected = expected.toLocaleLowerCase();