@conform-ed/qti-react 0.0.15 → 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.
@@ -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(["outcomeCondition", "setOutcomeValue", "exitTest"]);
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(children: readonly SectionChild[], select: number, random: () => number): SectionChild[] {
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
- function applyOrdering(children: readonly SectionChild[], random: () => number): SectionChild[] {
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: (SectionChild | null)[] = children.map((child) => (child.fixed === true ? child : null));
129
- const movable = children.filter((child) => child.fixed !== true);
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.select, random);
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
- children = applyOrdering(children, random);
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 children) {
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, path, preConditions, control, random));
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: path,
176
- preConditions: [...preConditions, ...(child.preConditions ?? [])],
177
- sessionControl: { ...specSessionControlDefaults, ...control, ...definedControl(child.itemSessionControl) },
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: view.testParts.map((part) => ({
192
- identifier: part.identifier,
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
- const itemKey = identifier.slice(0, dot);
289
- const variableName = identifier.slice(dot + 1);
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) * weight));
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) whose preconditions pass. */
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
- flagged = applyResultFlags(flagged, key, result);
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 { ...flagged, itemOutcomes, attemptCounts, pendingItemResults: remaining };
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 === "ended" || state.currentItemKey === null) {
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
- const target = positionOf(branchRule.target);
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
- return moveToItem({ ...initial, testOutcomes: runOutcomeProcessing(initial) }, firstNavigable(initial, 0, 0));
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 === "ended" || state.currentItemKey === null) {
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
- return preConditionsPass(plan.parts[target.partIndex]!.items[target.itemIndex]!, state);
724
- },
1445
+ const item = plan.parts[target.partIndex]!.items[target.itemIndex]!;
725
1446
 
726
- moveTo: (state, itemKey) => {
727
- const current = positionOf(state.currentItemKey ?? "");
728
- const target = positionOf(itemKey);
1447
+ return preConditionsPass(item, state) && navigableInTime(state, item) && !reviewBarred(state, item);
1448
+ },
729
1449
 
730
- if (
731
- state.status === "ended" ||
732
- !current ||
733
- !target ||
734
- target.partIndex !== current.partIndex ||
735
- plan.parts[current.partIndex]!.navigationMode !== "nonlinear"
736
- ) {
737
- return state;
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
- return markPresented({ ...state, currentItemKey: itemKey }, itemKey);
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) => state.status !== "ended" && remainingAttempts(state, itemKey) > 0,
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
- if (state.status === "ended") {
1494
+ const item = itemsByKey.get(itemKey);
1495
+
1496
+ if (state.status !== "in-progress" || !item) {
753
1497
  return state;
754
1498
  }
755
1499
 
756
- const partIndex = partIndexByItemKey.get(itemKey);
1500
+ const touched = touch(state);
757
1501
 
758
- // Simultaneous parts hold results pending and allow revision until the part is
759
- // left; the single attempt (spec) is only spent when the pending set flushes.
760
- if (partIndex !== undefined && plan.parts[partIndex]!.submissionMode === "simultaneous") {
761
- if (attemptsOf(state, itemKey) > 0) {
762
- return state; // the part was already submitted
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
- return {
766
- ...state,
767
- pendingItemResults: { ...(state.pendingItemResults ?? {}), [itemKey]: result },
768
- attemptedItems: state.attemptedItems.includes(itemKey)
769
- ? state.attemptedItems
770
- : [...state.attemptedItems, itemKey],
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
- // Adaptive items run their own attempt lifecycle, so maxAttempts is ignored (spec).
775
- if (result.adaptive !== true && remainingAttempts(state, itemKey) <= 0) {
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
- const next: TestSessionState = {
780
- ...applyResultFlags(state, itemKey, result),
781
- itemOutcomes: { ...state.itemOutcomes, [itemKey]: result.outcomes },
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 { ...next, testOutcomes: runOutcomeProcessing(next) };
1538
+ return settled.status === "in-progress" ? { ...settled, status: "suspended" } : settled;
789
1539
  },
790
1540
 
791
- end: (state) => (state.status === "ended" ? state : ended(state)),
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) => {