@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.
package/src/store.ts CHANGED
@@ -13,7 +13,15 @@
13
13
  */
14
14
 
15
15
  import { scoreResponse } from "./response-processing";
16
- import { applyCorrectResponseOverrides, executeResponseProcessing, executeTemplateProcessing, mulberry32 } from "./rp";
16
+ import { collectResponseViolations } from "./response-validity";
17
+ import type { InteractionConstraint, ResponseViolation } from "./response-validity";
18
+ import {
19
+ applyCorrectResponseOverrides,
20
+ applyTemplateDefaultOverrides,
21
+ executeResponseProcessing,
22
+ executeTemplateProcessing,
23
+ mulberry32,
24
+ } from "./rp";
17
25
  import type {
18
26
  CustomOperatorImplementation,
19
27
  OutcomeDeclarationView,
@@ -36,6 +44,23 @@ export interface AttemptSnapshot {
36
44
  readonly templateValues: Readonly<Record<string, OutcomeValue>>;
37
45
  /** Completed attempts so far (only ever exceeds 1 for adaptive items). */
38
46
  readonly attemptCount: number;
47
+ /**
48
+ * Elapsed session seconds at the latest submit (the built-in `duration` response
49
+ * variable handed to RP); null before the first submit. Persist it alongside the
50
+ * responses for server-side replay parity (ADR-0004).
51
+ */
52
+ readonly durationSeconds: number | null;
53
+ /**
54
+ * Interaction constraints the current responses fail (see response-validity).
55
+ * Always visible so UIs can explain themselves; submission is blocked on them
56
+ * only under `validateResponses`.
57
+ */
58
+ readonly responseViolations: readonly ResponseViolation[];
59
+ /**
60
+ * This clone's resolved correct responses (template `setCorrectResponse` overrides
61
+ * applied), keyed by response identifier. The solution state renders these.
62
+ */
63
+ readonly correctResponses: Readonly<Record<string, ResponseValue>>;
39
64
  }
40
65
 
41
66
  /**
@@ -51,12 +76,32 @@ export interface AttemptStoreOptions {
51
76
  readonly normalization?: ResponseNormalization | undefined;
52
77
  readonly templateDeclarations?: readonly TemplateDeclarationView[] | undefined;
53
78
  readonly templateProcessing?: TemplateProcessingView | undefined;
79
+ /**
80
+ * Test-level `templateDefault` values (§5.152) overriding the template
81
+ * declarations' defaults for this clone; the test session store supplies them from
82
+ * the controller's recorded `templateDefaultValues`.
83
+ */
84
+ readonly templateDefaultValues?: Readonly<Record<string, OutcomeValue>> | undefined;
54
85
  /** Clone seed for template processing; store it to replay the same clone. */
55
86
  readonly seed?: number | undefined;
56
87
  /** QTI adaptive item: multiple attempts, outcome carry-over, completionStatus lock. */
57
88
  readonly adaptive?: boolean | undefined;
58
89
  /** Registered vendor `customOperator` implementations by class (opt-in). */
59
90
  readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
91
+ /**
92
+ * Millisecond clock backing the built-in `duration` response variable (wall-clock
93
+ * from session start to submit). Injectable for deterministic tests and replays;
94
+ * defaults to Date.now.
95
+ */
96
+ readonly now?: (() => number) | undefined;
97
+ /** The item's interaction constraints (collectInteractionConstraints over the body). */
98
+ readonly constraints?: readonly InteractionConstraint[] | undefined;
99
+ /**
100
+ * ItemSessionControl validate-responses: "candidates are not allowed to submit the
101
+ * item until they have provided valid responses for all interactions". When set,
102
+ * submit() refuses while `responseViolations` is non-empty.
103
+ */
104
+ readonly validateResponses?: boolean | undefined;
60
105
  }
61
106
 
62
107
  export interface AttemptStore {
@@ -76,6 +121,15 @@ export interface AttemptStore {
76
121
  ) => () => void;
77
122
  readonly submit: () => readonly ScoreResult[];
78
123
  readonly reset: () => void;
124
+ /**
125
+ * Stop the session clock: duration "records the accumulated time (in seconds) of
126
+ * all Candidate Sessions for all Attempts … minus any time the session was in the
127
+ * suspended state". Navigating away from an item suspends its session (spec);
128
+ * the test session store drives this. Idempotent.
129
+ */
130
+ readonly suspend: () => void;
131
+ /** Restart the session clock after `suspend()`. Idempotent. */
132
+ readonly resume: () => void;
79
133
  }
