@longsightgroup/qti3-core 0.1.1 → 0.2.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/README.md +1 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +126 -0
- package/dist/catalog.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +293 -6
- package/dist/parser.js.map +1 -1
- package/dist/session.d.ts +3 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +68 -3
- package/dist/session.js.map +1 -1
- package/dist/support.js +7 -1
- package/dist/support.js.map +1 -1
- package/dist/tts.d.ts +61 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +368 -0
- package/dist/tts.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +243 -11
- package/dist/validation.js.map +1 -1
- package/package.json +4 -2
- package/src/catalog.ts +193 -0
- package/src/index.ts +85 -0
- package/src/parser.ts +1859 -0
- package/src/session.ts +2284 -0
- package/src/support.ts +253 -0
- package/src/tts.ts +555 -0
- package/src/types.ts +703 -0
- package/src/validation.ts +2449 -0
- package/src/xml.ts +139 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,2284 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QtiAssessmentItem,
|
|
3
|
+
QtiAttemptStatus,
|
|
4
|
+
QtiAttemptStateV1,
|
|
5
|
+
QtiDiagnostic,
|
|
6
|
+
QtiDocument,
|
|
7
|
+
QtiModalFeedback,
|
|
8
|
+
QtiPortableCustomStateValue,
|
|
9
|
+
QtiProcessingExpression,
|
|
10
|
+
QtiRecordValue,
|
|
11
|
+
QtiResponseCondition,
|
|
12
|
+
QtiResponseDeclaration,
|
|
13
|
+
QtiResponseRule,
|
|
14
|
+
QtiScalarValue,
|
|
15
|
+
QtiScoreResult,
|
|
16
|
+
QtiTemplateRule,
|
|
17
|
+
QtiValue,
|
|
18
|
+
QtiVariableDeclaration,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
export interface QtiCustomOperatorContext {
|
|
22
|
+
definition?: string | undefined;
|
|
23
|
+
className?: string | undefined;
|
|
24
|
+
attributes: Record<string, string>;
|
|
25
|
+
values: QtiValue[];
|
|
26
|
+
expression: Extract<QtiProcessingExpression, { type: "customOperator" }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type QtiCustomOperatorHandler = (context: QtiCustomOperatorContext) => QtiValue;
|
|
30
|
+
export type QtiCustomOperatorRegistry = Record<string, QtiCustomOperatorHandler>;
|
|
31
|
+
|
|
32
|
+
const COMPLETION_STATUS = "completionStatus";
|
|
33
|
+
const COMPLETION_NOT_ATTEMPTED = "not_attempted";
|
|
34
|
+
const COMPLETION_UNKNOWN = "unknown";
|
|
35
|
+
const COMPLETION_COMPLETED = "completed";
|
|
36
|
+
const ATTEMPT_STATE_SCHEMA = "qti3.attempt-state.v1";
|
|
37
|
+
const ATTEMPT_STATUSES = new Set<QtiAttemptStatus>([
|
|
38
|
+
"initialized",
|
|
39
|
+
"interacting",
|
|
40
|
+
"suspended",
|
|
41
|
+
"completed",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export interface QtiItemSessionOptions {
|
|
45
|
+
randomSeed?: string | number | undefined;
|
|
46
|
+
customOperators?: QtiCustomOperatorRegistry | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QtiItemSession {
|
|
50
|
+
readonly item: QtiAssessmentItem;
|
|
51
|
+
correctResponses(): Record<string, QtiValue>;
|
|
52
|
+
respond(identifier: string, value: QtiValue): void;
|
|
53
|
+
setInteractionState(identifier: string, state: QtiPortableCustomStateValue): void;
|
|
54
|
+
interactionState(identifier: string): QtiPortableCustomStateValue | undefined;
|
|
55
|
+
setStatus(status: QtiAttemptStatus): void;
|
|
56
|
+
score(): QtiScoreResult;
|
|
57
|
+
serialize(): QtiAttemptStateV1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function visibleModalFeedback(
|
|
61
|
+
item: QtiAssessmentItem,
|
|
62
|
+
outcomes: Record<string, QtiValue>,
|
|
63
|
+
): QtiModalFeedback[] {
|
|
64
|
+
return item.modalFeedback.filter((feedback) => {
|
|
65
|
+
if (feedback.showHide === "hide") return false;
|
|
66
|
+
const outcome = outcomes[feedback.outcomeIdentifier];
|
|
67
|
+
if (Array.isArray(outcome)) return outcome.includes(feedback.identifier);
|
|
68
|
+
return String(outcome ?? "") === feedback.identifier;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createItemSession(
|
|
73
|
+
document: QtiDocument,
|
|
74
|
+
priorState?: QtiAttemptStateV1,
|
|
75
|
+
options: QtiItemSessionOptions = {},
|
|
76
|
+
): QtiItemSession {
|
|
77
|
+
assertCompatiblePriorState(document, priorState);
|
|
78
|
+
|
|
79
|
+
const priorResponses = cloneValueRecord(priorState?.responses ?? {});
|
|
80
|
+
const priorOutcomes = cloneValueRecord(priorState?.outcomes ?? {});
|
|
81
|
+
const priorTemplateValues = cloneValueRecord(priorState?.templateValues ?? {});
|
|
82
|
+
const priorInteractionStates = clonePortableCustomStateRecord(
|
|
83
|
+
priorState?.interactionStates ?? {},
|
|
84
|
+
);
|
|
85
|
+
let validationMessages = cloneDiagnostics(priorState?.validationMessages ?? []);
|
|
86
|
+
const responses: Record<string, QtiValue> = {};
|
|
87
|
+
const outcomes: Record<string, QtiValue> = {};
|
|
88
|
+
const templateValues: Record<string, QtiValue> = {};
|
|
89
|
+
const interactionStates: Record<string, QtiPortableCustomStateValue> = {};
|
|
90
|
+
const correctResponses: Record<string, QtiValue> = {};
|
|
91
|
+
let status: QtiAttemptStatus = priorState?.status ?? "initialized";
|
|
92
|
+
const random = seededRandom(options.randomSeed ?? document.item.identifier);
|
|
93
|
+
const customOperators = options.customOperators ?? {};
|
|
94
|
+
const portableCustomResponseIdentifiers = new Set(
|
|
95
|
+
document.item.interactions
|
|
96
|
+
.filter((interaction) => interaction.type === "portableCustom")
|
|
97
|
+
.map((interaction) => interaction.responseIdentifier)
|
|
98
|
+
.filter((identifier): identifier is string => Boolean(identifier)),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const declaration of document.item.responseDeclarations) {
|
|
102
|
+
correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
|
|
103
|
+
if (declaration.defaultValue !== null && responses[declaration.identifier] === undefined) {
|
|
104
|
+
responses[declaration.identifier] = cloneValue(declaration.defaultValue);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const declaration of document.item.templateDeclarations) {
|
|
108
|
+
templateValues[declaration.identifier] = cloneValue(declaration.defaultValue);
|
|
109
|
+
}
|
|
110
|
+
for (const outcome of document.item.outcomeDeclarations) {
|
|
111
|
+
outcomes[outcome.identifier] = cloneValue(outcome.defaultValue);
|
|
112
|
+
}
|
|
113
|
+
outcomes[COMPLETION_STATUS] = COMPLETION_NOT_ATTEMPTED;
|
|
114
|
+
const baseResponses = cloneValueRecord(responses);
|
|
115
|
+
const baseOutcomes = cloneValueRecord(outcomes);
|
|
116
|
+
|
|
117
|
+
applyTemplateProcessing(
|
|
118
|
+
document,
|
|
119
|
+
templateValues,
|
|
120
|
+
responses,
|
|
121
|
+
outcomes,
|
|
122
|
+
correctResponses,
|
|
123
|
+
random,
|
|
124
|
+
customOperators,
|
|
125
|
+
new Set(),
|
|
126
|
+
baseResponses,
|
|
127
|
+
baseOutcomes,
|
|
128
|
+
);
|
|
129
|
+
if (priorState) {
|
|
130
|
+
Object.assign(templateValues, priorTemplateValues);
|
|
131
|
+
resetCorrectResponses(document, correctResponses);
|
|
132
|
+
applyTemplateProcessing(
|
|
133
|
+
document,
|
|
134
|
+
templateValues,
|
|
135
|
+
responses,
|
|
136
|
+
outcomes,
|
|
137
|
+
correctResponses,
|
|
138
|
+
random,
|
|
139
|
+
customOperators,
|
|
140
|
+
new Set(Object.keys(priorTemplateValues)),
|
|
141
|
+
baseResponses,
|
|
142
|
+
baseOutcomes,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const defaultOutcomes = cloneValueRecord(outcomes);
|
|
146
|
+
Object.assign(responses, priorResponses);
|
|
147
|
+
Object.assign(outcomes, priorOutcomes);
|
|
148
|
+
Object.assign(interactionStates, priorInteractionStates);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
item: document.item,
|
|
152
|
+
correctResponses() {
|
|
153
|
+
return cloneValueRecord(correctResponses);
|
|
154
|
+
},
|
|
155
|
+
respond(identifier: string, value: QtiValue) {
|
|
156
|
+
responses[identifier] = cloneValue(value);
|
|
157
|
+
validationMessages = [];
|
|
158
|
+
startAttempt();
|
|
159
|
+
},
|
|
160
|
+
setInteractionState(identifier: string, state: QtiPortableCustomStateValue) {
|
|
161
|
+
if (!portableCustomResponseIdentifiers.has(identifier)) {
|
|
162
|
+
throw new Error(`Cannot set interaction state for non-PCI response ${identifier}.`);
|
|
163
|
+
}
|
|
164
|
+
interactionStates[identifier] = clonePortableCustomState(state);
|
|
165
|
+
validationMessages = [];
|
|
166
|
+
startAttempt();
|
|
167
|
+
},
|
|
168
|
+
interactionState(identifier: string) {
|
|
169
|
+
const state = interactionStates[identifier];
|
|
170
|
+
return state === undefined ? undefined : clonePortableCustomState(state);
|
|
171
|
+
},
|
|
172
|
+
setStatus(nextStatus: QtiAttemptStatus) {
|
|
173
|
+
status = nextStatus;
|
|
174
|
+
},
|
|
175
|
+
score() {
|
|
176
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
177
|
+
if (document.item.adaptive || status !== "initialized" || Object.keys(responses).length > 0) {
|
|
178
|
+
startAttempt();
|
|
179
|
+
}
|
|
180
|
+
const completionStatus = outcomes[COMPLETION_STATUS] ?? COMPLETION_NOT_ATTEMPTED;
|
|
181
|
+
if (!document.item.adaptive) {
|
|
182
|
+
resetRecord(outcomes, cloneValueRecord(defaultOutcomes));
|
|
183
|
+
outcomes[COMPLETION_STATUS] = completionStatus;
|
|
184
|
+
}
|
|
185
|
+
applyResponseProcessing(
|
|
186
|
+
document,
|
|
187
|
+
responses,
|
|
188
|
+
outcomes,
|
|
189
|
+
templateValues,
|
|
190
|
+
correctResponses,
|
|
191
|
+
random,
|
|
192
|
+
customOperators,
|
|
193
|
+
);
|
|
194
|
+
if (outcomes[COMPLETION_STATUS] === COMPLETION_COMPLETED) status = "completed";
|
|
195
|
+
validationMessages = diagnostics;
|
|
196
|
+
const state = serialize(
|
|
197
|
+
document.item.identifier,
|
|
198
|
+
status,
|
|
199
|
+
responses,
|
|
200
|
+
outcomes,
|
|
201
|
+
templateValues,
|
|
202
|
+
interactionStates,
|
|
203
|
+
diagnostics,
|
|
204
|
+
);
|
|
205
|
+
return { outcomes: cloneValueRecord(outcomes), diagnostics, state };
|
|
206
|
+
},
|
|
207
|
+
serialize() {
|
|
208
|
+
return serialize(
|
|
209
|
+
document.item.identifier,
|
|
210
|
+
status,
|
|
211
|
+
responses,
|
|
212
|
+
outcomes,
|
|
213
|
+
templateValues,
|
|
214
|
+
interactionStates,
|
|
215
|
+
validationMessages,
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function startAttempt(): void {
|
|
221
|
+
if (status === "initialized" || status === "suspended") status = "interacting";
|
|
222
|
+
if (outcomes[COMPLETION_STATUS] === COMPLETION_NOT_ATTEMPTED) {
|
|
223
|
+
outcomes[COMPLETION_STATUS] = COMPLETION_UNKNOWN;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function isQtiAttemptStateV1(value: unknown): value is QtiAttemptStateV1 {
|
|
229
|
+
return attemptStateErrors(value).length === 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function assertQtiAttemptStateV1(value: unknown): asserts value is QtiAttemptStateV1 {
|
|
233
|
+
const [firstError] = attemptStateErrors(value);
|
|
234
|
+
if (firstError) throw new Error(firstError);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resetRecord<T>(target: Record<string, T>, source: Record<string, T>): void {
|
|
238
|
+
for (const key of Object.keys(target)) {
|
|
239
|
+
delete target[key];
|
|
240
|
+
}
|
|
241
|
+
Object.assign(target, source);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertCompatiblePriorState(
|
|
245
|
+
document: QtiDocument,
|
|
246
|
+
priorState: QtiAttemptStateV1 | undefined,
|
|
247
|
+
): void {
|
|
248
|
+
if (!priorState) return;
|
|
249
|
+
assertQtiAttemptStateV1(priorState);
|
|
250
|
+
if (priorState.itemIdentifier !== document.item.identifier) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Cannot restore state for ${priorState.itemIdentifier} into ${document.item.identifier}.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const responseIdentifiers = new Set(
|
|
256
|
+
document.item.responseDeclarations.map((declaration) => declaration.identifier),
|
|
257
|
+
);
|
|
258
|
+
const outcomeIdentifiers = new Set([
|
|
259
|
+
...document.item.outcomeDeclarations.map((declaration) => declaration.identifier),
|
|
260
|
+
COMPLETION_STATUS,
|
|
261
|
+
]);
|
|
262
|
+
const templateIdentifiers = new Set(
|
|
263
|
+
document.item.templateDeclarations.map((declaration) => declaration.identifier),
|
|
264
|
+
);
|
|
265
|
+
const interactionStateIdentifiers = new Set(
|
|
266
|
+
document.item.interactions
|
|
267
|
+
.filter((interaction) => interaction.type === "portableCustom")
|
|
268
|
+
.map((interaction) => interaction.responseIdentifier)
|
|
269
|
+
.filter((identifier): identifier is string => Boolean(identifier)),
|
|
270
|
+
);
|
|
271
|
+
assertKnownStateIdentifiers("response", priorState.responses, responseIdentifiers);
|
|
272
|
+
assertKnownStateIdentifiers("outcome", priorState.outcomes, outcomeIdentifiers);
|
|
273
|
+
assertKnownStateIdentifiers("template", priorState.templateValues ?? {}, templateIdentifiers);
|
|
274
|
+
assertKnownPortableCustomStateIdentifiers(
|
|
275
|
+
priorState.interactionStates ?? {},
|
|
276
|
+
interactionStateIdentifiers,
|
|
277
|
+
);
|
|
278
|
+
for (const message of priorState.validationMessages) {
|
|
279
|
+
if (message.path && !responseIdentifiers.has(message.path)) {
|
|
280
|
+
throw new Error(`Cannot restore validation message for unknown response ${message.path}.`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const declaration of document.item.responseDeclarations) {
|
|
284
|
+
assertRestoredValueMatchesDeclaration("response", declaration, priorState.responses);
|
|
285
|
+
}
|
|
286
|
+
for (const declaration of document.item.outcomeDeclarations) {
|
|
287
|
+
assertRestoredValueMatchesDeclaration("outcome", declaration, priorState.outcomes);
|
|
288
|
+
}
|
|
289
|
+
for (const declaration of document.item.templateDeclarations) {
|
|
290
|
+
assertRestoredValueMatchesDeclaration("template", declaration, priorState.templateValues ?? {});
|
|
291
|
+
}
|
|
292
|
+
const completionStatus = priorState.outcomes[COMPLETION_STATUS];
|
|
293
|
+
if (
|
|
294
|
+
completionStatus !== undefined &&
|
|
295
|
+
completionStatus !== COMPLETION_NOT_ATTEMPTED &&
|
|
296
|
+
completionStatus !== COMPLETION_UNKNOWN &&
|
|
297
|
+
completionStatus !== COMPLETION_COMPLETED &&
|
|
298
|
+
completionStatus !== "incomplete"
|
|
299
|
+
) {
|
|
300
|
+
throw new Error(`Cannot restore unsupported completionStatus ${String(completionStatus)}.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function assertKnownStateIdentifiers(
|
|
305
|
+
kind: string,
|
|
306
|
+
record: Record<string, QtiValue>,
|
|
307
|
+
allowed: Set<string>,
|
|
308
|
+
): void {
|
|
309
|
+
const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
|
|
310
|
+
if (unknown) throw new Error(`Cannot restore unknown ${kind} identifier ${unknown}.`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function assertKnownPortableCustomStateIdentifiers(
|
|
314
|
+
record: Record<string, QtiPortableCustomStateValue>,
|
|
315
|
+
allowed: Set<string>,
|
|
316
|
+
): void {
|
|
317
|
+
const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
|
|
318
|
+
if (unknown) throw new Error(`Cannot restore unknown interaction state identifier ${unknown}.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function assertRestoredValueMatchesDeclaration(
|
|
322
|
+
kind: string,
|
|
323
|
+
declaration: QtiVariableDeclaration,
|
|
324
|
+
record: Record<string, QtiValue>,
|
|
325
|
+
): void {
|
|
326
|
+
if (!(declaration.identifier in record)) return;
|
|
327
|
+
const value = record[declaration.identifier] ?? null;
|
|
328
|
+
const error = restoredValueError(declaration, value);
|
|
329
|
+
if (error) throw new Error(`Cannot restore ${kind} ${declaration.identifier}: ${error}.`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function restoredValueError(
|
|
333
|
+
declaration: QtiVariableDeclaration,
|
|
334
|
+
value: QtiValue,
|
|
335
|
+
): string | undefined {
|
|
336
|
+
if (value === null) return undefined;
|
|
337
|
+
if (declaration.cardinality === "record") {
|
|
338
|
+
return isRecordValue(value) ? undefined : "expected record value";
|
|
339
|
+
}
|
|
340
|
+
if (isRecordValue(value)) return "expected scalar value";
|
|
341
|
+
if (declaration.cardinality === "single" && Array.isArray(value)) {
|
|
342
|
+
return "expected single value";
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
(declaration.cardinality === "multiple" || declaration.cardinality === "ordered") &&
|
|
346
|
+
!Array.isArray(value)
|
|
347
|
+
) {
|
|
348
|
+
return `expected ${declaration.cardinality} value container`;
|
|
349
|
+
}
|
|
350
|
+
if (!declaration.baseType) return undefined;
|
|
351
|
+
for (const entry of restoredValueEntries(value)) {
|
|
352
|
+
if (!restoredScalarMatchesBaseType(entry, declaration.baseType)) {
|
|
353
|
+
return `value ${String(entry)} is not valid for base-type ${declaration.baseType}`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function restoredValueEntries(value: QtiValue): QtiScalarValue[] {
|
|
360
|
+
if (value === null || isRecordValue(value)) return [];
|
|
361
|
+
return Array.isArray(value) ? value : [value];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function restoredScalarMatchesBaseType(
|
|
365
|
+
value: QtiScalarValue,
|
|
366
|
+
baseType: QtiVariableDeclaration["baseType"],
|
|
367
|
+
): boolean {
|
|
368
|
+
if (!baseType) return true;
|
|
369
|
+
if (baseType === "integer") {
|
|
370
|
+
return typeof value === "number" ? Number.isInteger(value) : /^-?\d+$/.test(String(value));
|
|
371
|
+
}
|
|
372
|
+
if (baseType === "float") {
|
|
373
|
+
return typeof value === "number" ? Number.isFinite(value) : Number.isFinite(Number(value));
|
|
374
|
+
}
|
|
375
|
+
if (baseType === "boolean")
|
|
376
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
377
|
+
if (baseType === "point") return pointValueIsValid(value);
|
|
378
|
+
if (baseType === "pair" || baseType === "directedPair") return pairValueIsValid(value);
|
|
379
|
+
if (baseType === "identifier")
|
|
380
|
+
return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value);
|
|
381
|
+
return typeof value === "string";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function pointValueIsValid(value: QtiScalarValue): boolean {
|
|
385
|
+
const parts = String(value).trim().split(/\s+/);
|
|
386
|
+
return parts.length === 2 && parts.every((part) => Number.isFinite(Number(part)));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function pairValueIsValid(value: QtiScalarValue): boolean {
|
|
390
|
+
const parts = String(value).trim().split(/\s+/);
|
|
391
|
+
return parts.length === 2 && parts.every((part) => part.length > 0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function attemptStateErrors(value: unknown): string[] {
|
|
395
|
+
if (!isRecord(value)) return ["QTI attempt state must be an object."];
|
|
396
|
+
|
|
397
|
+
const schema = value.schema;
|
|
398
|
+
if (schema !== ATTEMPT_STATE_SCHEMA) {
|
|
399
|
+
return [`Unsupported QTI attempt state schema ${String(schema)}.`];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const errors: string[] = [];
|
|
403
|
+
if (typeof value.itemIdentifier !== "string" || value.itemIdentifier.length === 0) {
|
|
404
|
+
errors.push("QTI attempt state itemIdentifier must be a non-empty string.");
|
|
405
|
+
}
|
|
406
|
+
if (typeof value.status !== "string" || !ATTEMPT_STATUSES.has(value.status as QtiAttemptStatus)) {
|
|
407
|
+
errors.push(`QTI attempt state status ${String(value.status)} is not supported.`);
|
|
408
|
+
}
|
|
409
|
+
if (!isQtiValueRecord(value.responses)) {
|
|
410
|
+
errors.push("QTI attempt state responses must be a record of QTI values.");
|
|
411
|
+
}
|
|
412
|
+
if (!isQtiValueRecord(value.outcomes)) {
|
|
413
|
+
errors.push("QTI attempt state outcomes must be a record of QTI values.");
|
|
414
|
+
}
|
|
415
|
+
if (value.templateValues !== undefined && !isQtiValueRecord(value.templateValues)) {
|
|
416
|
+
errors.push("QTI attempt state templateValues must be a record of QTI values.");
|
|
417
|
+
}
|
|
418
|
+
if (
|
|
419
|
+
value.interactionStates !== undefined &&
|
|
420
|
+
!isPortableCustomStateRecord(value.interactionStates)
|
|
421
|
+
) {
|
|
422
|
+
errors.push("QTI attempt state interactionStates must be a record of JSON values.");
|
|
423
|
+
}
|
|
424
|
+
if (!isDiagnosticArray(value.validationMessages)) {
|
|
425
|
+
errors.push("QTI attempt state validationMessages must be an array of diagnostics.");
|
|
426
|
+
}
|
|
427
|
+
return errors;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function applyTemplateProcessing(
|
|
431
|
+
document: QtiDocument,
|
|
432
|
+
templateValues: Record<string, QtiValue>,
|
|
433
|
+
responses: Record<string, QtiValue>,
|
|
434
|
+
outcomes: Record<string, QtiValue>,
|
|
435
|
+
correctResponses: Record<string, QtiValue>,
|
|
436
|
+
random: () => number,
|
|
437
|
+
customOperators: QtiCustomOperatorRegistry,
|
|
438
|
+
preservedTemplateIdentifiers = new Set<string>(),
|
|
439
|
+
baseResponses: Record<string, QtiValue> = cloneValueRecord(responses),
|
|
440
|
+
baseOutcomes: Record<string, QtiValue> = cloneValueRecord(outcomes),
|
|
441
|
+
): void {
|
|
442
|
+
const rules = document.item.templateProcessing?.rules ?? [];
|
|
443
|
+
let restarts = 0;
|
|
444
|
+
for (let index = 0; index < rules.length; index += 1) {
|
|
445
|
+
const rule = rules[index]!;
|
|
446
|
+
const shouldExit = applyTemplateRule(
|
|
447
|
+
rule,
|
|
448
|
+
document,
|
|
449
|
+
templateValues,
|
|
450
|
+
responses,
|
|
451
|
+
outcomes,
|
|
452
|
+
correctResponses,
|
|
453
|
+
random,
|
|
454
|
+
customOperators,
|
|
455
|
+
preservedTemplateIdentifiers,
|
|
456
|
+
);
|
|
457
|
+
if (shouldExit) return;
|
|
458
|
+
if (rule.type === "templateConstraint") {
|
|
459
|
+
const satisfied = evaluateBoolean(
|
|
460
|
+
rule.expression,
|
|
461
|
+
document,
|
|
462
|
+
responses,
|
|
463
|
+
outcomes,
|
|
464
|
+
templateValues,
|
|
465
|
+
correctResponses,
|
|
466
|
+
random,
|
|
467
|
+
customOperators,
|
|
468
|
+
);
|
|
469
|
+
if (!satisfied) {
|
|
470
|
+
resetTemplateValues(document, templateValues);
|
|
471
|
+
resetRecord(responses, cloneValueRecord(baseResponses));
|
|
472
|
+
resetRecord(outcomes, cloneValueRecord(baseOutcomes));
|
|
473
|
+
resetCorrectResponses(document, correctResponses);
|
|
474
|
+
restarts += 1;
|
|
475
|
+
if (restarts <= 100) index = -1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resetTemplateValues(
|
|
482
|
+
document: QtiDocument,
|
|
483
|
+
templateValues: Record<string, QtiValue>,
|
|
484
|
+
): void {
|
|
485
|
+
for (const key of Object.keys(templateValues)) {
|
|
486
|
+
delete templateValues[key];
|
|
487
|
+
}
|
|
488
|
+
for (const declaration of document.item.templateDeclarations) {
|
|
489
|
+
templateValues[declaration.identifier] = declaration.defaultValue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function resetCorrectResponses(
|
|
494
|
+
document: QtiDocument,
|
|
495
|
+
correctResponses: Record<string, QtiValue>,
|
|
496
|
+
): void {
|
|
497
|
+
for (const declaration of document.item.responseDeclarations) {
|
|
498
|
+
correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function applyTemplateRule(
|
|
503
|
+
rule: QtiTemplateRule,
|
|
504
|
+
document: QtiDocument,
|
|
505
|
+
templateValues: Record<string, QtiValue>,
|
|
506
|
+
responses: Record<string, QtiValue>,
|
|
507
|
+
outcomes: Record<string, QtiValue>,
|
|
508
|
+
correctResponses: Record<string, QtiValue>,
|
|
509
|
+
random: () => number,
|
|
510
|
+
customOperators: QtiCustomOperatorRegistry,
|
|
511
|
+
preservedTemplateIdentifiers: Set<string>,
|
|
512
|
+
): boolean {
|
|
513
|
+
if (rule.type === "exitTemplate") return true;
|
|
514
|
+
if (rule.type === "templateConstraint") return false;
|
|
515
|
+
|
|
516
|
+
if (rule.type === "templateCondition") {
|
|
517
|
+
let branch = evaluateBoolean(
|
|
518
|
+
rule.ifExpression,
|
|
519
|
+
document,
|
|
520
|
+
responses,
|
|
521
|
+
outcomes,
|
|
522
|
+
templateValues,
|
|
523
|
+
correctResponses,
|
|
524
|
+
random,
|
|
525
|
+
customOperators,
|
|
526
|
+
)
|
|
527
|
+
? rule.thenRules
|
|
528
|
+
: undefined;
|
|
529
|
+
if (!branch) {
|
|
530
|
+
for (const elseIf of rule.elseIfs) {
|
|
531
|
+
if (
|
|
532
|
+
evaluateBoolean(
|
|
533
|
+
elseIf.expression,
|
|
534
|
+
document,
|
|
535
|
+
responses,
|
|
536
|
+
outcomes,
|
|
537
|
+
templateValues,
|
|
538
|
+
correctResponses,
|
|
539
|
+
random,
|
|
540
|
+
customOperators,
|
|
541
|
+
)
|
|
542
|
+
) {
|
|
543
|
+
branch = elseIf.rules;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
branch ??= rule.elseRules;
|
|
549
|
+
for (const branchRule of branch) {
|
|
550
|
+
const shouldExit = applyTemplateRule(
|
|
551
|
+
branchRule,
|
|
552
|
+
document,
|
|
553
|
+
templateValues,
|
|
554
|
+
responses,
|
|
555
|
+
outcomes,
|
|
556
|
+
correctResponses,
|
|
557
|
+
random,
|
|
558
|
+
customOperators,
|
|
559
|
+
preservedTemplateIdentifiers,
|
|
560
|
+
);
|
|
561
|
+
if (shouldExit) return true;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const value = evaluateValue(
|
|
567
|
+
rule.expression,
|
|
568
|
+
document,
|
|
569
|
+
responses,
|
|
570
|
+
outcomes,
|
|
571
|
+
templateValues,
|
|
572
|
+
correctResponses,
|
|
573
|
+
random,
|
|
574
|
+
customOperators,
|
|
575
|
+
);
|
|
576
|
+
if (rule.type === "setTemplateValue") {
|
|
577
|
+
if (preservedTemplateIdentifiers.has(rule.identifier)) return false;
|
|
578
|
+
templateValues[rule.identifier] = value;
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (rule.type === "setDefaultValue") {
|
|
583
|
+
const responseDeclaration = getResponseDeclaration(document, rule.identifier);
|
|
584
|
+
if (responseDeclaration) {
|
|
585
|
+
const normalized = normalizeValueForCardinality(value, responseDeclaration.cardinality);
|
|
586
|
+
responses[rule.identifier] = normalized;
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
const outcomeDeclaration = document.item.outcomeDeclarations.find(
|
|
590
|
+
(declaration) => declaration.identifier === rule.identifier,
|
|
591
|
+
);
|
|
592
|
+
if (outcomeDeclaration) {
|
|
593
|
+
outcomes[rule.identifier] = value;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const declaration = getResponseDeclaration(document, rule.identifier);
|
|
599
|
+
if (declaration)
|
|
600
|
+
correctResponses[rule.identifier] = normalizeValueForCardinality(
|
|
601
|
+
value,
|
|
602
|
+
declaration.cardinality,
|
|
603
|
+
);
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function applyResponseProcessing(
|
|
608
|
+
document: QtiDocument,
|
|
609
|
+
responses: Record<string, QtiValue>,
|
|
610
|
+
outcomes: Record<string, QtiValue>,
|
|
611
|
+
templateValues: Record<string, QtiValue>,
|
|
612
|
+
correctResponses: Record<string, QtiValue>,
|
|
613
|
+
random: () => number,
|
|
614
|
+
customOperators: QtiCustomOperatorRegistry,
|
|
615
|
+
): void {
|
|
616
|
+
const processing = document.item.responseProcessing;
|
|
617
|
+
if (processing?.rules.length) {
|
|
618
|
+
applyResponseRules(
|
|
619
|
+
processing.rules,
|
|
620
|
+
document,
|
|
621
|
+
responses,
|
|
622
|
+
outcomes,
|
|
623
|
+
templateValues,
|
|
624
|
+
correctResponses,
|
|
625
|
+
random,
|
|
626
|
+
customOperators,
|
|
627
|
+
);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
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;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
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
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (scored) outcomes.SCORE = score;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function applyResponseRules(
|
|
661
|
+
rules: QtiResponseRule[],
|
|
662
|
+
document: QtiDocument,
|
|
663
|
+
responses: Record<string, QtiValue>,
|
|
664
|
+
outcomes: Record<string, QtiValue>,
|
|
665
|
+
templateValues: Record<string, QtiValue>,
|
|
666
|
+
correctResponses: Record<string, QtiValue>,
|
|
667
|
+
random: () => number,
|
|
668
|
+
customOperators: QtiCustomOperatorRegistry,
|
|
669
|
+
): boolean {
|
|
670
|
+
for (const rule of rules) {
|
|
671
|
+
if (rule.type === "exitResponse") return true;
|
|
672
|
+
if (rule.type === "responseCondition") {
|
|
673
|
+
const shouldExit = applyResponseCondition(
|
|
674
|
+
rule.condition,
|
|
675
|
+
document,
|
|
676
|
+
responses,
|
|
677
|
+
outcomes,
|
|
678
|
+
templateValues,
|
|
679
|
+
correctResponses,
|
|
680
|
+
random,
|
|
681
|
+
customOperators,
|
|
682
|
+
);
|
|
683
|
+
if (shouldExit) return true;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (rule.type === "responseProcessingFragment") {
|
|
687
|
+
const shouldExit = applyResponseRules(
|
|
688
|
+
rule.rules,
|
|
689
|
+
document,
|
|
690
|
+
responses,
|
|
691
|
+
outcomes,
|
|
692
|
+
templateValues,
|
|
693
|
+
correctResponses,
|
|
694
|
+
random,
|
|
695
|
+
customOperators,
|
|
696
|
+
);
|
|
697
|
+
if (shouldExit) return true;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (rule.type === "lookupOutcomeValue") {
|
|
701
|
+
outcomes[rule.identifier] = lookupOutcomeValue(
|
|
702
|
+
document,
|
|
703
|
+
rule.identifier,
|
|
704
|
+
evaluateValue(
|
|
705
|
+
rule.expression,
|
|
706
|
+
document,
|
|
707
|
+
responses,
|
|
708
|
+
outcomes,
|
|
709
|
+
templateValues,
|
|
710
|
+
correctResponses,
|
|
711
|
+
random,
|
|
712
|
+
customOperators,
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
outcomes[rule.identifier] = evaluateValue(
|
|
718
|
+
rule.expression,
|
|
719
|
+
document,
|
|
720
|
+
responses,
|
|
721
|
+
outcomes,
|
|
722
|
+
templateValues,
|
|
723
|
+
correctResponses,
|
|
724
|
+
random,
|
|
725
|
+
customOperators,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function applyResponseCondition(
|
|
732
|
+
condition: QtiResponseCondition,
|
|
733
|
+
document: QtiDocument,
|
|
734
|
+
responses: Record<string, QtiValue>,
|
|
735
|
+
outcomes: Record<string, QtiValue>,
|
|
736
|
+
templateValues: Record<string, QtiValue>,
|
|
737
|
+
correctResponses: Record<string, QtiValue>,
|
|
738
|
+
random: () => number,
|
|
739
|
+
customOperators: QtiCustomOperatorRegistry,
|
|
740
|
+
): boolean {
|
|
741
|
+
let branch = evaluateBoolean(
|
|
742
|
+
condition.ifExpression,
|
|
743
|
+
document,
|
|
744
|
+
responses,
|
|
745
|
+
outcomes,
|
|
746
|
+
templateValues,
|
|
747
|
+
correctResponses,
|
|
748
|
+
random,
|
|
749
|
+
customOperators,
|
|
750
|
+
)
|
|
751
|
+
? condition.thenRules
|
|
752
|
+
: undefined;
|
|
753
|
+
if (!branch) {
|
|
754
|
+
for (const elseIf of condition.elseIfs) {
|
|
755
|
+
if (
|
|
756
|
+
evaluateBoolean(
|
|
757
|
+
elseIf.expression,
|
|
758
|
+
document,
|
|
759
|
+
responses,
|
|
760
|
+
outcomes,
|
|
761
|
+
templateValues,
|
|
762
|
+
correctResponses,
|
|
763
|
+
random,
|
|
764
|
+
customOperators,
|
|
765
|
+
)
|
|
766
|
+
) {
|
|
767
|
+
branch = elseIf.rules;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
branch ??= condition.elseRules;
|
|
773
|
+
return applyResponseRules(
|
|
774
|
+
branch,
|
|
775
|
+
document,
|
|
776
|
+
responses,
|
|
777
|
+
outcomes,
|
|
778
|
+
templateValues,
|
|
779
|
+
correctResponses,
|
|
780
|
+
random,
|
|
781
|
+
customOperators,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function evaluateBoolean(
|
|
786
|
+
expression: QtiProcessingExpression | undefined,
|
|
787
|
+
document: QtiDocument,
|
|
788
|
+
responses: Record<string, QtiValue>,
|
|
789
|
+
outcomes: Record<string, QtiValue> = {},
|
|
790
|
+
templateValues: Record<string, QtiValue> = {},
|
|
791
|
+
correctResponses: Record<string, QtiValue> = {},
|
|
792
|
+
random: () => number = Math.random,
|
|
793
|
+
customOperators: QtiCustomOperatorRegistry = {},
|
|
794
|
+
): boolean {
|
|
795
|
+
if (!expression) return false;
|
|
796
|
+
return booleanValue(
|
|
797
|
+
evaluateValue(
|
|
798
|
+
expression,
|
|
799
|
+
document,
|
|
800
|
+
responses,
|
|
801
|
+
outcomes,
|
|
802
|
+
templateValues,
|
|
803
|
+
correctResponses,
|
|
804
|
+
random,
|
|
805
|
+
customOperators,
|
|
806
|
+
),
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function evaluateValue(
|
|
811
|
+
expression: QtiProcessingExpression,
|
|
812
|
+
document: QtiDocument,
|
|
813
|
+
responses: Record<string, QtiValue>,
|
|
814
|
+
outcomes: Record<string, QtiValue> = {},
|
|
815
|
+
templateValues: Record<string, QtiValue> = {},
|
|
816
|
+
correctResponses: Record<string, QtiValue> = {},
|
|
817
|
+
random: () => number = Math.random,
|
|
818
|
+
customOperators: QtiCustomOperatorRegistry = {},
|
|
819
|
+
): QtiValue {
|
|
820
|
+
if (expression.type === "baseValue") return expression.value;
|
|
821
|
+
if (expression.type === "null") return null;
|
|
822
|
+
if (expression.type === "isNull") return isNullResponse(responses[expression.identifier] ?? null);
|
|
823
|
+
if (expression.type === "matchCorrect") {
|
|
824
|
+
const declaration = getResponseDeclaration(document, expression.correctIdentifier);
|
|
825
|
+
return declaration
|
|
826
|
+
? valuesEqual(
|
|
827
|
+
responses[expression.identifier] ?? null,
|
|
828
|
+
correctResponses[expression.correctIdentifier] ?? null,
|
|
829
|
+
declaration.cardinality === "ordered",
|
|
830
|
+
)
|
|
831
|
+
: false;
|
|
832
|
+
}
|
|
833
|
+
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
|
+
),
|
|
855
|
+
expressionIsOrdered(expression.left, document) ||
|
|
856
|
+
expressionIsOrdered(expression.right, document),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (expression.type === "mapResponse") {
|
|
860
|
+
const declaration = getResponseDeclaration(document, expression.identifier);
|
|
861
|
+
return declaration
|
|
862
|
+
? mapOrMatchResponse(
|
|
863
|
+
declaration,
|
|
864
|
+
responses[expression.identifier] ?? null,
|
|
865
|
+
correctResponses[expression.identifier] ?? null,
|
|
866
|
+
)
|
|
867
|
+
: 0;
|
|
868
|
+
}
|
|
869
|
+
if (expression.type === "mapResponsePoint") {
|
|
870
|
+
const declaration = getResponseDeclaration(document, expression.identifier);
|
|
871
|
+
return declaration?.areaMapping
|
|
872
|
+
? scoreAreaMapping(responses[expression.identifier] ?? null, declaration.areaMapping)
|
|
873
|
+
: 0;
|
|
874
|
+
}
|
|
875
|
+
if (expression.type === "correct") {
|
|
876
|
+
return correctResponses[expression.identifier] ?? null;
|
|
877
|
+
}
|
|
878
|
+
if (expression.type === "default") {
|
|
879
|
+
return defaultValueForIdentifier(document, expression.identifier);
|
|
880
|
+
}
|
|
881
|
+
if (expression.type === "variable") {
|
|
882
|
+
return (
|
|
883
|
+
responses[expression.identifier] ??
|
|
884
|
+
outcomes[expression.identifier] ??
|
|
885
|
+
templateValues[expression.identifier] ??
|
|
886
|
+
null
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (expression.type === "randomInteger") {
|
|
890
|
+
const step = expression.step > 0 ? expression.step : 1;
|
|
891
|
+
const count = Math.floor((expression.max - expression.min) / step) + 1;
|
|
892
|
+
return expression.min + Math.floor(random() * count) * step;
|
|
893
|
+
}
|
|
894
|
+
if (expression.type === "randomFloat") {
|
|
895
|
+
return expression.min + random() * (expression.max - expression.min);
|
|
896
|
+
}
|
|
897
|
+
if (expression.type === "random") {
|
|
898
|
+
if (expression.values.length === 0) return null;
|
|
899
|
+
const index = Math.floor(random() * expression.values.length);
|
|
900
|
+
return evaluateValue(
|
|
901
|
+
expression.values[index]!,
|
|
902
|
+
document,
|
|
903
|
+
responses,
|
|
904
|
+
outcomes,
|
|
905
|
+
templateValues,
|
|
906
|
+
correctResponses,
|
|
907
|
+
random,
|
|
908
|
+
customOperators,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (expression.type === "multiple" || expression.type === "ordered") {
|
|
912
|
+
const values = expression.expressions.flatMap((item) =>
|
|
913
|
+
valueContainer(
|
|
914
|
+
evaluateValue(
|
|
915
|
+
item,
|
|
916
|
+
document,
|
|
917
|
+
responses,
|
|
918
|
+
outcomes,
|
|
919
|
+
templateValues,
|
|
920
|
+
correctResponses,
|
|
921
|
+
random,
|
|
922
|
+
customOperators,
|
|
923
|
+
),
|
|
924
|
+
),
|
|
925
|
+
);
|
|
926
|
+
return values.length > 0 ? values : null;
|
|
927
|
+
}
|
|
928
|
+
if (expression.type === "index") {
|
|
929
|
+
const values = valueContainer(
|
|
930
|
+
evaluateValue(
|
|
931
|
+
expression.expression,
|
|
932
|
+
document,
|
|
933
|
+
responses,
|
|
934
|
+
outcomes,
|
|
935
|
+
templateValues,
|
|
936
|
+
correctResponses,
|
|
937
|
+
random,
|
|
938
|
+
customOperators,
|
|
939
|
+
),
|
|
940
|
+
);
|
|
941
|
+
const n = indexValue(expression.n, outcomes, templateValues);
|
|
942
|
+
if (n === undefined || n < 1 || n > values.length) return null;
|
|
943
|
+
return values[n - 1] ?? null;
|
|
944
|
+
}
|
|
945
|
+
if (expression.type === "containerSize") {
|
|
946
|
+
return valueContainer(
|
|
947
|
+
evaluateValue(
|
|
948
|
+
expression.expression,
|
|
949
|
+
document,
|
|
950
|
+
responses,
|
|
951
|
+
outcomes,
|
|
952
|
+
templateValues,
|
|
953
|
+
correctResponses,
|
|
954
|
+
random,
|
|
955
|
+
customOperators,
|
|
956
|
+
),
|
|
957
|
+
).length;
|
|
958
|
+
}
|
|
959
|
+
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,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
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,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
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
|
+
),
|
|
1011
|
+
);
|
|
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);
|
|
1015
|
+
}
|
|
1016
|
+
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
|
+
)
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
if (expression.type === "divide") {
|
|
1045
|
+
const dividendValue = evaluateValue(
|
|
1046
|
+
expression.left,
|
|
1047
|
+
document,
|
|
1048
|
+
responses,
|
|
1049
|
+
outcomes,
|
|
1050
|
+
templateValues,
|
|
1051
|
+
correctResponses,
|
|
1052
|
+
random,
|
|
1053
|
+
customOperators,
|
|
1054
|
+
);
|
|
1055
|
+
const divisorValue = evaluateValue(
|
|
1056
|
+
expression.right,
|
|
1057
|
+
document,
|
|
1058
|
+
responses,
|
|
1059
|
+
outcomes,
|
|
1060
|
+
templateValues,
|
|
1061
|
+
correctResponses,
|
|
1062
|
+
random,
|
|
1063
|
+
customOperators,
|
|
1064
|
+
);
|
|
1065
|
+
if (dividendValue === null || divisorValue === null) return null;
|
|
1066
|
+
const divisor = numericValue(divisorValue);
|
|
1067
|
+
if (divisor === 0) return null;
|
|
1068
|
+
const quotient = numericValue(dividendValue) / divisor;
|
|
1069
|
+
return Number.isFinite(quotient) ? quotient : null;
|
|
1070
|
+
}
|
|
1071
|
+
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
|
+
),
|
|
1097
|
+
);
|
|
1098
|
+
return Number.isFinite(value) ? value : null;
|
|
1099
|
+
}
|
|
1100
|
+
if (expression.type === "integerDivide") {
|
|
1101
|
+
const dividendValue = evaluateValue(
|
|
1102
|
+
expression.left,
|
|
1103
|
+
document,
|
|
1104
|
+
responses,
|
|
1105
|
+
outcomes,
|
|
1106
|
+
templateValues,
|
|
1107
|
+
correctResponses,
|
|
1108
|
+
random,
|
|
1109
|
+
customOperators,
|
|
1110
|
+
);
|
|
1111
|
+
const divisorValue = evaluateValue(
|
|
1112
|
+
expression.right,
|
|
1113
|
+
document,
|
|
1114
|
+
responses,
|
|
1115
|
+
outcomes,
|
|
1116
|
+
templateValues,
|
|
1117
|
+
correctResponses,
|
|
1118
|
+
random,
|
|
1119
|
+
customOperators,
|
|
1120
|
+
);
|
|
1121
|
+
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);
|
|
1125
|
+
}
|
|
1126
|
+
if (expression.type === "integerModulus") {
|
|
1127
|
+
const dividendValue = evaluateValue(
|
|
1128
|
+
expression.left,
|
|
1129
|
+
document,
|
|
1130
|
+
responses,
|
|
1131
|
+
outcomes,
|
|
1132
|
+
templateValues,
|
|
1133
|
+
correctResponses,
|
|
1134
|
+
random,
|
|
1135
|
+
customOperators,
|
|
1136
|
+
);
|
|
1137
|
+
const divisorValue = evaluateValue(
|
|
1138
|
+
expression.right,
|
|
1139
|
+
document,
|
|
1140
|
+
responses,
|
|
1141
|
+
outcomes,
|
|
1142
|
+
templateValues,
|
|
1143
|
+
correctResponses,
|
|
1144
|
+
random,
|
|
1145
|
+
customOperators,
|
|
1146
|
+
);
|
|
1147
|
+
if (dividendValue === null || divisorValue === null) return null;
|
|
1148
|
+
const divisor = numericValue(divisorValue);
|
|
1149
|
+
if (divisor === 0) return null;
|
|
1150
|
+
const dividend = numericValue(dividendValue);
|
|
1151
|
+
return dividend - Math.floor(dividend / divisor) * divisor;
|
|
1152
|
+
}
|
|
1153
|
+
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
|
+
),
|
|
1166
|
+
),
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
if (expression.type === "roundTo") {
|
|
1170
|
+
const value = numericValue(
|
|
1171
|
+
evaluateValue(
|
|
1172
|
+
expression.expression,
|
|
1173
|
+
document,
|
|
1174
|
+
responses,
|
|
1175
|
+
outcomes,
|
|
1176
|
+
templateValues,
|
|
1177
|
+
correctResponses,
|
|
1178
|
+
random,
|
|
1179
|
+
customOperators,
|
|
1180
|
+
),
|
|
1181
|
+
);
|
|
1182
|
+
return expression.roundingMode === "decimalPlaces"
|
|
1183
|
+
? roundToDecimalPlaces(value, expression.figures)
|
|
1184
|
+
: roundToSignificantFigures(value, expression.figures);
|
|
1185
|
+
}
|
|
1186
|
+
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
|
+
),
|
|
1199
|
+
),
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
if (expression.type === "integerToFloat") {
|
|
1203
|
+
const value = evaluateValue(
|
|
1204
|
+
expression.expression,
|
|
1205
|
+
document,
|
|
1206
|
+
responses,
|
|
1207
|
+
outcomes,
|
|
1208
|
+
templateValues,
|
|
1209
|
+
correctResponses,
|
|
1210
|
+
random,
|
|
1211
|
+
customOperators,
|
|
1212
|
+
);
|
|
1213
|
+
return value === null ? null : numericValue(value);
|
|
1214
|
+
}
|
|
1215
|
+
if (expression.type === "and") {
|
|
1216
|
+
return expression.expressions.every((item) =>
|
|
1217
|
+
booleanValue(
|
|
1218
|
+
evaluateValue(
|
|
1219
|
+
item,
|
|
1220
|
+
document,
|
|
1221
|
+
responses,
|
|
1222
|
+
outcomes,
|
|
1223
|
+
templateValues,
|
|
1224
|
+
correctResponses,
|
|
1225
|
+
random,
|
|
1226
|
+
customOperators,
|
|
1227
|
+
),
|
|
1228
|
+
),
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
if (expression.type === "anyN") {
|
|
1232
|
+
const min = indexValue(expression.min, outcomes, templateValues) ?? 0;
|
|
1233
|
+
const max = indexValue(expression.max, outcomes, templateValues) ?? 0;
|
|
1234
|
+
const values = expression.expressions.map((item) =>
|
|
1235
|
+
evaluateValue(
|
|
1236
|
+
item,
|
|
1237
|
+
document,
|
|
1238
|
+
responses,
|
|
1239
|
+
outcomes,
|
|
1240
|
+
templateValues,
|
|
1241
|
+
correctResponses,
|
|
1242
|
+
random,
|
|
1243
|
+
customOperators,
|
|
1244
|
+
),
|
|
1245
|
+
);
|
|
1246
|
+
const trueCount = values.filter((value) => value === true).length;
|
|
1247
|
+
const nullCount = values.filter((value) => value === null).length;
|
|
1248
|
+
if (min > max || trueCount > max || trueCount + nullCount < min) return false;
|
|
1249
|
+
if (trueCount >= min && trueCount <= max) return true;
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
if (expression.type === "or") {
|
|
1253
|
+
return expression.expressions.some((item) =>
|
|
1254
|
+
booleanValue(
|
|
1255
|
+
evaluateValue(
|
|
1256
|
+
item,
|
|
1257
|
+
document,
|
|
1258
|
+
responses,
|
|
1259
|
+
outcomes,
|
|
1260
|
+
templateValues,
|
|
1261
|
+
correctResponses,
|
|
1262
|
+
random,
|
|
1263
|
+
customOperators,
|
|
1264
|
+
),
|
|
1265
|
+
),
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
if (expression.type === "not") {
|
|
1269
|
+
return !booleanValue(
|
|
1270
|
+
evaluateValue(
|
|
1271
|
+
expression.expression,
|
|
1272
|
+
document,
|
|
1273
|
+
responses,
|
|
1274
|
+
outcomes,
|
|
1275
|
+
templateValues,
|
|
1276
|
+
correctResponses,
|
|
1277
|
+
random,
|
|
1278
|
+
customOperators,
|
|
1279
|
+
),
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
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
|
+
),
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
if (expression.type === "equalRounded") {
|
|
1307
|
+
const left = evaluateValue(
|
|
1308
|
+
expression.left,
|
|
1309
|
+
document,
|
|
1310
|
+
responses,
|
|
1311
|
+
outcomes,
|
|
1312
|
+
templateValues,
|
|
1313
|
+
correctResponses,
|
|
1314
|
+
random,
|
|
1315
|
+
customOperators,
|
|
1316
|
+
);
|
|
1317
|
+
const right = evaluateValue(
|
|
1318
|
+
expression.right,
|
|
1319
|
+
document,
|
|
1320
|
+
responses,
|
|
1321
|
+
outcomes,
|
|
1322
|
+
templateValues,
|
|
1323
|
+
correctResponses,
|
|
1324
|
+
random,
|
|
1325
|
+
customOperators,
|
|
1326
|
+
);
|
|
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
|
+
);
|
|
1338
|
+
return roundedLeft === null || roundedRight === null ? null : roundedLeft === roundedRight;
|
|
1339
|
+
}
|
|
1340
|
+
if (expression.type === "numericCompare") {
|
|
1341
|
+
const leftValue = evaluateValue(
|
|
1342
|
+
expression.left,
|
|
1343
|
+
document,
|
|
1344
|
+
responses,
|
|
1345
|
+
outcomes,
|
|
1346
|
+
templateValues,
|
|
1347
|
+
correctResponses,
|
|
1348
|
+
random,
|
|
1349
|
+
customOperators,
|
|
1350
|
+
);
|
|
1351
|
+
const rightValue = evaluateValue(
|
|
1352
|
+
expression.right,
|
|
1353
|
+
document,
|
|
1354
|
+
responses,
|
|
1355
|
+
outcomes,
|
|
1356
|
+
templateValues,
|
|
1357
|
+
correctResponses,
|
|
1358
|
+
random,
|
|
1359
|
+
customOperators,
|
|
1360
|
+
);
|
|
1361
|
+
if (leftValue === null || rightValue === null) return null;
|
|
1362
|
+
const left = numericValue(leftValue);
|
|
1363
|
+
const right = numericValue(rightValue);
|
|
1364
|
+
if (expression.operator === "lt") return left < right;
|
|
1365
|
+
if (expression.operator === "lte") return left <= right;
|
|
1366
|
+
if (expression.operator === "gt") return left > right;
|
|
1367
|
+
return left >= right;
|
|
1368
|
+
}
|
|
1369
|
+
if (expression.type === "durationCompare") {
|
|
1370
|
+
const leftValue = evaluateValue(
|
|
1371
|
+
expression.left,
|
|
1372
|
+
document,
|
|
1373
|
+
responses,
|
|
1374
|
+
outcomes,
|
|
1375
|
+
templateValues,
|
|
1376
|
+
correctResponses,
|
|
1377
|
+
random,
|
|
1378
|
+
customOperators,
|
|
1379
|
+
);
|
|
1380
|
+
const rightValue = evaluateValue(
|
|
1381
|
+
expression.right,
|
|
1382
|
+
document,
|
|
1383
|
+
responses,
|
|
1384
|
+
outcomes,
|
|
1385
|
+
templateValues,
|
|
1386
|
+
correctResponses,
|
|
1387
|
+
random,
|
|
1388
|
+
customOperators,
|
|
1389
|
+
);
|
|
1390
|
+
const left = durationSeconds(leftValue);
|
|
1391
|
+
const right = durationSeconds(rightValue);
|
|
1392
|
+
if (left === null || right === null) return null;
|
|
1393
|
+
return expression.operator === "lt" ? left < right : left >= right;
|
|
1394
|
+
}
|
|
1395
|
+
if (expression.type === "stringMatch") {
|
|
1396
|
+
return stringMatch(
|
|
1397
|
+
evaluateValue(
|
|
1398
|
+
expression.left,
|
|
1399
|
+
document,
|
|
1400
|
+
responses,
|
|
1401
|
+
outcomes,
|
|
1402
|
+
templateValues,
|
|
1403
|
+
correctResponses,
|
|
1404
|
+
random,
|
|
1405
|
+
customOperators,
|
|
1406
|
+
),
|
|
1407
|
+
evaluateValue(
|
|
1408
|
+
expression.right,
|
|
1409
|
+
document,
|
|
1410
|
+
responses,
|
|
1411
|
+
outcomes,
|
|
1412
|
+
templateValues,
|
|
1413
|
+
correctResponses,
|
|
1414
|
+
random,
|
|
1415
|
+
customOperators,
|
|
1416
|
+
),
|
|
1417
|
+
expression.caseSensitive,
|
|
1418
|
+
expression.substring,
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
if (expression.type === "substring") {
|
|
1422
|
+
return stringMatch(
|
|
1423
|
+
evaluateValue(
|
|
1424
|
+
expression.right,
|
|
1425
|
+
document,
|
|
1426
|
+
responses,
|
|
1427
|
+
outcomes,
|
|
1428
|
+
templateValues,
|
|
1429
|
+
correctResponses,
|
|
1430
|
+
random,
|
|
1431
|
+
customOperators,
|
|
1432
|
+
),
|
|
1433
|
+
evaluateValue(
|
|
1434
|
+
expression.left,
|
|
1435
|
+
document,
|
|
1436
|
+
responses,
|
|
1437
|
+
outcomes,
|
|
1438
|
+
templateValues,
|
|
1439
|
+
correctResponses,
|
|
1440
|
+
random,
|
|
1441
|
+
customOperators,
|
|
1442
|
+
),
|
|
1443
|
+
expression.caseSensitive,
|
|
1444
|
+
true,
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
if (expression.type === "patternMatch") {
|
|
1448
|
+
const value = evaluateValue(
|
|
1449
|
+
expression.expression,
|
|
1450
|
+
document,
|
|
1451
|
+
responses,
|
|
1452
|
+
outcomes,
|
|
1453
|
+
templateValues,
|
|
1454
|
+
correctResponses,
|
|
1455
|
+
random,
|
|
1456
|
+
customOperators,
|
|
1457
|
+
);
|
|
1458
|
+
if (value === null) return null;
|
|
1459
|
+
const patternValue =
|
|
1460
|
+
responses[expression.pattern] ??
|
|
1461
|
+
outcomes[expression.pattern] ??
|
|
1462
|
+
templateValues[expression.pattern] ??
|
|
1463
|
+
expression.pattern;
|
|
1464
|
+
try {
|
|
1465
|
+
return new RegExp(String(patternValue)).test(String(value));
|
|
1466
|
+
} catch {
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (expression.type === "fieldValue") {
|
|
1471
|
+
const value = evaluateValue(
|
|
1472
|
+
expression.expression,
|
|
1473
|
+
document,
|
|
1474
|
+
responses,
|
|
1475
|
+
outcomes,
|
|
1476
|
+
templateValues,
|
|
1477
|
+
correctResponses,
|
|
1478
|
+
random,
|
|
1479
|
+
customOperators,
|
|
1480
|
+
);
|
|
1481
|
+
return isRecordValue(value) ? (value[expression.fieldIdentifier] ?? null) : null;
|
|
1482
|
+
}
|
|
1483
|
+
if (expression.type === "member") {
|
|
1484
|
+
const value = evaluateValue(
|
|
1485
|
+
expression.value,
|
|
1486
|
+
document,
|
|
1487
|
+
responses,
|
|
1488
|
+
outcomes,
|
|
1489
|
+
templateValues,
|
|
1490
|
+
correctResponses,
|
|
1491
|
+
random,
|
|
1492
|
+
customOperators,
|
|
1493
|
+
);
|
|
1494
|
+
const collection = evaluateValue(
|
|
1495
|
+
expression.collection,
|
|
1496
|
+
document,
|
|
1497
|
+
responses,
|
|
1498
|
+
outcomes,
|
|
1499
|
+
templateValues,
|
|
1500
|
+
correctResponses,
|
|
1501
|
+
random,
|
|
1502
|
+
customOperators,
|
|
1503
|
+
);
|
|
1504
|
+
const values = valueContainer(collection);
|
|
1505
|
+
return value === null ? null : values.some((item) => valuesEqual(item, value));
|
|
1506
|
+
}
|
|
1507
|
+
if (expression.type === "delete") {
|
|
1508
|
+
const value = evaluateValue(
|
|
1509
|
+
expression.value,
|
|
1510
|
+
document,
|
|
1511
|
+
responses,
|
|
1512
|
+
outcomes,
|
|
1513
|
+
templateValues,
|
|
1514
|
+
correctResponses,
|
|
1515
|
+
random,
|
|
1516
|
+
customOperators,
|
|
1517
|
+
);
|
|
1518
|
+
const collection = valueContainer(
|
|
1519
|
+
evaluateValue(
|
|
1520
|
+
expression.collection,
|
|
1521
|
+
document,
|
|
1522
|
+
responses,
|
|
1523
|
+
outcomes,
|
|
1524
|
+
templateValues,
|
|
1525
|
+
correctResponses,
|
|
1526
|
+
random,
|
|
1527
|
+
customOperators,
|
|
1528
|
+
),
|
|
1529
|
+
);
|
|
1530
|
+
if (value === null || collection.length === 0) return null;
|
|
1531
|
+
const filtered = collection.filter((item) => !valuesEqual(item, value));
|
|
1532
|
+
return filtered.length > 0 ? filtered : null;
|
|
1533
|
+
}
|
|
1534
|
+
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
|
+
),
|
|
1546
|
+
);
|
|
1547
|
+
const values = valueContainer(
|
|
1548
|
+
evaluateValue(
|
|
1549
|
+
expression.values,
|
|
1550
|
+
document,
|
|
1551
|
+
responses,
|
|
1552
|
+
outcomes,
|
|
1553
|
+
templateValues,
|
|
1554
|
+
correctResponses,
|
|
1555
|
+
random,
|
|
1556
|
+
customOperators,
|
|
1557
|
+
),
|
|
1558
|
+
);
|
|
1559
|
+
if (collection.length === 0 || values.length === 0) return null;
|
|
1560
|
+
return containsValues(collection, values);
|
|
1561
|
+
}
|
|
1562
|
+
if (expression.type === "gcd" || expression.type === "lcm") {
|
|
1563
|
+
const values = expression.expressions.flatMap((item) => {
|
|
1564
|
+
const value = evaluateValue(
|
|
1565
|
+
item,
|
|
1566
|
+
document,
|
|
1567
|
+
responses,
|
|
1568
|
+
outcomes,
|
|
1569
|
+
templateValues,
|
|
1570
|
+
correctResponses,
|
|
1571
|
+
random,
|
|
1572
|
+
customOperators,
|
|
1573
|
+
);
|
|
1574
|
+
return value === null ? [null] : valueContainer(value);
|
|
1575
|
+
});
|
|
1576
|
+
if (values.length === 0 || values.some((value) => value === null)) return null;
|
|
1577
|
+
const integers = values.map((value) => Math.trunc(numericValue(value)));
|
|
1578
|
+
return expression.type === "gcd" ? generalizedGcd(integers) : generalizedLcm(integers);
|
|
1579
|
+
}
|
|
1580
|
+
if (expression.type === "inside") {
|
|
1581
|
+
const value = evaluateValue(
|
|
1582
|
+
expression.expression,
|
|
1583
|
+
document,
|
|
1584
|
+
responses,
|
|
1585
|
+
outcomes,
|
|
1586
|
+
templateValues,
|
|
1587
|
+
correctResponses,
|
|
1588
|
+
random,
|
|
1589
|
+
customOperators,
|
|
1590
|
+
);
|
|
1591
|
+
if (value === null) return null;
|
|
1592
|
+
if (expression.shape === "default") return true;
|
|
1593
|
+
return valueContainer(value).some((pointValue) => {
|
|
1594
|
+
const point = parsePoint(String(pointValue));
|
|
1595
|
+
return point
|
|
1596
|
+
? pointInsideArea(point, {
|
|
1597
|
+
shape: expression.shape,
|
|
1598
|
+
coords: expression.coords,
|
|
1599
|
+
mappedValue: 0,
|
|
1600
|
+
attributes: {},
|
|
1601
|
+
})
|
|
1602
|
+
: false;
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
if (expression.type === "mathConstant") {
|
|
1606
|
+
if (expression.name === "pi") return Math.PI;
|
|
1607
|
+
if (expression.name === "e") return Math.E;
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
if (expression.type === "mathOperator") {
|
|
1611
|
+
const values = expression.expressions.map((item) =>
|
|
1612
|
+
evaluateValue(
|
|
1613
|
+
item,
|
|
1614
|
+
document,
|
|
1615
|
+
responses,
|
|
1616
|
+
outcomes,
|
|
1617
|
+
templateValues,
|
|
1618
|
+
correctResponses,
|
|
1619
|
+
random,
|
|
1620
|
+
customOperators,
|
|
1621
|
+
),
|
|
1622
|
+
);
|
|
1623
|
+
if (values.length === 0 || values.some((value) => value === null)) return null;
|
|
1624
|
+
return mathOperatorValue(expression.name, values.map(numericValue));
|
|
1625
|
+
}
|
|
1626
|
+
if (expression.type === "repeat") {
|
|
1627
|
+
const repeats = indexValue(expression.numberRepeats, outcomes, templateValues);
|
|
1628
|
+
if (repeats === undefined || repeats < 1) return null;
|
|
1629
|
+
const container: QtiScalarValue[] = [];
|
|
1630
|
+
for (let repeat = 0; repeat < repeats; repeat += 1) {
|
|
1631
|
+
for (const item of expression.expressions) {
|
|
1632
|
+
const value = evaluateValue(
|
|
1633
|
+
item,
|
|
1634
|
+
document,
|
|
1635
|
+
responses,
|
|
1636
|
+
outcomes,
|
|
1637
|
+
templateValues,
|
|
1638
|
+
correctResponses,
|
|
1639
|
+
random,
|
|
1640
|
+
customOperators,
|
|
1641
|
+
);
|
|
1642
|
+
container.push(...valueContainer(value));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return container.length > 0 ? container : null;
|
|
1646
|
+
}
|
|
1647
|
+
if (expression.type === "statsOperator") {
|
|
1648
|
+
const value = evaluateValue(
|
|
1649
|
+
expression.expression,
|
|
1650
|
+
document,
|
|
1651
|
+
responses,
|
|
1652
|
+
outcomes,
|
|
1653
|
+
templateValues,
|
|
1654
|
+
correctResponses,
|
|
1655
|
+
random,
|
|
1656
|
+
customOperators,
|
|
1657
|
+
);
|
|
1658
|
+
if (value === null) return null;
|
|
1659
|
+
const values = valueContainer(value).map(numericValue);
|
|
1660
|
+
return statsOperatorValue(expression.name, values);
|
|
1661
|
+
}
|
|
1662
|
+
if (expression.type === "customOperator") {
|
|
1663
|
+
const operatorKey = expression.definition ?? expression.className ?? "";
|
|
1664
|
+
const handler = customOperators[operatorKey];
|
|
1665
|
+
if (!handler) return null;
|
|
1666
|
+
return handler({
|
|
1667
|
+
definition: expression.definition,
|
|
1668
|
+
className: expression.className,
|
|
1669
|
+
attributes: expression.attributes,
|
|
1670
|
+
values: expression.expressions.map((item) =>
|
|
1671
|
+
evaluateValue(
|
|
1672
|
+
item,
|
|
1673
|
+
document,
|
|
1674
|
+
responses,
|
|
1675
|
+
outcomes,
|
|
1676
|
+
templateValues,
|
|
1677
|
+
correctResponses,
|
|
1678
|
+
random,
|
|
1679
|
+
customOperators,
|
|
1680
|
+
),
|
|
1681
|
+
),
|
|
1682
|
+
expression,
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function getResponseDeclaration(
|
|
1689
|
+
document: QtiDocument,
|
|
1690
|
+
identifier: string,
|
|
1691
|
+
): QtiResponseDeclaration | undefined {
|
|
1692
|
+
return document.item.responseDeclarations.find(
|
|
1693
|
+
(declaration) => declaration.identifier === identifier,
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function expressionIsOrdered(expression: QtiProcessingExpression, document: QtiDocument): boolean {
|
|
1698
|
+
if (expression.type === "ordered") return true;
|
|
1699
|
+
if (
|
|
1700
|
+
(expression.type === "variable" ||
|
|
1701
|
+
expression.type === "correct" ||
|
|
1702
|
+
expression.type === "default" ||
|
|
1703
|
+
expression.type === "isNull") &&
|
|
1704
|
+
variableCardinality(document, expression.identifier) === "ordered"
|
|
1705
|
+
) {
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function variableCardinality(document: QtiDocument, identifier: string): string | undefined {
|
|
1712
|
+
return (
|
|
1713
|
+
document.item.responseDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1714
|
+
?.cardinality ??
|
|
1715
|
+
document.item.outcomeDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1716
|
+
?.cardinality ??
|
|
1717
|
+
document.item.templateDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1718
|
+
?.cardinality
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function defaultValueForIdentifier(document: QtiDocument, identifier: string): QtiValue {
|
|
1723
|
+
return (
|
|
1724
|
+
document.item.responseDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1725
|
+
?.defaultValue ??
|
|
1726
|
+
document.item.outcomeDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1727
|
+
?.defaultValue ??
|
|
1728
|
+
document.item.templateDeclarations.find((declaration) => declaration.identifier === identifier)
|
|
1729
|
+
?.defaultValue ??
|
|
1730
|
+
null
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function lookupOutcomeValue(document: QtiDocument, identifier: string, value: QtiValue): QtiValue {
|
|
1735
|
+
const declaration = document.item.outcomeDeclarations.find(
|
|
1736
|
+
(outcome) => outcome.identifier === identifier,
|
|
1737
|
+
);
|
|
1738
|
+
const lookupTable = declaration?.lookupTable;
|
|
1739
|
+
if (!lookupTable) return null;
|
|
1740
|
+
if (value === null) return lookupTable.defaultValue;
|
|
1741
|
+
const numeric = numericValue(value);
|
|
1742
|
+
if (lookupTable.type === "match") {
|
|
1743
|
+
return (
|
|
1744
|
+
lookupTable.entries.find((entry) => entry.sourceValue === numeric)?.targetValue ??
|
|
1745
|
+
lookupTable.defaultValue
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const entry = [...lookupTable.entries]
|
|
1749
|
+
.sort((left, right) => left.sourceValue - right.sourceValue)
|
|
1750
|
+
.find(
|
|
1751
|
+
(candidate) =>
|
|
1752
|
+
numeric < candidate.sourceValue ||
|
|
1753
|
+
(candidate.includeBoundary !== false && numeric === candidate.sourceValue),
|
|
1754
|
+
);
|
|
1755
|
+
return entry?.targetValue ?? lookupTable.defaultValue;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function mapOrMatchResponse(
|
|
1759
|
+
declaration: QtiResponseDeclaration,
|
|
1760
|
+
response: QtiValue,
|
|
1761
|
+
correctResponse: QtiValue,
|
|
1762
|
+
): number {
|
|
1763
|
+
if (declaration.areaMapping) return scoreAreaMapping(response, declaration.areaMapping);
|
|
1764
|
+
if (declaration.mapping) return scoreMapping(response, declaration.mapping);
|
|
1765
|
+
return valuesEqual(response, correctResponse, declaration.cardinality === "ordered") ? 1 : 0;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function scoreAreaMapping(
|
|
1769
|
+
response: QtiValue,
|
|
1770
|
+
areaMapping: NonNullable<QtiResponseDeclaration["areaMapping"]>,
|
|
1771
|
+
): number {
|
|
1772
|
+
const points = Array.isArray(response) ? response : response === null ? [] : [String(response)];
|
|
1773
|
+
let score = 0;
|
|
1774
|
+
for (const point of points) {
|
|
1775
|
+
const parsed = parsePoint(String(point));
|
|
1776
|
+
if (!parsed) {
|
|
1777
|
+
score += areaMapping.defaultValue;
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
const entry = areaMapping.entries.find((candidate) => pointInsideArea(parsed, candidate));
|
|
1781
|
+
score += entry?.mappedValue ?? areaMapping.defaultValue;
|
|
1782
|
+
}
|
|
1783
|
+
return clampMappedScore(score, areaMapping.attributes);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function parsePoint(value: string): { x: number; y: number } | undefined {
|
|
1787
|
+
const [x, y] = value
|
|
1788
|
+
.trim()
|
|
1789
|
+
.split(/[,\s]+/)
|
|
1790
|
+
.map((part) => Number(part));
|
|
1791
|
+
if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
1792
|
+
return undefined;
|
|
1793
|
+
}
|
|
1794
|
+
return { x, y };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function pointInsideArea(
|
|
1798
|
+
point: { x: number; y: number },
|
|
1799
|
+
entry: NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number],
|
|
1800
|
+
): boolean {
|
|
1801
|
+
if (entry.shape === "circle") {
|
|
1802
|
+
const [cx, cy, radius] = entry.coords;
|
|
1803
|
+
if (cx === undefined || cy === undefined || radius === undefined) return false;
|
|
1804
|
+
return Math.hypot(point.x - cx, point.y - cy) <= radius;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (entry.shape === "rect") {
|
|
1808
|
+
const [left, top, right, bottom] = entry.coords;
|
|
1809
|
+
if (left === undefined || top === undefined || right === undefined || bottom === undefined) {
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (entry.shape === "poly") {
|
|
1816
|
+
return pointInsidePolygon(point, entry.coords);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
return false;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function pointInsidePolygon(point: { x: number; y: number }, coords: number[]): boolean {
|
|
1823
|
+
if (coords.length < 6 || coords.length % 2 !== 0) return false;
|
|
1824
|
+
let inside = false;
|
|
1825
|
+
for (let index = 0, previous = coords.length - 2; index < coords.length; index += 2) {
|
|
1826
|
+
const xi = coords[index]!;
|
|
1827
|
+
const yi = coords[index + 1]!;
|
|
1828
|
+
const xj = coords[previous]!;
|
|
1829
|
+
const yj = coords[previous + 1]!;
|
|
1830
|
+
const intersects =
|
|
1831
|
+
yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
|
|
1832
|
+
if (intersects) inside = !inside;
|
|
1833
|
+
previous = index;
|
|
1834
|
+
}
|
|
1835
|
+
return inside;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function isNullResponse(response: QtiValue): boolean {
|
|
1839
|
+
return response === null || response === "" || (Array.isArray(response) && response.length === 0);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function serialize(
|
|
1843
|
+
itemIdentifier: string,
|
|
1844
|
+
status: QtiAttemptStatus,
|
|
1845
|
+
responses: Record<string, QtiValue>,
|
|
1846
|
+
outcomes: Record<string, QtiValue>,
|
|
1847
|
+
templateValues: Record<string, QtiValue>,
|
|
1848
|
+
interactionStates: Record<string, QtiPortableCustomStateValue>,
|
|
1849
|
+
validationMessages: QtiDiagnostic[],
|
|
1850
|
+
): QtiAttemptStateV1 {
|
|
1851
|
+
return {
|
|
1852
|
+
schema: ATTEMPT_STATE_SCHEMA,
|
|
1853
|
+
itemIdentifier,
|
|
1854
|
+
status,
|
|
1855
|
+
responses: cloneValueRecord(responses),
|
|
1856
|
+
outcomes: cloneValueRecord(outcomes),
|
|
1857
|
+
templateValues: cloneValueRecord(templateValues),
|
|
1858
|
+
interactionStates: clonePortableCustomStateRecord(interactionStates),
|
|
1859
|
+
validationMessages: cloneDiagnostics(validationMessages),
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function cloneValueRecord(record: Record<string, QtiValue>): Record<string, QtiValue> {
|
|
1864
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, cloneValue(value)]));
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function clonePortableCustomStateRecord(
|
|
1868
|
+
record: Record<string, QtiPortableCustomStateValue>,
|
|
1869
|
+
): Record<string, QtiPortableCustomStateValue> {
|
|
1870
|
+
return Object.fromEntries(
|
|
1871
|
+
Object.entries(record).map(([key, value]) => [key, clonePortableCustomState(value)]),
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function cloneValue(value: QtiValue): QtiValue {
|
|
1876
|
+
if (Array.isArray(value)) return [...value];
|
|
1877
|
+
if (isRecordValue(value)) return cloneValueRecord(value);
|
|
1878
|
+
return value;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function clonePortableCustomState(value: QtiPortableCustomStateValue): QtiPortableCustomStateValue {
|
|
1882
|
+
if (Array.isArray(value)) return value.map(clonePortableCustomState);
|
|
1883
|
+
if (isPortableCustomStateObject(value)) return clonePortableCustomStateRecord(value);
|
|
1884
|
+
return value;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function cloneDiagnostics(diagnostics: QtiDiagnostic[]): QtiDiagnostic[] {
|
|
1888
|
+
return diagnostics.map((diagnostic) => ({
|
|
1889
|
+
...diagnostic,
|
|
1890
|
+
source: diagnostic.source ? { ...diagnostic.source } : undefined,
|
|
1891
|
+
}));
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function scoreMapping(
|
|
1895
|
+
response: QtiValue,
|
|
1896
|
+
mapping: NonNullable<QtiResponseDeclaration["mapping"]>,
|
|
1897
|
+
): number {
|
|
1898
|
+
const values = Object.fromEntries(
|
|
1899
|
+
mapping.entries
|
|
1900
|
+
.filter((entry) => entry.mapKey !== undefined)
|
|
1901
|
+
.map((entry) => [entry.mapKey!, entry.mappedValue]),
|
|
1902
|
+
);
|
|
1903
|
+
if (Array.isArray(response)) {
|
|
1904
|
+
const score = response.reduce<number>(
|
|
1905
|
+
(sum, value) => sum + (values[String(value)] ?? mapping.defaultValue),
|
|
1906
|
+
0,
|
|
1907
|
+
);
|
|
1908
|
+
return clampMappedScore(score, mapping.attributes);
|
|
1909
|
+
}
|
|
1910
|
+
const score = typeof response === "string" ? (values[response] ?? mapping.defaultValue) : 0;
|
|
1911
|
+
return clampMappedScore(score, mapping.attributes);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function clampMappedScore(score: number, attributes: Record<string, string>): number {
|
|
1915
|
+
const lower = numericBound(attributes["lower-bound"]);
|
|
1916
|
+
const upper = numericBound(attributes["upper-bound"]);
|
|
1917
|
+
let clamped = score;
|
|
1918
|
+
if (lower !== undefined) clamped = Math.max(clamped, lower);
|
|
1919
|
+
if (upper !== undefined) clamped = Math.min(clamped, upper);
|
|
1920
|
+
return clamped;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function numericBound(value: string | undefined): number | undefined {
|
|
1924
|
+
if (value === undefined) return undefined;
|
|
1925
|
+
const numberValue = Number(value);
|
|
1926
|
+
return Number.isFinite(numberValue) ? numberValue : undefined;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function valuesEqual(actual: QtiValue, expected: QtiValue, ordered = false): boolean {
|
|
1930
|
+
if (isRecordValue(actual) || isRecordValue(expected)) {
|
|
1931
|
+
if (!isRecordValue(actual) || !isRecordValue(expected)) return false;
|
|
1932
|
+
const actualKeys = Object.keys(actual).sort();
|
|
1933
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
1934
|
+
return (
|
|
1935
|
+
valuesEqual(actualKeys, expectedKeys, true) &&
|
|
1936
|
+
actualKeys.every((key) => valuesEqual(actual[key] ?? null, expected[key] ?? null, ordered))
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
if (Array.isArray(actual) || Array.isArray(expected)) {
|
|
1940
|
+
const actualValues = valueContainer(actual);
|
|
1941
|
+
const expectedValues = Array.isArray(expected) ? expected : expected === null ? [] : [expected];
|
|
1942
|
+
if (actualValues.length !== expectedValues.length) return false;
|
|
1943
|
+
if (ordered)
|
|
1944
|
+
return actualValues.every((value, index) => scalarValuesEqual(value, expectedValues[index]!));
|
|
1945
|
+
const sortedExpected = [...expectedValues].sort(compareScalarValues);
|
|
1946
|
+
return [...actualValues]
|
|
1947
|
+
.sort(compareScalarValues)
|
|
1948
|
+
.every((value, index) => scalarValuesEqual(value, sortedExpected[index]!));
|
|
1949
|
+
}
|
|
1950
|
+
return scalarValuesEqual(actual, expected);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function scalarValuesEqual(actual: QtiValue, expected: QtiValue): boolean {
|
|
1954
|
+
if (typeof actual === "boolean" && typeof expected === "string") {
|
|
1955
|
+
return String(actual) === expected;
|
|
1956
|
+
}
|
|
1957
|
+
if (typeof actual === "string" && typeof expected === "boolean") {
|
|
1958
|
+
return actual === String(expected);
|
|
1959
|
+
}
|
|
1960
|
+
return actual === expected;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function normalizeValueForCardinality(
|
|
1964
|
+
value: QtiValue,
|
|
1965
|
+
cardinality: QtiResponseDeclaration["cardinality"],
|
|
1966
|
+
): QtiValue {
|
|
1967
|
+
if (
|
|
1968
|
+
(cardinality === "multiple" || cardinality === "ordered") &&
|
|
1969
|
+
value !== null &&
|
|
1970
|
+
!Array.isArray(value) &&
|
|
1971
|
+
!isRecordValue(value)
|
|
1972
|
+
) {
|
|
1973
|
+
return [value];
|
|
1974
|
+
}
|
|
1975
|
+
return value;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function valueContainer(value: QtiValue): QtiScalarValue[] {
|
|
1979
|
+
if (value === null) return [];
|
|
1980
|
+
if (isRecordValue(value)) return [];
|
|
1981
|
+
return Array.isArray(value) ? value.filter((item) => item !== null) : [value];
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function isRecordValue(value: QtiValue): value is QtiRecordValue {
|
|
1985
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
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
|
+
function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
|
|
2004
|
+
if (!isRecord(value)) return false;
|
|
2005
|
+
return Object.values(value).every(isQtiValue);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
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;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function isPortableCustomStateObject(
|
|
2018
|
+
value: QtiPortableCustomStateValue,
|
|
2019
|
+
): value is { [key: string]: QtiPortableCustomStateValue } {
|
|
2020
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function isPortableCustomStateRecord(
|
|
2024
|
+
value: unknown,
|
|
2025
|
+
): value is Record<string, QtiPortableCustomStateValue> {
|
|
2026
|
+
if (!isRecord(value)) return false;
|
|
2027
|
+
return Object.values(value).every(isPortableCustomState);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function isDiagnosticArray(value: unknown): value is QtiDiagnostic[] {
|
|
2031
|
+
if (!Array.isArray(value)) return false;
|
|
2032
|
+
return value.every((item) => {
|
|
2033
|
+
if (!isRecord(item)) return false;
|
|
2034
|
+
if (typeof item.code !== "string" || item.code.length === 0) return false;
|
|
2035
|
+
if (item.severity !== "info" && item.severity !== "warning" && item.severity !== "error") {
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
if (typeof item.message !== "string" || item.message.length === 0) return false;
|
|
2039
|
+
return item.path === undefined || typeof item.path === "string";
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2044
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function indexValue(
|
|
2048
|
+
n: string,
|
|
2049
|
+
outcomes: Record<string, QtiValue>,
|
|
2050
|
+
templateValues: Record<string, QtiValue>,
|
|
2051
|
+
): number | undefined {
|
|
2052
|
+
const parsed = Number(n);
|
|
2053
|
+
if (Number.isInteger(parsed)) return parsed;
|
|
2054
|
+
const value = outcomes[n] ?? templateValues[n] ?? null;
|
|
2055
|
+
const numeric = numericValue(value);
|
|
2056
|
+
return Number.isInteger(numeric) ? numeric : undefined;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function containsValues(collection: QtiScalarValue[], values: QtiScalarValue[]): boolean {
|
|
2060
|
+
const remaining = [...collection];
|
|
2061
|
+
for (const value of values) {
|
|
2062
|
+
const index = remaining.findIndex((candidate) => valuesEqual(candidate, value));
|
|
2063
|
+
if (index === -1) return false;
|
|
2064
|
+
remaining.splice(index, 1);
|
|
2065
|
+
}
|
|
2066
|
+
return true;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function compareScalarValues(left: QtiScalarValue, right: QtiScalarValue): number {
|
|
2070
|
+
return String(left).localeCompare(String(right));
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function numericValue(value: QtiValue): number {
|
|
2074
|
+
if (typeof value === "number") return value;
|
|
2075
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
2076
|
+
if (typeof value === "string") return Number(value);
|
|
2077
|
+
return 0;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function durationSeconds(value: QtiValue): number | null {
|
|
2081
|
+
if (value === null || Array.isArray(value) || isRecordValue(value)) return null;
|
|
2082
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
2083
|
+
const raw = String(value).trim();
|
|
2084
|
+
if (raw.length === 0) return null;
|
|
2085
|
+
const numeric = Number(raw);
|
|
2086
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
2087
|
+
const match =
|
|
2088
|
+
/^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(
|
|
2089
|
+
raw,
|
|
2090
|
+
);
|
|
2091
|
+
if (!match) return null;
|
|
2092
|
+
const [, days, hours, minutes, seconds] = match;
|
|
2093
|
+
if (!days && !hours && !minutes && !seconds) return null;
|
|
2094
|
+
const total =
|
|
2095
|
+
Number(days ?? 0) * 86_400 +
|
|
2096
|
+
Number(hours ?? 0) * 3_600 +
|
|
2097
|
+
Number(minutes ?? 0) * 60 +
|
|
2098
|
+
Number(seconds ?? 0);
|
|
2099
|
+
return Number.isFinite(total) ? total : null;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function booleanValue(value: QtiValue): boolean {
|
|
2103
|
+
if (typeof value === "boolean") return value;
|
|
2104
|
+
if (typeof value === "number") return value !== 0;
|
|
2105
|
+
if (typeof value === "string") return value.length > 0 && value !== "false";
|
|
2106
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function roundToDecimalPlaces(value: number, figures: number): number {
|
|
2111
|
+
const factor = 10 ** figures;
|
|
2112
|
+
return Math.round(value * factor) / factor;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function roundToSignificantFigures(value: number, figures: number): number {
|
|
2116
|
+
if (value === 0 || figures <= 0) return 0;
|
|
2117
|
+
const factor = 10 ** (figures - 1 - Math.floor(Math.log10(Math.abs(value))));
|
|
2118
|
+
return Math.round(value * factor) / factor;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function roundWithMode(value: number, roundingMode: string, figures: number): number | null {
|
|
2122
|
+
if (!Number.isFinite(value)) return null;
|
|
2123
|
+
if (roundingMode === "decimalPlaces") return roundToDecimalPlaces(value, figures);
|
|
2124
|
+
if (roundingMode === "significantFigures") return roundToSignificantFigures(value, figures);
|
|
2125
|
+
return null;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function generalizedGcd(values: number[]): number {
|
|
2129
|
+
let result = 0;
|
|
2130
|
+
for (const value of values) {
|
|
2131
|
+
result = gcd(result, Math.abs(value));
|
|
2132
|
+
}
|
|
2133
|
+
return result;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function generalizedLcm(values: number[]): number {
|
|
2137
|
+
if (values.some((value) => value === 0)) return 0;
|
|
2138
|
+
return values.reduce((result, value) => lcm(result, Math.abs(value)), 1);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function gcd(left: number, right: number): number {
|
|
2142
|
+
let a = Math.abs(left);
|
|
2143
|
+
let b = Math.abs(right);
|
|
2144
|
+
while (b !== 0) {
|
|
2145
|
+
const next = a % b;
|
|
2146
|
+
a = b;
|
|
2147
|
+
b = next;
|
|
2148
|
+
}
|
|
2149
|
+
return a;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function lcm(left: number, right: number): number {
|
|
2153
|
+
return Math.abs(left * right) / gcd(left, right);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function mathOperatorValue(name: string, values: number[]): QtiValue {
|
|
2157
|
+
const [first, second] = values;
|
|
2158
|
+
if (first === undefined) return null;
|
|
2159
|
+
switch (name) {
|
|
2160
|
+
case "abs":
|
|
2161
|
+
return finiteOrNull(Math.abs(first));
|
|
2162
|
+
case "acos":
|
|
2163
|
+
return Math.abs(first) > 1 ? null : finiteOrNull(Math.acos(first));
|
|
2164
|
+
case "acot":
|
|
2165
|
+
return finiteOrNull(Math.PI / 2 - Math.atan(1 / first));
|
|
2166
|
+
case "acsc":
|
|
2167
|
+
return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.asin(1 / first));
|
|
2168
|
+
case "asec":
|
|
2169
|
+
return Math.abs(first) < 1 ? null : finiteOrNull(Math.PI / 2 - Math.acos(1 / first));
|
|
2170
|
+
case "asin":
|
|
2171
|
+
return Math.abs(first) > 1 ? null : finiteOrNull(Math.asin(first));
|
|
2172
|
+
case "atan":
|
|
2173
|
+
return finiteOrNull(Math.atan(first));
|
|
2174
|
+
case "atan2":
|
|
2175
|
+
return second === undefined ? null : finiteOrNull(Math.atan2(first, second));
|
|
2176
|
+
case "ceil":
|
|
2177
|
+
return finiteOrNull(Math.ceil(first));
|
|
2178
|
+
case "cos":
|
|
2179
|
+
return finiteOrNull(Math.cos(first));
|
|
2180
|
+
case "cosh":
|
|
2181
|
+
return finiteOrNull(Math.cosh(first));
|
|
2182
|
+
case "cot":
|
|
2183
|
+
return finiteOrNull(1 / Math.tan(first));
|
|
2184
|
+
case "coth":
|
|
2185
|
+
return finiteOrNull(1 / Math.tanh(first));
|
|
2186
|
+
case "csc":
|
|
2187
|
+
return finiteOrNull(1 / Math.sin(first));
|
|
2188
|
+
case "csch":
|
|
2189
|
+
return finiteOrNull(1 / Math.sinh(first));
|
|
2190
|
+
case "exp":
|
|
2191
|
+
return finiteOrNull(Math.exp(first));
|
|
2192
|
+
case "floor":
|
|
2193
|
+
return finiteOrNull(Math.floor(first));
|
|
2194
|
+
case "ln":
|
|
2195
|
+
return first < 0 ? null : finiteOrNull(Math.log(first));
|
|
2196
|
+
case "log":
|
|
2197
|
+
return first < 0 ? null : finiteOrNull(Math.log10(first));
|
|
2198
|
+
case "sec":
|
|
2199
|
+
return finiteOrNull(1 / Math.cos(first));
|
|
2200
|
+
case "sech":
|
|
2201
|
+
return finiteOrNull(1 / Math.cosh(first));
|
|
2202
|
+
case "signum":
|
|
2203
|
+
return finiteOrNull(Math.sign(first));
|
|
2204
|
+
case "sin":
|
|
2205
|
+
return finiteOrNull(Math.sin(first));
|
|
2206
|
+
case "sinh":
|
|
2207
|
+
return finiteOrNull(Math.sinh(first));
|
|
2208
|
+
case "tan":
|
|
2209
|
+
return finiteOrNull(Math.tan(first));
|
|
2210
|
+
case "tanh":
|
|
2211
|
+
return finiteOrNull(Math.tanh(first));
|
|
2212
|
+
case "toDegrees":
|
|
2213
|
+
return finiteOrNull((first * 180) / Math.PI);
|
|
2214
|
+
case "toRadians":
|
|
2215
|
+
return finiteOrNull((first * Math.PI) / 180);
|
|
2216
|
+
default:
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
function statsOperatorValue(name: string, values: number[]): QtiValue {
|
|
2222
|
+
if (values.length === 0) return 0;
|
|
2223
|
+
const meanValue = mean(values);
|
|
2224
|
+
const squareDiffs = values.map((value) => (value - meanValue) ** 2);
|
|
2225
|
+
switch (name) {
|
|
2226
|
+
case "mean":
|
|
2227
|
+
return meanValue;
|
|
2228
|
+
case "sampleVariance":
|
|
2229
|
+
return meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1);
|
|
2230
|
+
case "sampleSD":
|
|
2231
|
+
return Math.sqrt(meanWithDivisor(squareDiffs, values.length > 1 ? values.length - 1 : 1));
|
|
2232
|
+
case "popVariance":
|
|
2233
|
+
return mean(squareDiffs);
|
|
2234
|
+
case "popSD":
|
|
2235
|
+
return Math.sqrt(mean(squareDiffs));
|
|
2236
|
+
default:
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function mean(values: number[]): number {
|
|
2242
|
+
return meanWithDivisor(values, values.length);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function meanWithDivisor(values: number[], divisor: number): number {
|
|
2246
|
+
if (values.length === 0) return 0;
|
|
2247
|
+
return values.reduce((sum, value) => sum + value, 0) / divisor;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function finiteOrNull(value: number): QtiValue {
|
|
2251
|
+
return Number.isFinite(value) ? value : null;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function stringMatch(
|
|
2255
|
+
left: QtiValue,
|
|
2256
|
+
right: QtiValue,
|
|
2257
|
+
caseSensitive: boolean,
|
|
2258
|
+
substring: boolean,
|
|
2259
|
+
): boolean {
|
|
2260
|
+
let actual = String(left ?? "");
|
|
2261
|
+
let expected = String(right ?? "");
|
|
2262
|
+
if (!caseSensitive) {
|
|
2263
|
+
actual = actual.toLocaleLowerCase();
|
|
2264
|
+
expected = expected.toLocaleLowerCase();
|
|
2265
|
+
}
|
|
2266
|
+
return substring ? actual.includes(expected) : actual === expected;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function seededRandom(seed: string | number): () => number {
|
|
2270
|
+
let state = typeof seed === "number" ? seed >>> 0 : hashSeed(seed);
|
|
2271
|
+
return () => {
|
|
2272
|
+
state = (1664525 * state + 1013904223) >>> 0;
|
|
2273
|
+
return state / 0x100000000;
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
function hashSeed(seed: string): number {
|
|
2278
|
+
let hash = 2166136261;
|
|
2279
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
2280
|
+
hash ^= seed.charCodeAt(index);
|
|
2281
|
+
hash = Math.imul(hash, 16777619);
|
|
2282
|
+
}
|
|
2283
|
+
return hash >>> 0;
|
|
2284
|
+
}
|