@conform-ed/qti-react 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -4
- package/dist/index.js +2492 -408
- package/dist/normalized-item.d.ts +7 -5
- package/dist/pnp.d.ts +115 -0
- package/dist/response-validity.d.ts +28 -0
- package/dist/rp/evaluate.d.ts +6 -1
- package/dist/rp/index.d.ts +2 -2
- package/dist/rp/interpreter.d.ts +7 -1
- package/dist/rp/lookup-table.d.ts +17 -0
- package/dist/rp/template-processing.d.ts +5 -0
- package/dist/rp/types.d.ts +71 -7
- package/dist/runtime.d.ts +95 -0
- package/dist/store.d.ts +47 -0
- package/dist/test/controller.d.ts +22 -0
- package/dist/test/index.d.ts +2 -1
- package/dist/test/results.d.ts +102 -0
- package/dist/test/session-store.d.ts +32 -0
- package/dist/test/types.d.ts +173 -5
- package/dist/types.d.ts +5 -0
- package/package.json +5 -1
- package/src/content-model.ts +44 -4
- package/src/index.ts +43 -1
- package/src/normalized-item.ts +106 -4
- package/src/pci/mount.ts +11 -3
- package/src/pnp.ts +333 -0
- package/src/reference-skin/choice.ts +3 -0
- package/src/response-validity.ts +163 -0
- package/src/rp/evaluate.ts +280 -32
- package/src/rp/index.ts +5 -0
- package/src/rp/interpreter.ts +81 -1
- package/src/rp/lookup-table.ts +46 -0
- package/src/rp/template-processing.ts +41 -0
- package/src/rp/types.ts +75 -7
- package/src/runtime.ts +397 -20
- package/src/store.ts +146 -8
- package/src/test/controller.ts +856 -82
- package/src/test/index.ts +23 -0
- package/src/test/results.ts +378 -0
- package/src/test/session-store.ts +109 -1
- package/src/test/types.ts +172 -5
- package/src/types.ts +1 -0
- package/src/xspattern.d.ts +11 -0
package/src/test/controller.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CapabilityIssue } from "../capability";
|
|
9
|
+
import { resolvePnpActivation, type PnpView } from "../pnp";
|
|
9
10
|
import {
|
|
10
11
|
RpUnsupportedError,
|
|
11
12
|
collectExpressionIssues,
|
|
@@ -13,8 +14,9 @@ import {
|
|
|
13
14
|
evaluateExpression,
|
|
14
15
|
type EvalEnv,
|
|
15
16
|
} from "../rp/evaluate";
|
|
17
|
+
import { hasLookupTable, lookupTableValue } from "../rp/lookup-table";
|
|
16
18
|
import { mulberry32 } from "../rp/template-processing";
|
|
17
|
-
import type { OutcomeValue, RpExpressionView } from "../rp/types";
|
|
19
|
+
import type { OutcomeDeclarationView, OutcomeValue, RpExpressionView } from "../rp/types";
|
|
18
20
|
import {
|
|
19
21
|
coerceScalar,
|
|
20
22
|
floatValue,
|
|
@@ -33,18 +35,31 @@ import type {
|
|
|
33
35
|
ItemSessionControlView,
|
|
34
36
|
OutcomeConditionBranch,
|
|
35
37
|
OutcomeRuleView,
|
|
38
|
+
RecordedAttempt,
|
|
36
39
|
TestController,
|
|
37
40
|
TestItemResult,
|
|
38
41
|
TestPlan,
|
|
39
42
|
TestPlanItem,
|
|
43
|
+
TestPlanSection,
|
|
40
44
|
TestSessionState,
|
|
45
|
+
TestTimingState,
|
|
46
|
+
TimeLimitsView,
|
|
47
|
+
TimingScopeRef,
|
|
41
48
|
} from "./types";
|
|
42
49
|
|
|
43
|
-
const supportedOutcomeRuleKinds = new Set([
|
|
50
|
+
const supportedOutcomeRuleKinds = new Set([
|
|
51
|
+
"outcomeCondition",
|
|
52
|
+
"setOutcomeValue",
|
|
53
|
+
"lookupOutcomeValue",
|
|
54
|
+
"outcomeProcessingFragment",
|
|
55
|
+
"exitTest",
|
|
56
|
+
]);
|
|
44
57
|
|
|
45
58
|
const testExpressionKinds = new Set([
|
|
46
59
|
...deterministicExpressionKinds,
|
|
47
60
|
"testVariables",
|
|
61
|
+
"outcomeMinimum",
|
|
62
|
+
"outcomeMaximum",
|
|
48
63
|
"numberCorrect",
|
|
49
64
|
"numberIncorrect",
|
|
50
65
|
"numberPresented",
|
|
@@ -114,19 +129,48 @@ function seededPick<T>(pool: readonly T[], count: number, random: () => number):
|
|
|
114
129
|
.map((index) => pool[index]!);
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
function applySelection(
|
|
132
|
+
function applySelection(
|
|
133
|
+
children: readonly SectionChild[],
|
|
134
|
+
selection: NonNullable<AssessmentSectionView["selection"]>,
|
|
135
|
+
random: () => number,
|
|
136
|
+
): SectionChild[] {
|
|
118
137
|
const required = children.filter((child) => child.required === true);
|
|
138
|
+
const needed = Math.max(0, selection.select - required.length);
|
|
139
|
+
|
|
140
|
+
if (selection.withReplacement === true) {
|
|
141
|
+
// "each element becomes eligible for selection multiple times. Selecting 3 nodes
|
|
142
|
+
// from {A,B,C,D} can then result in combinations such as {A,A,A}, {A,A,B} etc."
|
|
143
|
+
// (§5.129.2) — required children appear once, the remaining draws come from the
|
|
144
|
+
// whole pool. The multiset keeps document order (repeats adjacent); ordering
|
|
145
|
+
// shuffles separately.
|
|
146
|
+
const counts: number[] = children.map((child) => (child.required === true ? 1 : 0));
|
|
147
|
+
|
|
148
|
+
for (let draw = 0; draw < needed; draw += 1) {
|
|
149
|
+
const index = Math.floor(random() * children.length);
|
|
150
|
+
|
|
151
|
+
counts[index] = (counts[index] ?? 0) + 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return children.flatMap((child, index) => Array.from({ length: counts[index] ?? 0 }, () => child));
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
const optional = children.filter((child) => child.required !== true);
|
|
120
|
-
const needed = Math.max(0, select - required.length);
|
|
121
158
|
const picked = new Set<SectionChild>([...required, ...seededPick(optional, needed, random)]);
|
|
122
159
|
|
|
123
160
|
return children.filter((child) => picked.has(child));
|
|
124
161
|
}
|
|
125
162
|
|
|
126
|
-
|
|
163
|
+
/** One orderable unit of a section: a direct child, or a child hoisted out of an
|
|
164
|
+
* invisible keep-together=false descendant (`via` = the dissolved section chain). */
|
|
165
|
+
interface SectionUnit {
|
|
166
|
+
readonly child: SectionChild;
|
|
167
|
+
readonly via: readonly AssessmentSectionView[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyOrdering(units: readonly SectionUnit[], random: () => number): SectionUnit[] {
|
|
127
171
|
// Fixed children keep their positions; the rest shuffle into the remaining slots.
|
|
128
|
-
const result: (
|
|
129
|
-
const movable =
|
|
172
|
+
const result: (SectionUnit | null)[] = units.map((unit) => (unit.child.fixed === true ? unit : null));
|
|
173
|
+
const movable = units.filter((unit) => unit.child.fixed !== true);
|
|
130
174
|
const shuffled = seededPick(movable, movable.length, random);
|
|
131
175
|
// seededPick preserves document order after picking; re-shuffle for ordering.
|
|
132
176
|
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
@@ -140,6 +184,34 @@ function applyOrdering(children: readonly SectionChild[], random: () => number):
|
|
|
140
184
|
return result.map((slot) => slot ?? shuffled[cursor++]!);
|
|
141
185
|
}
|
|
142
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Under a shuffling parent, an invisible keep-together=false section's children are
|
|
189
|
+
* "mixed up with the other children of the parent section" (§4.2.7): the section
|
|
190
|
+
* dissolves into individual units (its own selection still applies first), each
|
|
191
|
+
* remembering the dissolved chain so identity, preconditions, and session control
|
|
192
|
+
* survive the hoist.
|
|
193
|
+
*/
|
|
194
|
+
function mixedUnits(
|
|
195
|
+
children: readonly SectionChild[],
|
|
196
|
+
random: () => number,
|
|
197
|
+
sections: Record<string, TestPlanSection>,
|
|
198
|
+
): SectionUnit[] {
|
|
199
|
+
return children.flatMap((child) => {
|
|
200
|
+
if (child.kind !== "assessmentSection" || child.visible !== false || child.keepTogether !== false) {
|
|
201
|
+
return [{ child, via: [] }];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
sections[child.identifier] = {
|
|
205
|
+
identifier: child.identifier,
|
|
206
|
+
...(child.timeLimits ? { timeLimits: child.timeLimits } : {}),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const inner = child.selection ? applySelection(child.children, child.selection, random) : child.children;
|
|
210
|
+
|
|
211
|
+
return mixedUnits(inner, random, sections).map((unit) => ({ child: unit.child, via: [child, ...unit.via] }));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
143
215
|
function resolveSection(
|
|
144
216
|
section: AssessmentSectionView,
|
|
145
217
|
partIdentifier: string,
|
|
@@ -147,34 +219,50 @@ function resolveSection(
|
|
|
147
219
|
inheritedPreConditions: readonly RpExpressionView[],
|
|
148
220
|
inheritedControl: ItemSessionControlView,
|
|
149
221
|
random: () => number,
|
|
222
|
+
sections: Record<string, TestPlanSection>,
|
|
150
223
|
): TestPlanItem[] {
|
|
151
224
|
const path = [...sectionPath, section.identifier];
|
|
225
|
+
|
|
226
|
+
sections[section.identifier] = {
|
|
227
|
+
identifier: section.identifier,
|
|
228
|
+
...(section.timeLimits ? { timeLimits: section.timeLimits } : {}),
|
|
229
|
+
};
|
|
152
230
|
const preConditions = [...inheritedPreConditions, ...(section.preConditions ?? [])];
|
|
153
231
|
const control = { ...inheritedControl, ...definedControl(section.itemSessionControl) };
|
|
154
232
|
|
|
155
233
|
let children: readonly SectionChild[] = section.children;
|
|
156
234
|
|
|
157
235
|
if (section.selection) {
|
|
158
|
-
children = applySelection(children, section.selection
|
|
236
|
+
children = applySelection(children, section.selection, random);
|
|
159
237
|
}
|
|
160
238
|
|
|
239
|
+
let units: SectionUnit[] = children.map((child) => ({ child, via: [] }));
|
|
240
|
+
|
|
161
241
|
if (section.ordering?.shuffle) {
|
|
162
|
-
|
|
242
|
+
// Mixing applies only "with a parent that is subject to shuffling" (§4.2.7).
|
|
243
|
+
units = applyOrdering(mixedUnits(children, random, sections), random);
|
|
163
244
|
}
|
|
164
245
|
|
|
165
246
|
const items: TestPlanItem[] = [];
|
|
166
247
|
|
|
167
|
-
for (const child of
|
|
248
|
+
for (const { child, via } of units) {
|
|
249
|
+
const viaPath = [...path, ...via.map((entry) => entry.identifier)];
|
|
250
|
+
const viaPreConditions = [...preConditions, ...via.flatMap((entry) => entry.preConditions ?? [])];
|
|
251
|
+
const viaControl = via.reduce(
|
|
252
|
+
(merged, entry) => ({ ...merged, ...definedControl(entry.itemSessionControl) }),
|
|
253
|
+
control,
|
|
254
|
+
);
|
|
255
|
+
|
|
168
256
|
if (child.kind === "assessmentSection") {
|
|
169
|
-
items.push(...resolveSection(child, partIdentifier,
|
|
257
|
+
items.push(...resolveSection(child, partIdentifier, viaPath, viaPreConditions, viaControl, random, sections));
|
|
170
258
|
} else {
|
|
171
259
|
items.push({
|
|
172
260
|
key: child.identifier,
|
|
173
261
|
ref: child,
|
|
174
262
|
partIdentifier,
|
|
175
|
-
sectionPath:
|
|
176
|
-
preConditions: [...
|
|
177
|
-
sessionControl: { ...specSessionControlDefaults, ...
|
|
263
|
+
sectionPath: viaPath,
|
|
264
|
+
preConditions: [...viaPreConditions, ...(child.preConditions ?? [])],
|
|
265
|
+
sessionControl: { ...specSessionControlDefaults, ...viaControl, ...definedControl(child.itemSessionControl) },
|
|
178
266
|
...(child.timeLimits ? { timeLimits: child.timeLimits } : {}),
|
|
179
267
|
});
|
|
180
268
|
}
|
|
@@ -185,18 +273,49 @@ function resolveSection(
|
|
|
185
273
|
|
|
186
274
|
function resolvePlan(view: AssessmentTestView, seed: number): TestPlan {
|
|
187
275
|
const random = mulberry32(seed);
|
|
276
|
+
const sections: Record<string, TestPlanSection> = {};
|
|
277
|
+
const parts = view.testParts.map((part) => ({
|
|
278
|
+
identifier: part.identifier,
|
|
279
|
+
navigationMode: part.navigationMode,
|
|
280
|
+
submissionMode: part.submissionMode,
|
|
281
|
+
...(part.timeLimits ? { timeLimits: part.timeLimits } : {}),
|
|
282
|
+
items: part.assessmentSections.flatMap((section) =>
|
|
283
|
+
resolveSection(section, part.identifier, [], [], definedControl(part.itemSessionControl), random, sections),
|
|
284
|
+
),
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
// Refs drawn more than once (selection with-replacement) get instance keys
|
|
288
|
+
// `identifier.n` — n is "the instance's place in the sequence of the item's
|
|
289
|
+
// instantiation" (§2.11.1.2), i.e. plan (delivery) order. Refs drawn once keep
|
|
290
|
+
// their bare identifier, so plans without replacement are unchanged.
|
|
291
|
+
const totals = new Map<string, number>();
|
|
292
|
+
|
|
293
|
+
for (const part of parts) {
|
|
294
|
+
for (const item of part.items) {
|
|
295
|
+
totals.set(item.ref.identifier, (totals.get(item.ref.identifier) ?? 0) + 1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const ordinals = new Map<string, number>();
|
|
300
|
+
const keyedParts = parts.map((part) => ({
|
|
301
|
+
...part,
|
|
302
|
+
items: part.items.map((item): TestPlanItem => {
|
|
303
|
+
if ((totals.get(item.ref.identifier) ?? 0) < 2) {
|
|
304
|
+
return item;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const instance = (ordinals.get(item.ref.identifier) ?? 0) + 1;
|
|
308
|
+
|
|
309
|
+
ordinals.set(item.ref.identifier, instance);
|
|
310
|
+
|
|
311
|
+
return { ...item, key: `${item.ref.identifier}.${instance}`, instance };
|
|
312
|
+
}),
|
|
313
|
+
}));
|
|
188
314
|
|
|
189
315
|
return {
|
|
190
316
|
...(view.timeLimits ? { timeLimits: view.timeLimits } : {}),
|
|
191
|
-
parts:
|
|
192
|
-
|
|
193
|
-
navigationMode: part.navigationMode,
|
|
194
|
-
submissionMode: part.submissionMode,
|
|
195
|
-
...(part.timeLimits ? { timeLimits: part.timeLimits } : {}),
|
|
196
|
-
items: part.assessmentSections.flatMap((section) =>
|
|
197
|
-
resolveSection(section, part.identifier, [], [], definedControl(part.itemSessionControl), random),
|
|
198
|
-
),
|
|
199
|
-
})),
|
|
317
|
+
parts: keyedParts,
|
|
318
|
+
sections,
|
|
200
319
|
};
|
|
201
320
|
}
|
|
202
321
|
|
|
@@ -204,6 +323,26 @@ function resolvePlan(view: AssessmentTestView, seed: number): TestPlan {
|
|
|
204
323
|
|
|
205
324
|
export interface TestControllerOptions {
|
|
206
325
|
readonly seed: number;
|
|
326
|
+
/**
|
|
327
|
+
* Each item's outcome declarations, keyed by item-ref identifier (shared by every
|
|
328
|
+
* selected instance of the ref). Feeds `outcomeMaximum`/`outcomeMinimum` with the declared
|
|
329
|
+
* `normal-maximum`/`normal-minimum`; items absent here degrade per spec — maximum
|
|
330
|
+
* → NULL (§2.11.2.7), minimum → ignored (§2.11.2.6) — never a refusal. Consumers
|
|
331
|
+
* can pass `assessmentItemViewFromNormalized(...).outcomeDeclarations` verbatim.
|
|
332
|
+
*/
|
|
333
|
+
readonly itemOutcomeDeclarations?: Readonly<Record<string, readonly OutcomeDeclarationView[]>> | undefined;
|
|
334
|
+
/**
|
|
335
|
+
* Millisecond clock backing the built-in test/part/section `duration` variables and
|
|
336
|
+
* timeLimits enforcement. Injectable for deterministic tests and replays; defaults
|
|
337
|
+
* to Date.now.
|
|
338
|
+
*/
|
|
339
|
+
readonly now?: (() => number) | undefined;
|
|
340
|
+
/**
|
|
341
|
+
* The candidate's AfA PNP. The controller consumes additional-testing-time:
|
|
342
|
+
* "the durations may be changed depending on the relevant accessibility values
|
|
343
|
+
* in the Personal Needs & Preferences settings for the learner" (§2.8.5).
|
|
344
|
+
*/
|
|
345
|
+
readonly pnp?: PnpView | undefined;
|
|
207
346
|
}
|
|
208
347
|
|
|
209
348
|
export function createTestController(view: AssessmentTestView, options: TestControllerOptions): TestController {
|
|
@@ -211,11 +350,21 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
211
350
|
const allItems: TestPlanItem[] = plan.parts.flatMap((part) => [...part.items]);
|
|
212
351
|
const partIndexByItemKey = new Map<string, number>();
|
|
213
352
|
const itemsByKey = new Map<string, TestPlanItem>();
|
|
353
|
+
/** Plan items per ref identifier, in plan order — >1 entry under with-replacement. */
|
|
354
|
+
const instancesByRef = new Map<string, TestPlanItem[]>();
|
|
214
355
|
|
|
215
356
|
plan.parts.forEach((part, partIndex) => {
|
|
216
357
|
for (const item of part.items) {
|
|
217
358
|
partIndexByItemKey.set(item.key, partIndex);
|
|
218
359
|
itemsByKey.set(item.key, item);
|
|
360
|
+
|
|
361
|
+
const siblings = instancesByRef.get(item.ref.identifier);
|
|
362
|
+
|
|
363
|
+
if (siblings) {
|
|
364
|
+
siblings.push(item);
|
|
365
|
+
} else {
|
|
366
|
+
instancesByRef.set(item.ref.identifier, [item]);
|
|
367
|
+
}
|
|
219
368
|
}
|
|
220
369
|
});
|
|
221
370
|
|
|
@@ -245,6 +394,15 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
245
394
|
});
|
|
246
395
|
}
|
|
247
396
|
|
|
397
|
+
/** The item's named weight; "If no matching definition is found the weight is assumed to be 1.0." */
|
|
398
|
+
function weightOf(item: TestPlanItem, weightIdentifier: string | undefined): number {
|
|
399
|
+
if (weightIdentifier === undefined) {
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return item.ref.weights?.find((entry) => entry.identifier === weightIdentifier)?.value ?? 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
248
406
|
function remainingAttempts(state: TestSessionState, itemKey: string): number {
|
|
249
407
|
const item = itemsByKey.get(itemKey);
|
|
250
408
|
|
|
@@ -257,6 +415,162 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
257
415
|
return max === 0 ? Number.POSITIVE_INFINITY : Math.max(0, max - attemptsOf(state, itemKey));
|
|
258
416
|
}
|
|
259
417
|
|
|
418
|
+
// ---------- Timing (ADR-0005, "Timing and time limits") ----------
|
|
419
|
+
|
|
420
|
+
const now = options.now ?? Date.now;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Fold wall-clock time since the last transition into every active scope: the test,
|
|
424
|
+
* and — while an item is current — its part, every ancestor section, and the item
|
|
425
|
+
* itself. Durations include "any other time spent navigating that part of the test"
|
|
426
|
+
* (§2.8.5), so they accrue whenever the session is open, not just during attempts.
|
|
427
|
+
*/
|
|
428
|
+
function touch(state: TestSessionState): TestSessionState {
|
|
429
|
+
if (state.status !== "in-progress") {
|
|
430
|
+
return state; // the clock stops at end and while suspended
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const nowMs = now();
|
|
434
|
+
const timing: TestTimingState = state.timing ?? {
|
|
435
|
+
lastTransitionAtMs: nowMs, // pre-timing persisted states start accruing here
|
|
436
|
+
testSeconds: 0,
|
|
437
|
+
partSeconds: {},
|
|
438
|
+
sectionSeconds: {},
|
|
439
|
+
itemSeconds: {},
|
|
440
|
+
};
|
|
441
|
+
const elapsed = Math.max(0, nowMs - timing.lastTransitionAtMs) / 1000; // clamp clock skew
|
|
442
|
+
const bump = (record: Readonly<Record<string, number>>, key: string): Readonly<Record<string, number>> => ({
|
|
443
|
+
...record,
|
|
444
|
+
[key]: (record[key] ?? 0) + elapsed,
|
|
445
|
+
});
|
|
446
|
+
const item = state.currentItemKey === null ? undefined : itemsByKey.get(state.currentItemKey);
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
...state,
|
|
450
|
+
timing: {
|
|
451
|
+
lastTransitionAtMs: nowMs,
|
|
452
|
+
testSeconds: timing.testSeconds + elapsed,
|
|
453
|
+
partSeconds: item ? bump(timing.partSeconds, item.partIdentifier) : timing.partSeconds,
|
|
454
|
+
sectionSeconds: item
|
|
455
|
+
? item.sectionPath.reduce((record, identifier) => bump(record, identifier), timing.sectionSeconds)
|
|
456
|
+
: timing.sectionSeconds,
|
|
457
|
+
itemSeconds: item ? bump(timing.itemSeconds, item.key) : timing.itemSeconds,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function secondsOf(state: TestSessionState, scope: TimingScopeRef): number {
|
|
463
|
+
const timing = state.timing;
|
|
464
|
+
|
|
465
|
+
if (!timing) {
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
switch (scope.kind) {
|
|
470
|
+
case "test":
|
|
471
|
+
return timing.testSeconds;
|
|
472
|
+
case "part":
|
|
473
|
+
return timing.partSeconds[scope.identifier] ?? 0;
|
|
474
|
+
case "section":
|
|
475
|
+
return timing.sectionSeconds[scope.identifier] ?? 0;
|
|
476
|
+
case "item":
|
|
477
|
+
return timing.itemSeconds[scope.key] ?? 0;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function timeLimitsOf(scope: TimingScopeRef): TimeLimitsView | undefined {
|
|
482
|
+
switch (scope.kind) {
|
|
483
|
+
case "test":
|
|
484
|
+
return plan.timeLimits;
|
|
485
|
+
case "part":
|
|
486
|
+
return plan.parts.find((part) => part.identifier === scope.identifier)?.timeLimits;
|
|
487
|
+
case "section":
|
|
488
|
+
return plan.sections[scope.identifier]?.timeLimits;
|
|
489
|
+
case "item":
|
|
490
|
+
return itemsByKey.get(scope.key)?.timeLimits;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The accommodation applies only while "additional-testing-time" is active for
|
|
495
|
+
// the candidate (a prohibit-set entry switches it off).
|
|
496
|
+
const additionalTestingTime = resolvePnpActivation(options.pnp).active.has("additional-testing-time")
|
|
497
|
+
? options.pnp?.additionalTestingTime
|
|
498
|
+
: undefined;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The candidate's effective ceiling for a scope: a time-multiplier scales every
|
|
502
|
+
* declared max-time proportionally (a rate accommodation); fixed-minutes extend
|
|
503
|
+
* the assessment window (the test scope) by that absolute amount; unlimited
|
|
504
|
+
* removes ceilings. Minimum times are floors and are never adjusted.
|
|
505
|
+
*/
|
|
506
|
+
function effectiveMaxTime(scope: TimingScopeRef): number | undefined {
|
|
507
|
+
if (additionalTestingTime?.unlimited === true) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const declared = timeLimitsOf(scope)?.maxTime;
|
|
512
|
+
if (declared === undefined) {
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (additionalTestingTime?.timeMultiplier !== undefined) {
|
|
517
|
+
return declared * additionalTestingTime.timeMultiplier;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (additionalTestingTime?.fixedMinutes !== undefined && scope.kind === "test") {
|
|
521
|
+
return declared + additionalTestingTime.fixedMinutes * 60;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return declared;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** "Beyond the max-time" (§7.40.3) is strictly beyond: exactly maxTime is in time. */
|
|
528
|
+
function scopeExpired(state: TestSessionState, scope: TimingScopeRef): boolean {
|
|
529
|
+
const maxTime = effectiveMaxTime(scope);
|
|
530
|
+
|
|
531
|
+
return maxTime !== undefined && secondsOf(state, scope) > maxTime;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The item's enclosing timing scopes, innermost first (item → sections → part → test). */
|
|
535
|
+
function enclosingScopes(item: TestPlanItem): TimingScopeRef[] {
|
|
536
|
+
return [
|
|
537
|
+
{ kind: "item", key: item.key },
|
|
538
|
+
...[...item.sectionPath].reverse().map((identifier): TimingScopeRef => ({ kind: "section", identifier })),
|
|
539
|
+
{ kind: "part", identifier: item.partIdentifier },
|
|
540
|
+
{ kind: "test" },
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Navigable in time: neither the item's own maxTime nor any enclosing scope's is spent. */
|
|
545
|
+
function navigableInTime(state: TestSessionState, item: TestPlanItem): boolean {
|
|
546
|
+
return !enclosingScopes(item).some((scope) => scopeExpired(state, scope));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* minTime applies "to qti-assessment-sections and qti-assessment-items only when
|
|
551
|
+
* linear navigation mode is in effect" (§7.40.1); satisfied at exact equality.
|
|
552
|
+
* Sections gate only when the move would leave them.
|
|
553
|
+
*/
|
|
554
|
+
function minTimeBlocked(state: TestSessionState, from: TestPlanItem, to: TestPlanItem | null): boolean {
|
|
555
|
+
const itemMin = from.timeLimits?.minTime;
|
|
556
|
+
|
|
557
|
+
if (itemMin !== undefined && secondsOf(state, { kind: "item", key: from.key }) < itemMin) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const destinationSections = new Set(to?.sectionPath ?? []);
|
|
562
|
+
|
|
563
|
+
return from.sectionPath.some((identifier) => {
|
|
564
|
+
if (destinationSections.has(identifier)) {
|
|
565
|
+
return false; // staying inside the section
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const minTime = plan.sections[identifier]?.timeLimits?.minTime;
|
|
569
|
+
|
|
570
|
+
return minTime !== undefined && secondsOf(state, { kind: "section", identifier }) < minTime;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
260
574
|
function defaultTestOutcomes(): Map<string, MaybeRpValue> {
|
|
261
575
|
const outcomes = new Map<string, MaybeRpValue>();
|
|
262
576
|
|
|
@@ -279,14 +593,88 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
279
593
|
return outcomes;
|
|
280
594
|
}
|
|
281
595
|
|
|
596
|
+
const durationValue = (seconds: number): MaybeRpValue => rpValue("single", [seconds], "duration");
|
|
597
|
+
|
|
282
598
|
function makeEnv(state: TestSessionState, outcomes?: Map<string, MaybeRpValue>): EvalEnv {
|
|
283
599
|
return {
|
|
284
600
|
lookupVariable: (identifier) => {
|
|
601
|
+
// Built-in session durations (§2.8.5) resolve before any declared variable —
|
|
602
|
+
// the name is reserved, so author declarations never shadow it.
|
|
603
|
+
if (identifier === "duration") {
|
|
604
|
+
return state.timing === undefined ? null : durationValue(state.timing.testSeconds);
|
|
605
|
+
}
|
|
606
|
+
|
|
285
607
|
const dot = identifier.indexOf(".");
|
|
286
608
|
|
|
287
609
|
if (dot !== -1) {
|
|
288
|
-
|
|
289
|
-
|
|
610
|
+
let itemKey = identifier.slice(0, dot);
|
|
611
|
+
let variableName = identifier.slice(dot + 1);
|
|
612
|
+
|
|
613
|
+
// Instance addressing (§2.11.1.2): in `Q01.2.SCORE` "a number that denotes
|
|
614
|
+
// the instance's place in the sequence of the item's instantiation is
|
|
615
|
+
// inserted between the item variable identifier and the item variable" —
|
|
616
|
+
// the session key is `Q01.2`, so try the two-segment key first.
|
|
617
|
+
const secondDot = identifier.indexOf(".", dot + 1);
|
|
618
|
+
|
|
619
|
+
if (secondDot !== -1 && itemsByKey.has(identifier.slice(0, secondDot))) {
|
|
620
|
+
itemKey = identifier.slice(0, secondDot);
|
|
621
|
+
variableName = identifier.slice(secondDot + 1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const instances = instancesByRef.get(itemKey);
|
|
625
|
+
|
|
626
|
+
if (instances !== undefined && instances.length > 1) {
|
|
627
|
+
// A bare ref over multiple instances "is taken from the last instance
|
|
628
|
+
// submitted if submission is simultaneous, otherwise it is undefined"
|
|
629
|
+
// (§2.11.1.2); undefined maps to NULL like every undefined value here.
|
|
630
|
+
// Simultaneous parts flush in plan order, so the last submitted instance
|
|
631
|
+
// is the last one in plan order holding a committed result.
|
|
632
|
+
const partIndex = partIndexByItemKey.get(instances[0]!.key);
|
|
633
|
+
|
|
634
|
+
if (partIndex === undefined || plan.parts[partIndex]!.submissionMode !== "simultaneous") {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
for (let index = instances.length - 1; index >= 0; index -= 1) {
|
|
639
|
+
const instanceKey = instances[index]!.key;
|
|
640
|
+
|
|
641
|
+
if (variableName === "duration") {
|
|
642
|
+
const seconds = state.itemDurationSeconds?.[instanceKey];
|
|
643
|
+
|
|
644
|
+
if (seconds !== undefined) {
|
|
645
|
+
return durationValue(seconds);
|
|
646
|
+
}
|
|
647
|
+
} else if (state.itemOutcomes[instanceKey] !== undefined) {
|
|
648
|
+
return liftFlat(state.itemOutcomes[instanceKey]?.[variableName] ?? null);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (variableName === "duration") {
|
|
656
|
+
if (itemsByKey.has(itemKey)) {
|
|
657
|
+
// The item-session duration is the consumer's report (the attempt store
|
|
658
|
+
// owns it; the controller's per-item clock is enforcement-only).
|
|
659
|
+
const seconds = state.itemDurationSeconds?.[itemKey];
|
|
660
|
+
|
|
661
|
+
return seconds === undefined ? null : durationValue(seconds);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const partSeconds = state.timing?.partSeconds[itemKey];
|
|
665
|
+
|
|
666
|
+
if (plan.parts.some((part) => part.identifier === itemKey)) {
|
|
667
|
+
return partSeconds === undefined ? null : durationValue(partSeconds);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (plan.sections[itemKey]) {
|
|
671
|
+
const seconds = state.timing?.sectionSeconds[itemKey];
|
|
672
|
+
|
|
673
|
+
return seconds === undefined ? null : durationValue(seconds);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
290
678
|
|
|
291
679
|
return liftFlat(state.itemOutcomes[itemKey]?.[variableName] ?? null);
|
|
292
680
|
}
|
|
@@ -323,10 +711,8 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
323
711
|
// Weighted numeric values multiply by the item's named weight (missing
|
|
324
712
|
// names weigh 1) and the container becomes float (spec).
|
|
325
713
|
if (weightIdentifier !== undefined && isNumericBaseType(lifted.baseType)) {
|
|
326
|
-
const weight = item.ref.weights?.find((entry) => entry.identifier === weightIdentifier)?.value ?? 1;
|
|
327
|
-
|
|
328
714
|
baseType = "float";
|
|
329
|
-
members.push(...lifted.values.map((entry) => Number(entry) *
|
|
715
|
+
members.push(...lifted.values.map((entry) => Number(entry) * weightOf(item, weightIdentifier)));
|
|
330
716
|
continue;
|
|
331
717
|
}
|
|
332
718
|
|
|
@@ -360,6 +746,34 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
360
746
|
return integer(countIn(state.correctItems));
|
|
361
747
|
case "numberIncorrect":
|
|
362
748
|
return integer(countIn(state.incorrectItems));
|
|
749
|
+
case "outcomeMinimum":
|
|
750
|
+
case "outcomeMaximum": {
|
|
751
|
+
const bound = expression.kind === "outcomeMaximum" ? "normalMaximum" : "normalMinimum";
|
|
752
|
+
const members: number[] = [];
|
|
753
|
+
|
|
754
|
+
for (const item of subset) {
|
|
755
|
+
// Declarations are per item document, shared by every instance of a ref.
|
|
756
|
+
const declared = options.itemOutcomeDeclarations?.[item.ref.identifier]?.find(
|
|
757
|
+
(entry) => entry.identifier === expression.outcomeIdentifier,
|
|
758
|
+
)?.[bound];
|
|
759
|
+
|
|
760
|
+
if (declared === undefined) {
|
|
761
|
+
// "If any of the items within the given subset have no declared
|
|
762
|
+
// maximum the result is NULL" (§2.11.2.7); for the minimum, "Items
|
|
763
|
+
// with no declared minimum are ignored." (§2.11.2.6)
|
|
764
|
+
if (expression.kind === "outcomeMaximum") {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Weighting "As per the 'weight-identifier' characteristic of
|
|
771
|
+
// 'qti-test-variables'" (§7.28.5); result base-type float.
|
|
772
|
+
members.push(declared * weightOf(item, expression.weightIdentifier));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return members.length === 0 ? null : rpValue("multiple", members, "float");
|
|
776
|
+
}
|
|
363
777
|
default:
|
|
364
778
|
throw new RpUnsupportedError(expression.kind);
|
|
365
779
|
}
|
|
@@ -414,6 +828,28 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
414
828
|
continue;
|
|
415
829
|
}
|
|
416
830
|
|
|
831
|
+
// "Outcome rules are followed in the order given. Variables updated by a rule
|
|
832
|
+
// take their new value when evaluated as part of any following rules." (§5.103.1)
|
|
833
|
+
if (rule.kind === "outcomeProcessingFragment") {
|
|
834
|
+
executeRules(rule.rules ?? []);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (rule.kind === "lookupOutcomeValue") {
|
|
839
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
840
|
+
const declaration = (view.outcomeDeclarations ?? []).find((entry) => entry.identifier === rule.identifier);
|
|
841
|
+
|
|
842
|
+
if (!hasLookupTable(declaration)) {
|
|
843
|
+
// §5.87 presumes "the lookupTable associated with the outcome's
|
|
844
|
+
// declaration" — no table, no spec-defined value: refuse, never guess.
|
|
845
|
+
throw new RpUnsupportedError("lookupOutcomeValue");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
outcomes.set(rule.identifier, lookupTableValue(declaration, evaluateExpression(rule.expression, env)));
|
|
849
|
+
}
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
417
853
|
// outcomeCondition
|
|
418
854
|
if (rule.outcomeIf && branchTaken(rule.outcomeIf)) {
|
|
419
855
|
continue;
|
|
@@ -440,7 +876,7 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
440
876
|
return Object.fromEntries([...outcomes].map(([identifier, value]) => [identifier, toOutcomeValue(value)]));
|
|
441
877
|
}
|
|
442
878
|
|
|
443
|
-
/** The first item at or after (partIndex, itemIndex)
|
|
879
|
+
/** The first item at or after (partIndex, itemIndex) that is reachable: preconditions pass and no enclosing time limit is spent. */
|
|
444
880
|
function firstNavigable(state: TestSessionState, partIndex: number, itemIndex: number): TestPlanItem | null {
|
|
445
881
|
for (let p = partIndex; p < plan.parts.length; p += 1) {
|
|
446
882
|
const items = plan.parts[p]!.items;
|
|
@@ -448,7 +884,7 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
448
884
|
for (let i = p === partIndex ? itemIndex : 0; i < items.length; i += 1) {
|
|
449
885
|
const item = items[i]!;
|
|
450
886
|
|
|
451
|
-
if (preConditionsPass(item, state)) {
|
|
887
|
+
if (preConditionsPass(item, state) && navigableInTime(state, item)) {
|
|
452
888
|
return item;
|
|
453
889
|
}
|
|
454
890
|
}
|
|
@@ -496,6 +932,33 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
496
932
|
}
|
|
497
933
|
|
|
498
934
|
/** Commit pending simultaneous results for one part (or all parts when null). */
|
|
935
|
+
/**
|
|
936
|
+
* Record a committed attempt for results reporting: "A report may contain multiple
|
|
937
|
+
* results for the same instance of an item representing multiple attempts … each
|
|
938
|
+
* item result must have a different datestamp."
|
|
939
|
+
*/
|
|
940
|
+
function withRecordedAttempt(
|
|
941
|
+
state: TestSessionState,
|
|
942
|
+
itemKey: string,
|
|
943
|
+
result: TestItemResult,
|
|
944
|
+
atMs: number,
|
|
945
|
+
): TestSessionState {
|
|
946
|
+
const entry: RecordedAttempt = {
|
|
947
|
+
atMs,
|
|
948
|
+
outcomes: result.outcomes,
|
|
949
|
+
...(result.responses !== undefined ? { responses: result.responses } : {}),
|
|
950
|
+
...(result.durationSeconds !== undefined ? { durationSeconds: result.durationSeconds } : {}),
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
...state,
|
|
955
|
+
attemptHistory: {
|
|
956
|
+
...(state.attemptHistory ?? {}),
|
|
957
|
+
[itemKey]: [...(state.attemptHistory?.[itemKey] ?? []), entry],
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
499
962
|
function flushPending(state: TestSessionState, partIndex: number | null): TestSessionState {
|
|
500
963
|
const pending = state.pendingItemResults ?? {};
|
|
501
964
|
const keys = Object.keys(pending).filter((key) => partIndex === null || partIndexByItemKey.get(key) === partIndex);
|
|
@@ -506,6 +969,7 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
506
969
|
|
|
507
970
|
const itemOutcomes = { ...state.itemOutcomes };
|
|
508
971
|
const attemptCounts = { ...(state.attemptCounts ?? {}) };
|
|
972
|
+
const itemDurations = { ...(state.itemDurationSeconds ?? {}) };
|
|
509
973
|
const remaining = { ...pending };
|
|
510
974
|
let flagged = state;
|
|
511
975
|
|
|
@@ -515,10 +979,23 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
515
979
|
itemOutcomes[key] = result.outcomes;
|
|
516
980
|
attemptCounts[key] = (attemptCounts[key] ?? 0) + 1; // the part's single attempt
|
|
517
981
|
delete remaining[key];
|
|
518
|
-
|
|
982
|
+
|
|
983
|
+
if (result.durationSeconds !== undefined) {
|
|
984
|
+
itemDurations[key] = result.durationSeconds;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// The flush is the part submission, but the datestamp is the candidate's
|
|
988
|
+
// submit instant, stamped when the pending result was recorded.
|
|
989
|
+
flagged = withRecordedAttempt(applyResultFlags(flagged, key, result), key, result, result.submittedAtMs ?? now());
|
|
519
990
|
}
|
|
520
991
|
|
|
521
|
-
return {
|
|
992
|
+
return {
|
|
993
|
+
...flagged,
|
|
994
|
+
itemOutcomes,
|
|
995
|
+
attemptCounts,
|
|
996
|
+
itemDurationSeconds: itemDurations,
|
|
997
|
+
pendingItemResults: remaining,
|
|
998
|
+
};
|
|
522
999
|
}
|
|
523
1000
|
|
|
524
1001
|
function ended(state: TestSessionState): TestSessionState {
|
|
@@ -527,6 +1004,60 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
527
1004
|
return { ...flushed, status: "ended", currentItemKey: null, testOutcomes: runOutcomeProcessing(flushed) };
|
|
528
1005
|
}
|
|
529
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* "The value is obtained by evaluating an expression defined within the reference
|
|
1009
|
+
* to the item at test level and which may therefore depend on the values of
|
|
1010
|
+
* variables taken from other items in the test or from outcomes defined at test
|
|
1011
|
+
* level itself." (§5.152) Unsupported expressions are statically reported; the
|
|
1012
|
+
* declared default stands.
|
|
1013
|
+
*/
|
|
1014
|
+
function evaluateTemplateDefaults(
|
|
1015
|
+
state: TestSessionState,
|
|
1016
|
+
item: TestPlanItem,
|
|
1017
|
+
): Readonly<Record<string, OutcomeValue>> | undefined {
|
|
1018
|
+
const defaults = item.ref.templateDefaults;
|
|
1019
|
+
|
|
1020
|
+
if (!defaults || defaults.length === 0) {
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const env = makeEnv(state);
|
|
1025
|
+
const values: Record<string, OutcomeValue> = {};
|
|
1026
|
+
|
|
1027
|
+
for (const entry of defaults) {
|
|
1028
|
+
try {
|
|
1029
|
+
values[entry.templateIdentifier] = toOutcomeValue(evaluateExpression(entry.expression, env));
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (!(error instanceof RpUnsupportedError)) {
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return values;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Record templateDefault values for any of `items` not yet evaluated ("the first attempt"). */
|
|
1041
|
+
function withTemplateDefaults(state: TestSessionState, items: readonly TestPlanItem[]): TestSessionState {
|
|
1042
|
+
let merged = state.templateDefaultValues ?? {};
|
|
1043
|
+
let changed = false;
|
|
1044
|
+
|
|
1045
|
+
for (const item of items) {
|
|
1046
|
+
if (merged[item.key] !== undefined) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const values = evaluateTemplateDefaults(state, item);
|
|
1051
|
+
|
|
1052
|
+
if (values) {
|
|
1053
|
+
merged = { ...merged, [item.key]: values };
|
|
1054
|
+
changed = true;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return changed ? { ...state, templateDefaultValues: merged } : state;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
530
1061
|
function moveToItem(state: TestSessionState, item: TestPlanItem | null): TestSessionState {
|
|
531
1062
|
if (item === null) {
|
|
532
1063
|
return ended(state);
|
|
@@ -545,11 +1076,21 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
545
1076
|
}
|
|
546
1077
|
}
|
|
547
1078
|
|
|
1079
|
+
// templateDefault timing (§5.152): linear — "immediately prior to the start of
|
|
1080
|
+
// the first attempt, after any pre-conditions are evaluated" (the item has just
|
|
1081
|
+
// been chosen as current); nonlinear — "at the start of the testPart" (first
|
|
1082
|
+
// entry computes every ref's defaults). Already-recorded keys are never redone.
|
|
1083
|
+
const part = toPart === undefined ? undefined : plan.parts[toPart];
|
|
1084
|
+
|
|
1085
|
+
if (part) {
|
|
1086
|
+
next = withTemplateDefaults(next, part.navigationMode === "nonlinear" ? part.items : [item]);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
548
1089
|
return markPresented({ ...next, currentItemKey: item.key }, item.key);
|
|
549
1090
|
}
|
|
550
1091
|
|
|
551
1092
|
function nextState(state: TestSessionState): TestSessionState {
|
|
552
|
-
if (state.status
|
|
1093
|
+
if (state.status !== "in-progress" || state.currentItemKey === null) {
|
|
553
1094
|
return state;
|
|
554
1095
|
}
|
|
555
1096
|
|
|
@@ -573,6 +1114,15 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
573
1114
|
return state;
|
|
574
1115
|
}
|
|
575
1116
|
|
|
1117
|
+
// minTime (linear mode, sections and items only, §7.40.1) gates before branch
|
|
1118
|
+
// rules — author-explicit jumps do not bypass the minimum either.
|
|
1119
|
+
if (
|
|
1120
|
+
part.navigationMode === "linear" &&
|
|
1121
|
+
minTimeBlocked(state, currentItem, firstNavigable(state, current.partIndex, current.itemIndex + 1))
|
|
1122
|
+
) {
|
|
1123
|
+
return state;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
576
1126
|
// Branch rules: first matching rule wins (author-explicit jumps bypass skip checks).
|
|
577
1127
|
for (const branchRule of currentItem.ref.branchRules ?? []) {
|
|
578
1128
|
if (!conditionPasses(branchRule.expression, state)) {
|
|
@@ -599,7 +1149,17 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
599
1149
|
return moveToItem(state, firstNavigable(state, current.partIndex, index));
|
|
600
1150
|
}
|
|
601
1151
|
|
|
602
|
-
|
|
1152
|
+
// A target naming a multi-instance ref jumps to its next instance after the
|
|
1153
|
+
// current item — the §2.8.3 repetition idiom; branch paths only move forward.
|
|
1154
|
+
const target =
|
|
1155
|
+
positionOf(branchRule.target) ??
|
|
1156
|
+
(instancesByRef.get(branchRule.target) ?? [])
|
|
1157
|
+
.map((instance) => positionOf(instance.key))
|
|
1158
|
+
.find(
|
|
1159
|
+
(position) =>
|
|
1160
|
+
position !== null && position.partIndex === current.partIndex && position.itemIndex > current.itemIndex,
|
|
1161
|
+
) ??
|
|
1162
|
+
null;
|
|
603
1163
|
|
|
604
1164
|
if (target && target.partIndex === current.partIndex) {
|
|
605
1165
|
return moveToItem(state, firstNavigable(state, target.partIndex, target.itemIndex));
|
|
@@ -618,7 +1178,8 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
618
1178
|
(item) =>
|
|
619
1179
|
!item.sessionControl.allowSkipping &&
|
|
620
1180
|
!state.attemptedItems.includes(item.key) &&
|
|
621
|
-
preConditionsPass(item, state)
|
|
1181
|
+
preConditionsPass(item, state) &&
|
|
1182
|
+
navigableInTime(state, item), // an item whose time is spent can never be attempted
|
|
622
1183
|
);
|
|
623
1184
|
|
|
624
1185
|
if (blocked) {
|
|
@@ -629,6 +1190,138 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
629
1190
|
return moveToItem(state, destination);
|
|
630
1191
|
}
|
|
631
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* "If set to 'false' the candidate can not review the qti-item-body or their
|
|
1195
|
+
* responses once they have submitted their last attempt" (allowReview, default
|
|
1196
|
+
* true). Before the last attempt ends, revisiting is interaction, not review.
|
|
1197
|
+
* The end of the last attempt is judged on the attempt count; adaptive items
|
|
1198
|
+
* (which bypass maxAttempts via `result.adaptive`) are managed by their consumer.
|
|
1199
|
+
*/
|
|
1200
|
+
function reviewBarred(state: TestSessionState, item: TestPlanItem): boolean {
|
|
1201
|
+
return item.sessionControl.allowReview === false && remainingAttempts(state, item.key) <= 0;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** Post-end review (allowReview): ended session, presented item, review allowed. */
|
|
1205
|
+
function reviewable(state: TestSessionState, itemKey: string): boolean {
|
|
1206
|
+
const item = itemsByKey.get(itemKey);
|
|
1207
|
+
|
|
1208
|
+
return (
|
|
1209
|
+
state.status === "ended" &&
|
|
1210
|
+
item !== undefined &&
|
|
1211
|
+
item.sessionControl.allowReview &&
|
|
1212
|
+
(state.presentedItems ?? []).includes(itemKey)
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** "allowed to provide a comment on the item during the session" (default false). */
|
|
1217
|
+
function commentable(state: TestSessionState, itemKey: string): boolean {
|
|
1218
|
+
return state.status === "in-progress" && itemsByKey.get(itemKey)?.sessionControl.allowComment === true;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** The submit mechanics shared by the timed path: pending (simultaneous) or committed. */
|
|
1222
|
+
function submitBody(state: TestSessionState, itemKey: string, result: TestItemResult): TestSessionState {
|
|
1223
|
+
const partIndex = partIndexByItemKey.get(itemKey);
|
|
1224
|
+
|
|
1225
|
+
// Simultaneous parts hold results pending and allow revision until the part is
|
|
1226
|
+
// left; the single attempt (spec) is only spent when the pending set flushes.
|
|
1227
|
+
if (partIndex !== undefined && plan.parts[partIndex]!.submissionMode === "simultaneous") {
|
|
1228
|
+
if (attemptsOf(state, itemKey) > 0) {
|
|
1229
|
+
return state; // the part was already submitted
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return {
|
|
1233
|
+
...state,
|
|
1234
|
+
// Stamped now so the flush can keep the candidate's submit-time datestamp.
|
|
1235
|
+
pendingItemResults: { ...(state.pendingItemResults ?? {}), [itemKey]: { ...result, submittedAtMs: now() } },
|
|
1236
|
+
attemptedItems: state.attemptedItems.includes(itemKey)
|
|
1237
|
+
? state.attemptedItems
|
|
1238
|
+
: [...state.attemptedItems, itemKey],
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// "When validate-responses is turned on (true) then the candidates are not
|
|
1243
|
+
// allowed to submit the item until they have provided valid responses for all
|
|
1244
|
+
// interactions" — applicable "only … with individual submission mode" (the
|
|
1245
|
+
// simultaneous branch above is exempt by spec).
|
|
1246
|
+
if (result.valid === false && itemsByKey.get(itemKey)?.sessionControl.validateResponses === true) {
|
|
1247
|
+
return state;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Adaptive items run their own attempt lifecycle, so maxAttempts is ignored (spec).
|
|
1251
|
+
if (result.adaptive !== true && remainingAttempts(state, itemKey) <= 0) {
|
|
1252
|
+
return state;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const next: TestSessionState = {
|
|
1256
|
+
...withRecordedAttempt(applyResultFlags(state, itemKey, result), itemKey, result, now()),
|
|
1257
|
+
itemOutcomes: { ...state.itemOutcomes, [itemKey]: result.outcomes },
|
|
1258
|
+
attemptedItems: state.attemptedItems.includes(itemKey)
|
|
1259
|
+
? state.attemptedItems
|
|
1260
|
+
: [...state.attemptedItems, itemKey],
|
|
1261
|
+
attemptCounts: { ...(state.attemptCounts ?? {}), [itemKey]: attemptsOf(state, itemKey) + 1 },
|
|
1262
|
+
...(result.durationSeconds !== undefined
|
|
1263
|
+
? { itemDurationSeconds: { ...(state.itemDurationSeconds ?? {}), [itemKey]: result.durationSeconds } }
|
|
1264
|
+
: {}),
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
return { ...next, testOutcomes: runOutcomeProcessing(next) };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Apply max-time consequences to recorded durations: an expired test ends (designed
|
|
1272
|
+
* policy — the spec defines no expiry behavior beyond late-submission acceptance,
|
|
1273
|
+
* see ADR-0005); a current item inside any expired scope advances to the first item
|
|
1274
|
+
* still reachable, ending the test when none remains.
|
|
1275
|
+
*/
|
|
1276
|
+
function applyExpiries(state: TestSessionState): TestSessionState {
|
|
1277
|
+
if (state.status !== "in-progress") {
|
|
1278
|
+
return state;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (scopeExpired(state, { kind: "test" })) {
|
|
1282
|
+
return ended(state);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const currentKey = state.currentItemKey;
|
|
1286
|
+
const item = currentKey === null ? undefined : itemsByKey.get(currentKey);
|
|
1287
|
+
|
|
1288
|
+
if (!item || navigableInTime(state, item)) {
|
|
1289
|
+
return state;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const position = positionOf(item.key);
|
|
1293
|
+
|
|
1294
|
+
return position === null
|
|
1295
|
+
? ended(state)
|
|
1296
|
+
: moveToItem(state, firstNavigable(state, position.partIndex, position.itemIndex + 1));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Every public transition folds the clock first, then settles expiries, then runs
|
|
1301
|
+
* its operation. A blocked operation (identity) returns the ORIGINAL state — the
|
|
1302
|
+
* stamp is unchanged, so no time is lost and `canNext`'s "would `next()` change
|
|
1303
|
+
* state" contract stays meaningful.
|
|
1304
|
+
*/
|
|
1305
|
+
function withTransition(
|
|
1306
|
+
state: TestSessionState,
|
|
1307
|
+
op: (settled: TestSessionState) => TestSessionState,
|
|
1308
|
+
): TestSessionState {
|
|
1309
|
+
if (state.status !== "in-progress") {
|
|
1310
|
+
return state;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const touched = touch(state);
|
|
1314
|
+
const settled = applyExpiries(touched);
|
|
1315
|
+
|
|
1316
|
+
if (settled !== touched) {
|
|
1317
|
+
return settled; // the expiry consumed the action
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const result = op(settled);
|
|
1321
|
+
|
|
1322
|
+
return result === settled ? state : result;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
632
1325
|
// ---------- Static capability walk ----------
|
|
633
1326
|
|
|
634
1327
|
const issues: CapabilityIssue[] = [];
|
|
@@ -648,10 +1341,22 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
648
1341
|
continue;
|
|
649
1342
|
}
|
|
650
1343
|
|
|
1344
|
+
if (rule.kind === "lookupOutcomeValue") {
|
|
1345
|
+
const declaration = (view.outcomeDeclarations ?? []).find((entry) => entry.identifier === rule.identifier);
|
|
1346
|
+
|
|
1347
|
+
if (!hasLookupTable(declaration)) {
|
|
1348
|
+
report("lookupOutcomeValue"); // gate parity with the runtime refusal
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
651
1352
|
if (rule.expression) {
|
|
652
1353
|
collectExpressionIssues(rule.expression, testExpressionKinds, report);
|
|
653
1354
|
}
|
|
654
1355
|
|
|
1356
|
+
if (rule.rules) {
|
|
1357
|
+
walkOutcomeRules(rule.rules); // outcomeProcessingFragment nesting (§5.103)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
655
1360
|
for (const branch of [rule.outcomeIf, ...(rule.outcomeElseIfs ?? [])]) {
|
|
656
1361
|
if (branch) {
|
|
657
1362
|
collectExpressionIssues(branch.expression, testExpressionKinds, report);
|
|
@@ -675,11 +1380,16 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
675
1380
|
for (const branchRule of item.ref.branchRules ?? []) {
|
|
676
1381
|
collectExpressionIssues(branchRule.expression, testExpressionKinds, report);
|
|
677
1382
|
}
|
|
1383
|
+
|
|
1384
|
+
for (const entry of item.ref.templateDefaults ?? []) {
|
|
1385
|
+
collectExpressionIssues(entry.expression, testExpressionKinds, report);
|
|
1386
|
+
}
|
|
678
1387
|
}
|
|
679
1388
|
|
|
680
1389
|
// ---------- Public surface ----------
|
|
681
1390
|
|
|
682
1391
|
return {
|
|
1392
|
+
test: view,
|
|
683
1393
|
plan,
|
|
684
1394
|
issues,
|
|
685
1395
|
|
|
@@ -696,16 +1406,28 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
696
1406
|
incorrectItems: [],
|
|
697
1407
|
pendingItemResults: {},
|
|
698
1408
|
testOutcomes: {},
|
|
1409
|
+
timing: {
|
|
1410
|
+
lastTransitionAtMs: now(),
|
|
1411
|
+
testSeconds: 0,
|
|
1412
|
+
partSeconds: {},
|
|
1413
|
+
sectionSeconds: {},
|
|
1414
|
+
itemSeconds: {},
|
|
1415
|
+
},
|
|
699
1416
|
};
|
|
700
1417
|
|
|
701
|
-
|
|
1418
|
+
// Position only after the opening outcome-processing run: start-time
|
|
1419
|
+
// preconditions must see declared outcome defaults (e.g. the §2.8.3 drill
|
|
1420
|
+
// pattern gates every instance on a boolean that starts false).
|
|
1421
|
+
const opened: TestSessionState = { ...initial, testOutcomes: runOutcomeProcessing(initial) };
|
|
1422
|
+
|
|
1423
|
+
return moveToItem(opened, firstNavigable(opened, 0, 0));
|
|
702
1424
|
},
|
|
703
1425
|
|
|
704
1426
|
currentItem: (state) =>
|
|
705
1427
|
state.currentItemKey === null ? null : (allItems.find((item) => item.key === state.currentItemKey) ?? null),
|
|
706
1428
|
|
|
707
1429
|
canMoveTo: (state, itemKey) => {
|
|
708
|
-
if (state.status
|
|
1430
|
+
if (state.status !== "in-progress" || state.currentItemKey === null) {
|
|
709
1431
|
return false;
|
|
710
1432
|
}
|
|
711
1433
|
|
|
@@ -720,75 +1442,127 @@ export function createTestController(view: AssessmentTestView, options: TestCont
|
|
|
720
1442
|
return false;
|
|
721
1443
|
}
|
|
722
1444
|
|
|
723
|
-
|
|
724
|
-
},
|
|
1445
|
+
const item = plan.parts[target.partIndex]!.items[target.itemIndex]!;
|
|
725
1446
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const target = positionOf(itemKey);
|
|
1447
|
+
return preConditionsPass(item, state) && navigableInTime(state, item) && !reviewBarred(state, item);
|
|
1448
|
+
},
|
|
729
1449
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1450
|
+
moveTo: (state, itemKey) =>
|
|
1451
|
+
withTransition(state, (settled) => {
|
|
1452
|
+
const current = positionOf(settled.currentItemKey ?? "");
|
|
1453
|
+
const target = positionOf(itemKey);
|
|
1454
|
+
const item = itemsByKey.get(itemKey);
|
|
1455
|
+
|
|
1456
|
+
if (
|
|
1457
|
+
!current ||
|
|
1458
|
+
!target ||
|
|
1459
|
+
!item ||
|
|
1460
|
+
target.partIndex !== current.partIndex ||
|
|
1461
|
+
plan.parts[current.partIndex]!.navigationMode !== "nonlinear" ||
|
|
1462
|
+
!navigableInTime(settled, item) ||
|
|
1463
|
+
reviewBarred(settled, item)
|
|
1464
|
+
) {
|
|
1465
|
+
return settled;
|
|
1466
|
+
}
|
|
739
1467
|
|
|
740
|
-
|
|
741
|
-
|
|
1468
|
+
return markPresented({ ...settled, currentItemKey: itemKey }, itemKey);
|
|
1469
|
+
}),
|
|
742
1470
|
|
|
743
1471
|
canNext: (state) => nextState(state) !== state,
|
|
744
1472
|
|
|
745
|
-
next: nextState,
|
|
1473
|
+
next: (state) => withTransition(state, nextState),
|
|
746
1474
|
|
|
747
1475
|
remainingAttempts,
|
|
748
1476
|
|
|
749
|
-
canSubmitItem: (state, itemKey) =>
|
|
1477
|
+
canSubmitItem: (state, itemKey) => {
|
|
1478
|
+
if (state.status !== "in-progress" || remainingAttempts(state, itemKey) <= 0) {
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Lateness is judged on recorded timing (as of the last transition/tick).
|
|
1483
|
+
const item = itemsByKey.get(itemKey);
|
|
1484
|
+
|
|
1485
|
+
return (
|
|
1486
|
+
item !== undefined &&
|
|
1487
|
+
enclosingScopes(item).every(
|
|
1488
|
+
(scope) => !scopeExpired(state, scope) || timeLimitsOf(scope)?.allowLateSubmission === true,
|
|
1489
|
+
)
|
|
1490
|
+
);
|
|
1491
|
+
},
|
|
750
1492
|
|
|
751
1493
|
submitItem: (state, itemKey, result) => {
|
|
752
|
-
|
|
1494
|
+
const item = itemsByKey.get(itemKey);
|
|
1495
|
+
|
|
1496
|
+
if (state.status !== "in-progress" || !item) {
|
|
753
1497
|
return state;
|
|
754
1498
|
}
|
|
755
1499
|
|
|
756
|
-
const
|
|
1500
|
+
const touched = touch(state);
|
|
757
1501
|
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1502
|
+
// "The allow-late-submission attribute regulates whether a candidate's response
|
|
1503
|
+
// that is beyond the max-time should still be accepted." (§7.40.3, default
|
|
1504
|
+
// false). Every exceeded enclosing scope's own flag must permit it; a refusal
|
|
1505
|
+
// is recorded, never silent (ADR-0003), and the expiry then applies.
|
|
1506
|
+
const barring = enclosingScopes(item).find(
|
|
1507
|
+
(scope) => scopeExpired(touched, scope) && timeLimitsOf(scope)?.allowLateSubmission !== true,
|
|
1508
|
+
);
|
|
764
1509
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
:
|
|
771
|
-
|
|
1510
|
+
if (barring) {
|
|
1511
|
+
return applyExpiries({
|
|
1512
|
+
...touched,
|
|
1513
|
+
rejectedSubmissions: [
|
|
1514
|
+
...(touched.rejectedSubmissions ?? []),
|
|
1515
|
+
{ itemKey, scope: barring, atTestSeconds: touched.timing?.testSeconds ?? 0 },
|
|
1516
|
+
],
|
|
1517
|
+
});
|
|
772
1518
|
}
|
|
773
1519
|
|
|
774
|
-
|
|
775
|
-
|
|
1520
|
+
const accepted = submitBody(touched, itemKey, result);
|
|
1521
|
+
|
|
1522
|
+
return accepted === touched ? state : applyExpiries(accepted);
|
|
1523
|
+
},
|
|
1524
|
+
|
|
1525
|
+
end: (state) => (state.status === "ended" ? state : ended(touch(state))),
|
|
1526
|
+
|
|
1527
|
+
tick: (state) => (state.status !== "in-progress" ? state : applyExpiries(touch(state))),
|
|
1528
|
+
|
|
1529
|
+
suspend: (state) => {
|
|
1530
|
+
if (state.status !== "in-progress") {
|
|
776
1531
|
return state;
|
|
777
1532
|
}
|
|
778
1533
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
attemptedItems: state.attemptedItems.includes(itemKey)
|
|
783
|
-
? state.attemptedItems
|
|
784
|
-
: [...state.attemptedItems, itemKey],
|
|
785
|
-
attemptCounts: { ...(state.attemptCounts ?? {}), [itemKey]: attemptsOf(state, itemKey) + 1 },
|
|
786
|
-
};
|
|
1534
|
+
// Fold first: time up to this instant counts, and an expiry the fold reveals
|
|
1535
|
+
// still applies — suspension cannot rescue an already-exceeded limit.
|
|
1536
|
+
const settled = applyExpiries(touch(state));
|
|
787
1537
|
|
|
788
|
-
return { ...
|
|
1538
|
+
return settled.status === "in-progress" ? { ...settled, status: "suspended" } : settled;
|
|
789
1539
|
},
|
|
790
1540
|
|
|
791
|
-
|
|
1541
|
+
resume: (state) =>
|
|
1542
|
+
state.status !== "suspended"
|
|
1543
|
+
? state
|
|
1544
|
+
: {
|
|
1545
|
+
...state,
|
|
1546
|
+
status: "in-progress",
|
|
1547
|
+
// Re-stamp without folding: the suspended gap never accrues to any
|
|
1548
|
+
// scope ("minus any time the session was in the suspended state").
|
|
1549
|
+
...(state.timing ? { timing: { ...state.timing, lastTransitionAtMs: now() } } : {}),
|
|
1550
|
+
},
|
|
1551
|
+
|
|
1552
|
+
canReview: reviewable,
|
|
1553
|
+
|
|
1554
|
+
// "the item session is allowed to enter the review state during which the
|
|
1555
|
+
// candidate can review the qti-item-body along with the responses they gave,
|
|
1556
|
+
// but cannot update or resubmit them" — only the current pointer moves; the
|
|
1557
|
+
// ended status (and the stopped clock) stay put.
|
|
1558
|
+
review: (state, itemKey) => (reviewable(state, itemKey) ? { ...state, currentItemKey: itemKey } : state),
|
|
1559
|
+
|
|
1560
|
+
canComment: commentable,
|
|
1561
|
+
|
|
1562
|
+
setItemComment: (state, itemKey, comment) =>
|
|
1563
|
+
commentable(state, itemKey)
|
|
1564
|
+
? { ...state, itemComments: { ...(state.itemComments ?? {}), [itemKey]: comment } }
|
|
1565
|
+
: state,
|
|
792
1566
|
|
|
793
1567
|
visibleTestFeedbacks: (state) =>
|
|
794
1568
|
(view.testFeedbacks ?? []).filter((feedback) => {
|