80
134
 
81
135
  export function createAttemptStore(
@@ -84,9 +138,13 @@ export function createAttemptStore(
84
138
  options?: AttemptStoreOptions,
85
139
  ): AttemptStore {
86
140
  const seed = options?.seed ?? Math.floor(Math.random() * 2 ** 31);
141
+ // Test-level templateDefault values replace the declared defaults for this clone.
142
+ const templateDeclarations = options?.templateDefaultValues
143
+ ? applyTemplateDefaultOverrides(options.templateDeclarations ?? [], options.templateDefaultValues)
144
+ : (options?.templateDeclarations ?? []);
87
145
  const templateResult = options?.templateProcessing
88
146
  ? executeTemplateProcessing(options.templateProcessing, {
89
- templateDeclarations: options.templateDeclarations ?? [],
147
+ templateDeclarations,
90
148
  responseDeclarations: declarations,
91
149
  seed,
92
150
  customOperators: options.customOperators,
@@ -102,14 +160,53 @@ export function createAttemptStore(
102
160
  // RP's random stream: seed-derived but independent of template processing's, and
103
161
  // continuous across attempts — seed + submission sequence replays exact outcomes.
104
162
  const rpRandom = mulberry32((seed ^ 0x9e3779b9) >>> 0);
163
+ const now = options?.now ?? Date.now;
164
+ // The session clock: accumulated active milliseconds plus the running stretch.
165
+ // "the time between the beginning and the end of the item session minus any time
166
+ // the session was in the suspended state."
167
+ let activeMs = 0;
168
+ let runningSinceMs: number | null = now();
169
+
170
+ const activeSeconds = (): number => (activeMs + (runningSinceMs === null ? 0 : now() - runningSinceMs)) / 1000;
171
+
172
+ // The built-in completionStatus "starts with the reserved value 'not_attempted'.
173
+ // At the start of the first attempt it changes to the reserved value 'unknown'."
174
+ // (§2.2.2.3). The store exists only for a presented item, so its creation is the
175
+ // start of the first attempt; "not_attempted" is the state of items that never got
176
+ // a store. Explicit declarations (legacy content) keep the declared path.
177
+ const completionStatusDeclared = (options?.outcomeDeclarations ?? []).some(
178
+ (declaration) => declaration.identifier === "completionStatus",
179
+ );
180
+ const maintainedOutcomes = (): Readonly<Record<string, OutcomeValue>> =>
181
+ completionStatusDeclared ? {} : { completionStatus: "unknown" };
182
+
183
+ const violationsOf = (responses: Readonly<Record<string, ResponseValue>>): readonly ResponseViolation[] =>
184
+ options?.constraints ? collectResponseViolations(options.constraints, responses) : [];
185
+
186
+ // The clone's correct responses, flattened to response values for the solution state.
187
+ const correctResponses: Record<string, ResponseValue> = {};
188
+
189
+ for (const declaration of effectiveDeclarations) {
190
+ const values = declaration.correctResponse?.values;
191
+
192
+ if (values !== undefined) {
193
+ correctResponses[declaration.identifier] =
194
+ declaration.cardinality === "single"
195
+ ? ((values[0]?.value ?? null) as ResponseValue)
196
+ : (values.map((entry) => entry.value) as never);
197
+ }
198
+ }
105
199
 
106
200
  let snapshot: AttemptSnapshot = {
107
201
  responses: { ...initialResponses },
108
202
  submitted: false,
109
203
  scores: [],
110
- outcomes: {},
204
+ outcomes: maintainedOutcomes(),
111
205
  templateValues: templateResult?.templateValues ?? {},
112
206
  attemptCount: 0,
207
+ durationSeconds: null,
208
+ responseViolations: violationsOf(initialResponses),
209
+ correctResponses,
113
210
  };
114
211
 
115
212
  function emit(next: AttemptSnapshot): void {
@@ -128,6 +225,7 @@ export function createAttemptStore(
128
225
 
129
226
  function computeOutcomes(
130
227
  responses: Readonly<Record<string, ResponseValue>>,
228
+ durationSeconds: number,
131
229
  priorOutcomes?: Readonly<Record<string, OutcomeValue>>,
132
230
  ): Readonly<Record<string, OutcomeValue>> {
133
231
  if (!options?.responseProcessing) {
@@ -139,11 +237,18 @@ export function createAttemptStore(
139
237
  outcomeDeclarations: options.outcomeDeclarations ?? [],
140
238
  responses,
141
239
  normalization: options.normalization,
142
- templateDeclarations: options.templateDeclarations,
240
+ templateDeclarations,
143
241
  templateValues: snapshot.templateValues,
144
242
  priorOutcomes,
145
243
  random: rpRandom,
146
244
  customOperators: options.customOperators,
245
+ // Built-in session variables: numAttempts "increases by 1 at the start of
246
+ // each attempt", so the attempt being scored is included.
247
+ duration: durationSeconds,
248
+ numAttempts: snapshot.attemptCount + 1,
249
+ ...(typeof snapshot.outcomes["completionStatus"] === "string"
250
+ ? { completionStatus: snapshot.outcomes["completionStatus"] }
251
+ : {}),
147
252
  }).outcomes;
148
253
  }
149
254
 
@@ -165,9 +270,12 @@ export function createAttemptStore(
165
270
  return;
166
271
  }
167
272
 
273
+ const responses = { ...snapshot.responses, [responseIdentifier]: value };
274
+
168
275
  emit({
169
276
  ...snapshot,
170
- responses: { ...snapshot.responses, [responseIdentifier]: value },
277
+ responses,
278
+ responseViolations: violationsOf(responses),
171
279
  });
172
280
  },
173
281
 
@@ -198,12 +306,24 @@ export function createAttemptStore(
198
306
  }
199
307
 
200
308
  if (collected !== snapshot.responses) {
201
- snapshot = { ...snapshot, responses: collected };
309
+ snapshot = { ...snapshot, responses: collected, responseViolations: violationsOf(collected) };
310
+ }
311
+
312
+ // "candidates are not allowed to submit the item until they have provided
313
+ // valid responses for all interactions" — the refusal is visible through
314
+ // `responseViolations`, never silent (ADR-0003).
315
+ if (options?.validateResponses && snapshot.responseViolations.length > 0) {
316
+ emit(snapshot);
317
+
318
+ return snapshot.scores;
202
319
  }
203
320
 
204
321
  const scores = computeScores(snapshot.responses);
322
+ const durationSeconds = activeSeconds();
205
323
  const priorOutcomes = options?.adaptive && snapshot.attemptCount > 0 ? snapshot.outcomes : undefined;
206
- const outcomes = computeOutcomes(snapshot.responses, priorOutcomes);
324
+ const rpOutcomes = computeOutcomes(snapshot.responses, durationSeconds, priorOutcomes);
325
+ // Items without responseProcessing still carry the maintained built-in.
326
+ const outcomes = options?.responseProcessing ? rpOutcomes : { ...maintainedOutcomes(), ...rpOutcomes };
207
327
  // completion_status: the corpus's snake_case authoring of the same built-in.
208
328
  const completionStatus = outcomes["completionStatus"] ?? outcomes["completion_status"];
209
329
  const completed = !options?.adaptive || completionStatus === "completed";
@@ -229,19 +349,37 @@ export function createAttemptStore(
229
349
  scores,
230
350
  outcomes,
231
351
  attemptCount: snapshot.attemptCount + 1,
352
+ durationSeconds,
232
353
  });
233
354
 
234
355
  return scores;
235
356
  },
236
357
 
358
+ suspend: () => {
359
+ if (runningSinceMs !== null) {
360
+ activeMs += now() - runningSinceMs;
361
+ runningSinceMs = null;
362
+ }
363
+ },
364
+
365
+ resume: () => {
366
+ runningSinceMs ??= now();
367
+ },
368
+
237
369
  reset: () => {
370
+ activeMs = 0;
371
+ runningSinceMs = now();
372
+
238
373
  emit({
239
374
  responses: { ...initialResponses },
240
375
  submitted: false,
241
376
  scores: [],
242
- outcomes: {},
377
+ outcomes: maintainedOutcomes(),
243
378
  templateValues: snapshot.templateValues,
244
379
  attemptCount: 0,
380
+ durationSeconds: null,
381
+ responseViolations: violationsOf(initialResponses),
382
+ correctResponses,
245
383
  });
246
384
  },
247
385
  };