@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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +18 -3
- package/dist/parser.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +191 -80
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +136 -3
- package/dist/validation.js.map +1 -1
- package/dist/value-format.d.ts +7 -0
- package/dist/value-format.d.ts.map +1 -0
- package/dist/value-format.js +46 -0
- package/dist/value-format.js.map +1 -0
- package/dist/xml.d.ts +2 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +2 -0
- package/dist/xml.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/parser.ts +19 -3
- package/src/session.ts +329 -270
- package/src/types.ts +3 -0
- package/src/validation.ts +151 -2
- package/src/value-format.ts +39 -0
- package/src/xml.ts +4 -0
package/dist/session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { qtiScalarToString, qtiValueToString, qtiValueToStringList } from "./value-format.js";
|
|
1
2
|
const COMPLETION_STATUS = "completionStatus";
|
|
2
3
|
const COMPLETION_NOT_ATTEMPTED = "not_attempted";
|
|
3
4
|
const COMPLETION_UNKNOWN = "unknown";
|
|
@@ -14,9 +15,10 @@ export function visibleModalFeedback(item, outcomes) {
|
|
|
14
15
|
if (feedback.showHide === "hide")
|
|
15
16
|
return false;
|
|
16
17
|
const outcome = outcomes[feedback.outcomeIdentifier];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const outcomeValue = outcome === undefined ? null : outcome;
|
|
19
|
+
if (Array.isArray(outcomeValue))
|
|
20
|
+
return outcomeValue.includes(feedback.identifier);
|
|
21
|
+
return qtiValueToString(outcomeValue) === feedback.identifier;
|
|
20
22
|
});
|
|
21
23
|
}
|
|
22
24
|
export function createItemSession(document, priorState, options = {}) {
|
|
@@ -27,6 +29,7 @@ export function createItemSession(document, priorState, options = {}) {
|
|
|
27
29
|
const priorInteractionStates = clonePortableCustomStateRecord(priorState?.interactionStates ?? {});
|
|
28
30
|
let validationMessages = cloneDiagnostics(priorState?.validationMessages ?? []);
|
|
29
31
|
const responses = {};
|
|
32
|
+
const responseDefaults = {};
|
|
30
33
|
const outcomes = {};
|
|
31
34
|
const templateValues = {};
|
|
32
35
|
const interactionStates = {};
|
|
@@ -40,8 +43,8 @@ export function createItemSession(document, priorState, options = {}) {
|
|
|
40
43
|
.filter((identifier) => Boolean(identifier)));
|
|
41
44
|
for (const declaration of document.item.responseDeclarations) {
|
|
42
45
|
correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
|
|
43
|
-
if (declaration.defaultValue !== null
|
|
44
|
-
|
|
46
|
+
if (declaration.defaultValue !== null) {
|
|
47
|
+
responseDefaults[declaration.identifier] = cloneValue(declaration.defaultValue);
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
for (const declaration of document.item.templateDeclarations) {
|
|
@@ -52,12 +55,13 @@ export function createItemSession(document, priorState, options = {}) {
|
|
|
52
55
|
}
|
|
53
56
|
outcomes[COMPLETION_STATUS] = COMPLETION_NOT_ATTEMPTED;
|
|
54
57
|
const baseResponses = cloneValueRecord(responses);
|
|
58
|
+
const baseResponseDefaults = cloneValueRecord(responseDefaults);
|
|
55
59
|
const baseOutcomes = cloneValueRecord(outcomes);
|
|
56
|
-
applyTemplateProcessing(document, templateValues, responses, outcomes, correctResponses, random, customOperators, new Set(), baseResponses, baseOutcomes);
|
|
60
|
+
applyTemplateProcessing(document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, new Set(), baseResponses, baseResponseDefaults, baseOutcomes);
|
|
57
61
|
if (priorState) {
|
|
58
62
|
Object.assign(templateValues, priorTemplateValues);
|
|
59
63
|
resetCorrectResponses(document, correctResponses);
|
|
60
|
-
applyTemplateProcessing(document, templateValues, responses, outcomes, correctResponses, random, customOperators, new Set(Object.keys(priorTemplateValues)), baseResponses, baseOutcomes);
|
|
64
|
+
applyTemplateProcessing(document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, new Set(Object.keys(priorTemplateValues)), baseResponses, baseResponseDefaults, baseOutcomes);
|
|
61
65
|
}
|
|
62
66
|
const defaultOutcomes = cloneValueRecord(outcomes);
|
|
63
67
|
Object.assign(responses, priorResponses);
|
|
@@ -90,7 +94,7 @@ export function createItemSession(document, priorState, options = {}) {
|
|
|
90
94
|
},
|
|
91
95
|
score() {
|
|
92
96
|
const diagnostics = [];
|
|
93
|
-
if (document.item.adaptive || status !== "initialized"
|
|
97
|
+
if (document.item.adaptive || status !== "initialized") {
|
|
94
98
|
startAttempt();
|
|
95
99
|
}
|
|
96
100
|
const completionStatus = outcomes[COMPLETION_STATUS] ?? COMPLETION_NOT_ATTEMPTED;
|
|
@@ -110,6 +114,10 @@ export function createItemSession(document, priorState, options = {}) {
|
|
|
110
114
|
},
|
|
111
115
|
};
|
|
112
116
|
function startAttempt() {
|
|
117
|
+
for (const [identifier, value] of Object.entries(responseDefaults)) {
|
|
118
|
+
if (responses[identifier] === undefined)
|
|
119
|
+
responses[identifier] = cloneValue(value);
|
|
120
|
+
}
|
|
113
121
|
if (status === "initialized" || status === "suspended")
|
|
114
122
|
status = "interacting";
|
|
115
123
|
if (outcomes[COMPLETION_STATUS] === COMPLETION_NOT_ATTEMPTED) {
|
|
@@ -172,7 +180,7 @@ function assertCompatiblePriorState(document, priorState) {
|
|
|
172
180
|
completionStatus !== COMPLETION_UNKNOWN &&
|
|
173
181
|
completionStatus !== COMPLETION_COMPLETED &&
|
|
174
182
|
completionStatus !== "incomplete") {
|
|
175
|
-
throw new Error(`Cannot restore unsupported completionStatus ${
|
|
183
|
+
throw new Error(`Cannot restore unsupported completionStatus ${qtiValueToString(completionStatus)}.`);
|
|
176
184
|
}
|
|
177
185
|
}
|
|
178
186
|
function assertKnownStateIdentifiers(kind, record, allowed) {
|
|
@@ -281,12 +289,12 @@ function attemptStateErrors(value) {
|
|
|
281
289
|
}
|
|
282
290
|
return errors;
|
|
283
291
|
}
|
|
284
|
-
function applyTemplateProcessing(document, templateValues, responses, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers = new Set(), baseResponses = cloneValueRecord(responses), baseOutcomes = cloneValueRecord(outcomes)) {
|
|
292
|
+
function applyTemplateProcessing(document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers = new Set(), baseResponses = cloneValueRecord(responses), baseResponseDefaults = cloneValueRecord(responseDefaults), baseOutcomes = cloneValueRecord(outcomes)) {
|
|
285
293
|
const rules = document.item.templateProcessing?.rules ?? [];
|
|
286
294
|
let restarts = 0;
|
|
287
295
|
for (let index = 0; index < rules.length; index += 1) {
|
|
288
296
|
const rule = rules[index];
|
|
289
|
-
const shouldExit = applyTemplateRule(rule, document, templateValues, responses, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers);
|
|
297
|
+
const shouldExit = applyTemplateRule(rule, document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers);
|
|
290
298
|
if (shouldExit)
|
|
291
299
|
return;
|
|
292
300
|
if (rule.type === "templateConstraint") {
|
|
@@ -294,6 +302,7 @@ function applyTemplateProcessing(document, templateValues, responses, outcomes,
|
|
|
294
302
|
if (!satisfied) {
|
|
295
303
|
resetTemplateValues(document, templateValues);
|
|
296
304
|
resetRecord(responses, cloneValueRecord(baseResponses));
|
|
305
|
+
resetRecord(responseDefaults, cloneValueRecord(baseResponseDefaults));
|
|
297
306
|
resetRecord(outcomes, cloneValueRecord(baseOutcomes));
|
|
298
307
|
resetCorrectResponses(document, correctResponses);
|
|
299
308
|
restarts += 1;
|
|
@@ -316,7 +325,7 @@ function resetCorrectResponses(document, correctResponses) {
|
|
|
316
325
|
correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
|
|
317
326
|
}
|
|
318
327
|
}
|
|
319
|
-
function applyTemplateRule(rule, document, templateValues, responses, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers) {
|
|
328
|
+
function applyTemplateRule(rule, document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers) {
|
|
320
329
|
if (rule.type === "exitTemplate")
|
|
321
330
|
return true;
|
|
322
331
|
if (rule.type === "templateConstraint")
|
|
@@ -335,7 +344,7 @@ function applyTemplateRule(rule, document, templateValues, responses, outcomes,
|
|
|
335
344
|
}
|
|
336
345
|
branch ??= rule.elseRules;
|
|
337
346
|
for (const branchRule of branch) {
|
|
338
|
-
const shouldExit = applyTemplateRule(branchRule, document, templateValues, responses, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers);
|
|
347
|
+
const shouldExit = applyTemplateRule(branchRule, document, templateValues, responses, responseDefaults, outcomes, correctResponses, random, customOperators, preservedTemplateIdentifiers);
|
|
339
348
|
if (shouldExit)
|
|
340
349
|
return true;
|
|
341
350
|
}
|
|
@@ -352,7 +361,12 @@ function applyTemplateRule(rule, document, templateValues, responses, outcomes,
|
|
|
352
361
|
const responseDeclaration = getResponseDeclaration(document, rule.identifier);
|
|
353
362
|
if (responseDeclaration) {
|
|
354
363
|
const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
|
|
355
|
-
|
|
364
|
+
if (normalized === null) {
|
|
365
|
+
delete responseDefaults[rule.identifier];
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
responseDefaults[rule.identifier] = normalized;
|
|
369
|
+
}
|
|
356
370
|
return false;
|
|
357
371
|
}
|
|
358
372
|
const outcomeDeclaration = document.item.outcomeDeclarations.find((declaration) => declaration.identifier === rule.identifier);
|
|
@@ -372,29 +386,37 @@ function applyResponseProcessing(document, responses, outcomes, templateValues,
|
|
|
372
386
|
applyResponseRules(processing.rules, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
373
387
|
return;
|
|
374
388
|
}
|
|
375
|
-
const
|
|
376
|
-
if (
|
|
377
|
-
let score = 0;
|
|
378
|
-
for (const declaration of document.item.responseDeclarations) {
|
|
379
|
-
score += mapOrMatchResponse(declaration, responses[declaration.identifier] ?? null, correctResponses[declaration.identifier] ?? null);
|
|
380
|
-
}
|
|
381
|
-
outcomes.SCORE = score;
|
|
389
|
+
const templateKind = responseProcessingTemplateKind(processing?.template);
|
|
390
|
+
if (templateKind === "unsupported") {
|
|
382
391
|
return;
|
|
383
392
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
score += valuesEqual(response, correctResponse, declaration.cardinality === "ordered")
|
|
391
|
-
? 1
|
|
392
|
-
: 0;
|
|
393
|
-
scored = true;
|
|
394
|
-
}
|
|
393
|
+
if (templateKind === "mapResponse" || templateKind === "mapResponsePoint") {
|
|
394
|
+
const declaration = getResponseDeclaration(document, "RESPONSE");
|
|
395
|
+
outcomes.SCORE = declaration
|
|
396
|
+
? mapOrMatchResponse(declaration, responses.RESPONSE ?? null, correctResponses.RESPONSE ?? null)
|
|
397
|
+
: 0;
|
|
398
|
+
return;
|
|
395
399
|
}
|
|
396
|
-
if (
|
|
397
|
-
|
|
400
|
+
if (templateKind === "matchCorrect") {
|
|
401
|
+
const declaration = getResponseDeclaration(document, "RESPONSE");
|
|
402
|
+
const matches = declaration
|
|
403
|
+
? qtiMatchValues(responses.RESPONSE ?? null, correctResponses.RESPONSE ?? null, declaration.cardinality === "ordered")
|
|
404
|
+
: null;
|
|
405
|
+
outcomes.SCORE = matches === true ? 1 : 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function responseProcessingTemplateKind(template) {
|
|
409
|
+
if (!template)
|
|
410
|
+
return undefined;
|
|
411
|
+
const path = template.split(/[?#]/, 1)[0] ?? "";
|
|
412
|
+
const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
|
|
413
|
+
if (name === "match_correct")
|
|
414
|
+
return "matchCorrect";
|
|
415
|
+
if (name === "map_response")
|
|
416
|
+
return "mapResponse";
|
|
417
|
+
if (name === "map_response_point")
|
|
418
|
+
return "mapResponsePoint";
|
|
419
|
+
return "unsupported";
|
|
398
420
|
}
|
|
399
421
|
function applyResponseRules(rules, document, responses, outcomes, templateValues, correctResponses, random, customOperators) {
|
|
400
422
|
for (const rule of rules) {
|
|
@@ -450,11 +472,13 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
450
472
|
if (expression.type === "matchCorrect") {
|
|
451
473
|
const declaration = getResponseDeclaration(document, expression.correctIdentifier);
|
|
452
474
|
return declaration
|
|
453
|
-
?
|
|
475
|
+
? qtiMatchValues(responses[expression.identifier] ?? null, correctResponses[expression.correctIdentifier] ?? null, declaration.cardinality === "ordered")
|
|
454
476
|
: false;
|
|
455
477
|
}
|
|
456
478
|
if (expression.type === "match") {
|
|
457
|
-
|
|
479
|
+
const left = evaluateValue(expression.left, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
480
|
+
const right = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
481
|
+
return qtiMatchValues(left, right, expressionIsOrdered(expression.left, document) ||
|
|
458
482
|
expressionIsOrdered(expression.right, document));
|
|
459
483
|
}
|
|
460
484
|
if (expression.type === "mapResponse") {
|
|
@@ -510,37 +534,40 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
510
534
|
return valueContainer(evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators)).length;
|
|
511
535
|
}
|
|
512
536
|
if (expression.type === "sum") {
|
|
513
|
-
|
|
514
|
-
|
|
537
|
+
const values = evaluateNumericOperands(expression.expressions, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
538
|
+
return values ? values.reduce((sum, value) => sum + value, 0) : null;
|
|
515
539
|
}
|
|
516
540
|
if (expression.type === "product") {
|
|
517
|
-
|
|
518
|
-
|
|
541
|
+
const values = evaluateNumericOperands(expression.expressions, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
542
|
+
return values ? values.reduce((product, value) => product * value, 1) : null;
|
|
519
543
|
}
|
|
520
544
|
if (expression.type === "min" || expression.type === "max") {
|
|
521
|
-
const values = expression.expressions
|
|
522
|
-
if (values.length === 0)
|
|
545
|
+
const values = evaluateNumericOperands(expression.expressions, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
546
|
+
if (!values || values.length === 0)
|
|
523
547
|
return null;
|
|
524
|
-
|
|
525
|
-
return expression.type === "min" ? Math.min(...numericValues) : Math.max(...numericValues);
|
|
548
|
+
return expression.type === "min" ? Math.min(...values) : Math.max(...values);
|
|
526
549
|
}
|
|
527
550
|
if (expression.type === "subtract") {
|
|
528
|
-
|
|
529
|
-
|
|
551
|
+
const values = evaluateNumericOperands([expression.left, expression.right], document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
552
|
+
return values && values.length === 2 ? values[0] - values[1] : null;
|
|
530
553
|
}
|
|
531
554
|
if (expression.type === "divide") {
|
|
532
555
|
const dividendValue = evaluateValue(expression.left, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
533
556
|
const divisorValue = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
534
557
|
if (dividendValue === null || divisorValue === null)
|
|
535
558
|
return null;
|
|
536
|
-
const
|
|
537
|
-
|
|
559
|
+
const dividend = numericValueOrNull(dividendValue);
|
|
560
|
+
const divisor = numericValueOrNull(divisorValue);
|
|
561
|
+
if (dividend === null || divisor === null || divisor === 0)
|
|
538
562
|
return null;
|
|
539
|
-
const quotient =
|
|
563
|
+
const quotient = dividend / divisor;
|
|
540
564
|
return Number.isFinite(quotient) ? quotient : null;
|
|
541
565
|
}
|
|
542
566
|
if (expression.type === "power") {
|
|
543
|
-
const
|
|
567
|
+
const values = evaluateNumericOperands([expression.left, expression.right], document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
568
|
+
if (!values || values.length !== 2)
|
|
569
|
+
return null;
|
|
570
|
+
const value = Math.pow(values[0], values[1]);
|
|
544
571
|
return Number.isFinite(value) ? value : null;
|
|
545
572
|
}
|
|
546
573
|
if (expression.type === "integerDivide") {
|
|
@@ -548,40 +575,53 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
548
575
|
const divisorValue = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
549
576
|
if (dividendValue === null || divisorValue === null)
|
|
550
577
|
return null;
|
|
551
|
-
const
|
|
552
|
-
|
|
578
|
+
const dividend = numericValueOrNull(dividendValue);
|
|
579
|
+
const divisor = numericValueOrNull(divisorValue);
|
|
580
|
+
if (dividend === null || divisor === null || divisor === 0)
|
|
553
581
|
return null;
|
|
554
|
-
return Math.floor(
|
|
582
|
+
return Math.floor(dividend / divisor);
|
|
555
583
|
}
|
|
556
584
|
if (expression.type === "integerModulus") {
|
|
557
585
|
const dividendValue = evaluateValue(expression.left, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
558
586
|
const divisorValue = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
559
587
|
if (dividendValue === null || divisorValue === null)
|
|
560
588
|
return null;
|
|
561
|
-
const
|
|
562
|
-
|
|
589
|
+
const dividend = numericValueOrNull(dividendValue);
|
|
590
|
+
const divisor = numericValueOrNull(divisorValue);
|
|
591
|
+
if (dividend === null || divisor === null || divisor === 0)
|
|
563
592
|
return null;
|
|
564
|
-
const dividend = numericValue(dividendValue);
|
|
565
593
|
return dividend - Math.floor(dividend / divisor) * divisor;
|
|
566
594
|
}
|
|
567
595
|
if (expression.type === "round") {
|
|
568
|
-
|
|
596
|
+
const value = numericValueOrNull(evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
597
|
+
return value === null ? null : Math.round(value);
|
|
569
598
|
}
|
|
570
599
|
if (expression.type === "roundTo") {
|
|
571
|
-
const value =
|
|
600
|
+
const value = numericValueOrNull(evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
601
|
+
if (value === null)
|
|
602
|
+
return null;
|
|
572
603
|
return expression.roundingMode === "decimalPlaces"
|
|
573
604
|
? roundToDecimalPlaces(value, expression.figures)
|
|
574
605
|
: roundToSignificantFigures(value, expression.figures);
|
|
575
606
|
}
|
|
576
607
|
if (expression.type === "truncate") {
|
|
577
|
-
|
|
608
|
+
const value = numericValueOrNull(evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
609
|
+
return value === null ? null : Math.trunc(value);
|
|
578
610
|
}
|
|
579
611
|
if (expression.type === "integerToFloat") {
|
|
580
612
|
const value = evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
581
|
-
return
|
|
613
|
+
return numericValueOrNull(value);
|
|
582
614
|
}
|
|
583
615
|
if (expression.type === "and") {
|
|
584
|
-
|
|
616
|
+
let sawNull = false;
|
|
617
|
+
for (const item of expression.expressions) {
|
|
618
|
+
const value = booleanValueOrNull(evaluateValue(item, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
619
|
+
if (value === false)
|
|
620
|
+
return false;
|
|
621
|
+
if (value === null)
|
|
622
|
+
sawNull = true;
|
|
623
|
+
}
|
|
624
|
+
return sawNull ? null : true;
|
|
585
625
|
}
|
|
586
626
|
if (expression.type === "anyN") {
|
|
587
627
|
const min = indexValue(expression.min, outcomes, templateValues) ?? 0;
|
|
@@ -596,21 +636,36 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
596
636
|
return null;
|
|
597
637
|
}
|
|
598
638
|
if (expression.type === "or") {
|
|
599
|
-
|
|
639
|
+
let sawNull = false;
|
|
640
|
+
for (const item of expression.expressions) {
|
|
641
|
+
const value = booleanValueOrNull(evaluateValue(item, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
642
|
+
if (value === true)
|
|
643
|
+
return true;
|
|
644
|
+
if (value === null)
|
|
645
|
+
sawNull = true;
|
|
646
|
+
}
|
|
647
|
+
return sawNull ? null : false;
|
|
600
648
|
}
|
|
601
649
|
if (expression.type === "not") {
|
|
602
|
-
|
|
650
|
+
const value = booleanValueOrNull(evaluateValue(expression.expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators));
|
|
651
|
+
return value === null ? null : !value;
|
|
603
652
|
}
|
|
604
653
|
if (expression.type === "equal") {
|
|
605
|
-
|
|
654
|
+
const left = evaluateValue(expression.left, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
655
|
+
const right = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
656
|
+
return left === null || right === null ? null : valuesEqual(left, right);
|
|
606
657
|
}
|
|
607
658
|
if (expression.type === "equalRounded") {
|
|
608
659
|
const left = evaluateValue(expression.left, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
609
660
|
const right = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
610
661
|
if (left === null || right === null)
|
|
611
662
|
return null;
|
|
612
|
-
const
|
|
613
|
-
const
|
|
663
|
+
const leftNumber = numericValueOrNull(left);
|
|
664
|
+
const rightNumber = numericValueOrNull(right);
|
|
665
|
+
if (leftNumber === null || rightNumber === null)
|
|
666
|
+
return null;
|
|
667
|
+
const roundedLeft = roundWithMode(leftNumber, expression.roundingMode, expression.figures);
|
|
668
|
+
const roundedRight = roundWithMode(rightNumber, expression.roundingMode, expression.figures);
|
|
614
669
|
return roundedLeft === null || roundedRight === null ? null : roundedLeft === roundedRight;
|
|
615
670
|
}
|
|
616
671
|
if (expression.type === "numericCompare") {
|
|
@@ -618,8 +673,10 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
618
673
|
const rightValue = evaluateValue(expression.right, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
619
674
|
if (leftValue === null || rightValue === null)
|
|
620
675
|
return null;
|
|
621
|
-
const left =
|
|
622
|
-
const right =
|
|
676
|
+
const left = numericValueOrNull(leftValue);
|
|
677
|
+
const right = numericValueOrNull(rightValue);
|
|
678
|
+
if (left === null || right === null)
|
|
679
|
+
return null;
|
|
623
680
|
if (expression.operator === "lt")
|
|
624
681
|
return left < right;
|
|
625
682
|
if (expression.operator === "lte")
|
|
@@ -652,7 +709,7 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
652
709
|
templateValues[expression.pattern] ??
|
|
653
710
|
expression.pattern;
|
|
654
711
|
try {
|
|
655
|
-
return new RegExp(
|
|
712
|
+
return new RegExp(typeof patternValue === "string" ? patternValue : qtiValueToString(patternValue)).test(qtiValueToString(value));
|
|
656
713
|
}
|
|
657
714
|
catch {
|
|
658
715
|
return null;
|
|
@@ -665,20 +722,29 @@ function evaluateValue(expression, document, responses, outcomes = {}, templateV
|
|
|
665
722
|
if (expression.type === "member") {
|
|
666
723
|
const value = evaluateValue(expression.value, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
667
724
|
const collection = evaluateValue(expression.collection, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
725
|
+
if (value === null || collection === null)
|
|
726
|
+
return null;
|
|
668
727
|
const values = valueContainer(collection);
|
|
669
|
-
return
|
|
728
|
+
return values.some((item) => valuesEqual(item, value));
|
|
670
729
|
}
|
|
671
730
|
if (expression.type === "delete") {
|
|
672
731
|
const value = evaluateValue(expression.value, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
673
|
-
const
|
|
674
|
-
if (value === null ||
|
|
732
|
+
const collectionValue = evaluateValue(expression.collection, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
733
|
+
if (value === null || collectionValue === null)
|
|
734
|
+
return null;
|
|
735
|
+
const collection = valueContainer(collectionValue);
|
|
736
|
+
if (collection.length === 0)
|
|
675
737
|
return null;
|
|
676
738
|
const filtered = collection.filter((item) => !valuesEqual(item, value));
|
|
677
739
|
return filtered.length > 0 ? filtered : null;
|
|
678
740
|
}
|
|
679
741
|
if (expression.type === "contains") {
|
|
680
|
-
const
|
|
681
|
-
const
|
|
742
|
+
const collectionValue = evaluateValue(expression.collection, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
743
|
+
const valuesValue = evaluateValue(expression.values, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
744
|
+
if (collectionValue === null || valuesValue === null)
|
|
745
|
+
return null;
|
|
746
|
+
const collection = valueContainer(collectionValue);
|
|
747
|
+
const values = valueContainer(valuesValue);
|
|
682
748
|
if (collection.length === 0 || values.length === 0)
|
|
683
749
|
return null;
|
|
684
750
|
return containsValues(collection, values);
|
|
@@ -817,7 +883,11 @@ function mapOrMatchResponse(declaration, response, correctResponse) {
|
|
|
817
883
|
return valuesEqual(response, correctResponse, declaration.cardinality === "ordered") ? 1 : 0;
|
|
818
884
|
}
|
|
819
885
|
function scoreAreaMapping(response, areaMapping) {
|
|
820
|
-
const points = Array.isArray(response)
|
|
886
|
+
const points = Array.isArray(response)
|
|
887
|
+
? response.map(qtiScalarToString)
|
|
888
|
+
: response === null
|
|
889
|
+
? []
|
|
890
|
+
: qtiValueToStringList(response);
|
|
821
891
|
let score = 0;
|
|
822
892
|
for (const point of points) {
|
|
823
893
|
const parsed = parsePoint(String(point));
|
|
@@ -924,7 +994,9 @@ function scoreMapping(response, mapping) {
|
|
|
924
994
|
const score = response.reduce((sum, value) => sum + (values[String(value)] ?? mapping.defaultValue), 0);
|
|
925
995
|
return clampMappedScore(score, mapping.attributes);
|
|
926
996
|
}
|
|
927
|
-
const score =
|
|
997
|
+
const score = response === null || isRecordValue(response)
|
|
998
|
+
? 0
|
|
999
|
+
: (values[String(response)] ?? mapping.defaultValue);
|
|
928
1000
|
return clampMappedScore(score, mapping.attributes);
|
|
929
1001
|
}
|
|
930
1002
|
function clampMappedScore(score, attributes) {
|
|
@@ -966,6 +1038,11 @@ function valuesEqual(actual, expected, ordered = false) {
|
|
|
966
1038
|
}
|
|
967
1039
|
return scalarValuesEqual(actual, expected);
|
|
968
1040
|
}
|
|
1041
|
+
function qtiMatchValues(actual, expected, ordered = false) {
|
|
1042
|
+
if (actual === null || expected === null)
|
|
1043
|
+
return null;
|
|
1044
|
+
return valuesEqual(actual, expected, ordered);
|
|
1045
|
+
}
|
|
969
1046
|
function scalarValuesEqual(actual, expected) {
|
|
970
1047
|
if (typeof actual === "boolean" && typeof expected === "string") {
|
|
971
1048
|
return String(actual) === expected;
|
|
@@ -1058,8 +1135,24 @@ function indexValue(n, outcomes, templateValues) {
|
|
|
1058
1135
|
if (Number.isInteger(parsed))
|
|
1059
1136
|
return parsed;
|
|
1060
1137
|
const value = outcomes[n] ?? templateValues[n] ?? null;
|
|
1061
|
-
const numeric =
|
|
1062
|
-
return Number.isInteger(numeric) ? numeric : undefined;
|
|
1138
|
+
const numeric = numericValueOrNull(value);
|
|
1139
|
+
return numeric !== null && Number.isInteger(numeric) ? numeric : undefined;
|
|
1140
|
+
}
|
|
1141
|
+
function evaluateNumericOperands(expressions, document, responses, outcomes, templateValues, correctResponses, random, customOperators) {
|
|
1142
|
+
const numericValues = [];
|
|
1143
|
+
for (const expression of expressions) {
|
|
1144
|
+
const value = evaluateValue(expression, document, responses, outcomes, templateValues, correctResponses, random, customOperators);
|
|
1145
|
+
if (value === null || isRecordValue(value))
|
|
1146
|
+
return null;
|
|
1147
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1148
|
+
for (const item of values) {
|
|
1149
|
+
const numeric = numericValueOrNull(item);
|
|
1150
|
+
if (numeric === null)
|
|
1151
|
+
return null;
|
|
1152
|
+
numericValues.push(numeric);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return numericValues;
|
|
1063
1156
|
}
|
|
1064
1157
|
function containsValues(collection, values) {
|
|
1065
1158
|
const remaining = [...collection];
|
|
@@ -1083,6 +1176,17 @@ function numericValue(value) {
|
|
|
1083
1176
|
return Number(value);
|
|
1084
1177
|
return 0;
|
|
1085
1178
|
}
|
|
1179
|
+
function numericValueOrNull(value) {
|
|
1180
|
+
if (typeof value === "number")
|
|
1181
|
+
return Number.isFinite(value) ? value : null;
|
|
1182
|
+
if (typeof value === "boolean")
|
|
1183
|
+
return value ? 1 : 0;
|
|
1184
|
+
if (typeof value === "string") {
|
|
1185
|
+
const parsed = Number(value);
|
|
1186
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1187
|
+
}
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1086
1190
|
function durationSeconds(value) {
|
|
1087
1191
|
if (value === null || Array.isArray(value) || isRecordValue(value))
|
|
1088
1192
|
return null;
|
|
@@ -1117,6 +1221,11 @@ function booleanValue(value) {
|
|
|
1117
1221
|
return value.length > 0;
|
|
1118
1222
|
return false;
|
|
1119
1223
|
}
|
|
1224
|
+
function booleanValueOrNull(value) {
|
|
1225
|
+
if (value === null)
|
|
1226
|
+
return null;
|
|
1227
|
+
return booleanValue(value);
|
|
1228
|
+
}
|
|
1120
1229
|
function roundToDecimalPlaces(value, figures) {
|
|
1121
1230
|
const factor = 10 ** figures;
|
|
1122
1231
|
return Math.round(value * factor) / factor;
|
|
@@ -1258,8 +1367,10 @@ function finiteOrNull(value) {
|
|
|
1258
1367
|
return Number.isFinite(value) ? value : null;
|
|
1259
1368
|
}
|
|
1260
1369
|
function stringMatch(left, right, caseSensitive, substring) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1370
|
+
if (left === null || right === null)
|
|
1371
|
+
return null;
|
|
1372
|
+
let actual = qtiValueToString(left);
|
|
1373
|
+
let expected = qtiValueToString(right);
|
|
1263
1374
|
if (!caseSensitive) {
|
|
1264
1375
|
actual = actual.toLocaleLowerCase();
|
|
1265
1376
|
expected = expected.toLocaleLowerCase();
|