@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.
Files changed (42) hide show
  1. package/dist/index.d.ts +6 -4
  2. package/dist/index.js +2492 -408
  3. package/dist/normalized-item.d.ts +7 -5
  4. package/dist/pnp.d.ts +115 -0
  5. package/dist/response-validity.d.ts +28 -0
  6. package/dist/rp/evaluate.d.ts +6 -1
  7. package/dist/rp/index.d.ts +2 -2
  8. package/dist/rp/interpreter.d.ts +7 -1
  9. package/dist/rp/lookup-table.d.ts +17 -0
  10. package/dist/rp/template-processing.d.ts +5 -0
  11. package/dist/rp/types.d.ts +71 -7
  12. package/dist/runtime.d.ts +95 -0
  13. package/dist/store.d.ts +47 -0
  14. package/dist/test/controller.d.ts +22 -0
  15. package/dist/test/index.d.ts +2 -1
  16. package/dist/test/results.d.ts +102 -0
  17. package/dist/test/session-store.d.ts +32 -0
  18. package/dist/test/types.d.ts +173 -5
  19. package/dist/types.d.ts +5 -0
  20. package/package.json +5 -1
  21. package/src/content-model.ts +44 -4
  22. package/src/index.ts +43 -1
  23. package/src/normalized-item.ts +106 -4
  24. package/src/pci/mount.ts +11 -3
  25. package/src/pnp.ts +333 -0
  26. package/src/reference-skin/choice.ts +3 -0
  27. package/src/response-validity.ts +163 -0
  28. package/src/rp/evaluate.ts +280 -32
  29. package/src/rp/index.ts +5 -0
  30. package/src/rp/interpreter.ts +81 -1
  31. package/src/rp/lookup-table.ts +46 -0
  32. package/src/rp/template-processing.ts +41 -0
  33. package/src/rp/types.ts +75 -7
  34. package/src/runtime.ts +397 -20
  35. package/src/store.ts +146 -8
  36. package/src/test/controller.ts +856 -82
  37. package/src/test/index.ts +23 -0
  38. package/src/test/results.ts +378 -0
  39. package/src/test/session-store.ts +109 -1
  40. package/src/test/types.ts +172 -5
  41. package/src/types.ts +1 -0
  42. package/src/xspattern.d.ts +11 -0
package/src/test/index.ts CHANGED
@@ -1,4 +1,21 @@
1
1
  export { createTestController, type TestControllerOptions } from "./controller";
2
+ export {
3
+ assessmentResultFromNormalized,
4
+ buildAssessmentResult,
5
+ type AssessmentResultDocumentView,
6
+ type AssessmentResultInput,
7
+ type AssessmentResultItemDetails,
8
+ type AssessmentResultView,
9
+ type ItemResultView,
10
+ type ResultContextView,
11
+ type ResultOutcomeVariableView,
12
+ type ResultResponseVariableView,
13
+ type ResultSessionIdentifierView,
14
+ type ResultSessionStatus,
15
+ type ResultSupportView,
16
+ type ResultValueView,
17
+ type TestResultView,
18
+ } from "./results";
2
19
  export {
3
20
  createTestSessionStore,
4
21
  type TestSessionSnapshot,
@@ -7,12 +24,15 @@ export {
7
24
  } from "./session-store";
8
25
  export type {
9
26
  AssessmentItemRefView,
27
+ RecordedAttempt,
10
28
  AssessmentSectionView,
11
29
  AssessmentTestView,
12
30
  BranchRuleView,
13
31
  ItemSessionControlView,
14
32
  OutcomeConditionBranch,
15
33
  OutcomeRuleView,
34
+ RejectedSubmission,
35
+ TemplateDefaultView,
16
36
  TestController,
17
37
  TestFeedbackView,
18
38
  TestItemResult,
@@ -20,6 +40,9 @@ export type {
20
40
  TestPlan,
21
41
  TestPlanItem,
22
42
  TestPlanPart,
43
+ TestPlanSection,
23
44
  TestSessionState,
45
+ TestTimingState,
24
46
  TimeLimitsView,
47
+ TimingScopeRef,
25
48
  } from "./types";
@@ -0,0 +1,378 @@
1
+ /**
2
+ * QTI Results Reporting — the export builder. Maps a session (test view + resolved
3
+ * plan + persisted state) onto the AssessmentResult model: one final itemResult per
4
+ * recorded attempt ("A report may contain multiple results for the same instance of
5
+ * an item representing multiple attempts … each item result must have a different
6
+ * datestamp"), pendingResponseProcessing for unflushed simultaneous submissions,
7
+ * and initial entries for everything else — "all items selected for presentation
8
+ * should be reported with a corresponding itemResult". The shapes mirror the
9
+ * contracts result schema structurally; serialization to XML lives in qti-xml.
10
+ */
11
+
12
+ import { pnpFeaturePreference, resolvePnpActivation, type PnpView } from "../pnp";
13
+ import type { OutcomeDeclarationView, OutcomeValue } from "../rp";
14
+ import type { ResponseDeclarationView, ResponseValue } from "../types";
15
+ import type { AssessmentTestView, RecordedAttempt, TestPlan, TestPlanItem, TestSessionState } from "./types";
16
+
17
+ export interface ResultValueView {
18
+ readonly value: string;
19
+ /** Required for record-cardinality members, invalid otherwise (schema rule). */
20
+ readonly fieldIdentifier?: string;
21
+ readonly baseType?: string;
22
+ }
23
+
24
+ export interface ResultResponseVariableView {
25
+ readonly identifier: string;
26
+ readonly cardinality: string;
27
+ readonly baseType?: string;
28
+ readonly candidateResponse: { readonly values: readonly ResultValueView[] };
29
+ readonly correctResponse?: { readonly values: readonly ResultValueView[] };
30
+ }
31
+
32
+ export interface ResultOutcomeVariableView {
33
+ readonly identifier: string;
34
+ readonly cardinality: string;
35
+ readonly baseType?: string;
36
+ readonly values: readonly ResultValueView[];
37
+ }
38
+
39
+ export type ResultSessionStatus =
40
+ | "final"
41
+ | "initial"
42
+ | "pendingExternalScoring"
43
+ | "pendingResponseProcessing"
44
+ | "pendingSubmission";
45
+
46
+ export interface ItemResultView {
47
+ readonly identifier: string;
48
+ readonly sequenceIndex?: number;
49
+ readonly datestamp: string;
50
+ readonly sessionStatus: ResultSessionStatus;
51
+ readonly responseVariables?: readonly ResultResponseVariableView[];
52
+ readonly outcomeVariables?: readonly ResultOutcomeVariableView[];
53
+ readonly candidateComment?: string;
54
+ }
55
+
56
+ /**
57
+ * RR Support (§2.6.4): a feature "aligned to the QTI profile of the 1EdTech Access
58
+ * for All Personal Needs and Preferences (AfA PNP)".
59
+ */
60
+ export interface ResultSupportView {
61
+ readonly name: string;
62
+ readonly assignment: "assigned" | "universal" | "prohibited" | "inherit";
63
+ readonly value?: string;
64
+ readonly xmlLang?: string;
65
+ }
66
+
67
+ export interface TestResultView {
68
+ readonly identifier: string;
69
+ readonly datestamp: string;
70
+ readonly responseVariables?: readonly ResultResponseVariableView[];
71
+ readonly outcomeVariables?: readonly ResultOutcomeVariableView[];
72
+ readonly supports?: readonly ResultSupportView[];
73
+ }
74
+
75
+ export interface ResultSessionIdentifierView {
76
+ readonly sourceId: string;
77
+ readonly identifier: string;
78
+ }
79
+
80
+ export interface ResultContextView {
81
+ readonly sourcedId?: string;
82
+ readonly sessionIdentifiers?: readonly ResultSessionIdentifierView[];
83
+ }
84
+
85
+ export interface AssessmentResultView {
86
+ readonly context: ResultContextView;
87
+ readonly testResult?: TestResultView;
88
+ readonly itemResults?: readonly ItemResultView[];
89
+ }
90
+
91
+ export interface AssessmentResultDocumentView {
92
+ readonly assessmentResult: AssessmentResultView;
93
+ }
94
+
95
+ export interface AssessmentResultItemDetails {
96
+ readonly responseDeclarations?: readonly ResponseDeclarationView[];
97
+ readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
98
+ /** The clone's resolved correct responses (`AttemptSnapshot.correctResponses`). */
99
+ readonly correctResponses?: Readonly<Record<string, ResponseValue>>;
100
+ }
101
+
102
+ export interface AssessmentResultInput {
103
+ readonly test: AssessmentTestView;
104
+ readonly plan: TestPlan;
105
+ readonly state: TestSessionState;
106
+ /** Result context (`sourcedId`, session identifiers); the consumer's identifiers. */
107
+ readonly context?: ResultContextView;
108
+ /** Export instant for the testResult datestamp (epoch ms; default Date.now()). */
109
+ readonly nowMs?: number;
110
+ /** Per-item typing and correct responses; omit (or return null) when unresolvable. */
111
+ readonly itemDetails?: (item: TestPlanItem) => AssessmentResultItemDetails | null;
112
+ /** The candidate's AfA PNP the session ran with — reported as testResult supports. */
113
+ readonly pnp?: PnpView | undefined;
114
+ }
115
+
116
+ function iso(ms: number): string {
117
+ return new Date(ms).toISOString();
118
+ }
119
+
120
+ /**
121
+ * The supports the session ran with, from the PNP's activation policy: assigned
122
+ * features carry their stated detail (language; the additional-testing-time value);
123
+ * prohibited features carry none — "A value MUST NOT be present when
124
+ * 'assignment=prohibited'" (RR §2.6.4.3).
125
+ */
126
+ function pnpSupports(pnp: PnpView | undefined): ResultSupportView[] {
127
+ if (!pnp) {
128
+ return [];
129
+ }
130
+
131
+ const activation = resolvePnpActivation(pnp);
132
+ const names = [...new Set([...activation.active, ...activation.optional, ...activation.prohibited])].sort();
133
+
134
+ return names.map((name): ResultSupportView => {
135
+ if (activation.prohibited.has(name)) {
136
+ return { name, assignment: "prohibited" };
137
+ }
138
+
139
+ const preference = pnpFeaturePreference(pnp, name);
140
+ const xmlLang = typeof preference?.["xmlLang"] === "string" ? preference["xmlLang"] : undefined;
141
+
142
+ let value: string | undefined;
143
+ if (name === "additional-testing-time") {
144
+ const time = pnp.additionalTestingTime;
145
+ value =
146
+ time?.unlimited === true
147
+ ? "unlimited"
148
+ : time?.timeMultiplier !== undefined
149
+ ? String(time.timeMultiplier)
150
+ : time?.fixedMinutes !== undefined
151
+ ? String(time.fixedMinutes)
152
+ : undefined;
153
+ }
154
+
155
+ return {
156
+ name,
157
+ assignment: "assigned",
158
+ ...(value !== undefined ? { value } : {}),
159
+ ...(xmlLang !== undefined ? { xmlLang } : {}),
160
+ };
161
+ });
162
+ }
163
+
164
+ /** Flatten a stored value into result `value` entries (record members keep fields). */
165
+ function valueViews(value: OutcomeValue | ResponseValue): ResultValueView[] {
166
+ if (value === null || value === undefined || value === "") {
167
+ return [];
168
+ }
169
+
170
+ if (Array.isArray(value)) {
171
+ return value.filter((member) => member !== null && member !== "").map((member) => ({ value: String(member) }));
172
+ }
173
+
174
+ if (typeof value === "object") {
175
+ // Record values (arrays were handled above): members keep their field names.
176
+ return Object.entries(value)
177
+ .filter(([, member]) => member !== null && member !== "")
178
+ .map(([fieldIdentifier, member]) => ({ fieldIdentifier, value: String(member) }));
179
+ }
180
+
181
+ return [{ value: String(value) }];
182
+ }
183
+
184
+ function inferBaseType(value: OutcomeValue | ResponseValue, identifier: string): string | undefined {
185
+ const sample = Array.isArray(value) ? value[0] : value;
186
+
187
+ if (typeof sample === "number") {
188
+ return "float";
189
+ }
190
+
191
+ if (typeof sample === "boolean") {
192
+ return "boolean";
193
+ }
194
+
195
+ if (identifier === "completionStatus" || identifier === "completion_status") {
196
+ return "identifier"; // the built-in's declared type (§2.2.2.3)
197
+ }
198
+
199
+ return typeof sample === "string" ? "string" : undefined;
200
+ }
201
+
202
+ function outcomeVariablesOf(
203
+ outcomes: Readonly<Record<string, OutcomeValue>>,
204
+ declarations: readonly OutcomeDeclarationView[] | undefined,
205
+ ): ResultOutcomeVariableView[] {
206
+ return Object.entries(outcomes).map(([identifier, value]) => {
207
+ const declaration = declarations?.find((entry) => entry.identifier === identifier);
208
+ const cardinality = declaration?.cardinality ?? (Array.isArray(value) ? "multiple" : "single");
209
+ const baseType = declaration?.baseType ?? inferBaseType(value, identifier);
210
+
211
+ return {
212
+ identifier,
213
+ cardinality,
214
+ // baseType must be omitted for record cardinality (schema rule).
215
+ ...(baseType !== undefined && cardinality !== "record" ? { baseType } : {}),
216
+ values: valueViews(value),
217
+ };
218
+ });
219
+ }
220
+
221
+ /** Durations are "a single float that records time in seconds" (RR base types). */
222
+ function durationVariable(identifier: string, seconds: number): ResultResponseVariableView {
223
+ return {
224
+ identifier,
225
+ cardinality: "single",
226
+ baseType: "duration",
227
+ candidateResponse: { values: [{ value: String(seconds) }] },
228
+ };
229
+ }
230
+
231
+ /** numAttempts is reported as the built-in response variable it is. */
232
+ function numAttemptsVariable(count: number): ResultResponseVariableView {
233
+ return {
234
+ identifier: "numAttempts",
235
+ cardinality: "single",
236
+ baseType: "integer",
237
+ candidateResponse: { values: [{ value: String(count) }] },
238
+ };
239
+ }
240
+
241
+ function responseVariablesOf(
242
+ responses: Readonly<Record<string, ResponseValue>> | undefined,
243
+ details: AssessmentResultItemDetails | null,
244
+ ): ResultResponseVariableView[] {
245
+ return Object.entries(responses ?? {}).map(([identifier, value]) => {
246
+ const declaration = details?.responseDeclarations?.find((entry) => entry.identifier === identifier);
247
+ const cardinality = declaration?.cardinality ?? (Array.isArray(value) ? "multiple" : "single");
248
+ const baseType = declaration?.baseType;
249
+ const correct = details?.correctResponses?.[identifier];
250
+ const correctValues = correct === undefined ? [] : valueViews(correct);
251
+
252
+ return {
253
+ identifier,
254
+ cardinality,
255
+ ...(baseType !== undefined && cardinality !== "record" ? { baseType } : {}),
256
+ candidateResponse: { values: valueViews(value) },
257
+ // correctResponse requires at least one value (schema); omit when empty.
258
+ ...(correctValues.length > 0 ? { correctResponse: { values: correctValues } } : {}),
259
+ };
260
+ });
261
+ }
262
+
263
+ export function buildAssessmentResult(input: AssessmentResultInput): AssessmentResultDocumentView {
264
+ const { test, plan, state } = input;
265
+ const nowMs = input.nowMs ?? Date.now();
266
+ const timing = state.timing;
267
+
268
+ const scopeDurations: ResultResponseVariableView[] = timing
269
+ ? [
270
+ durationVariable("duration", timing.testSeconds),
271
+ ...plan.parts
272
+ .filter((part) => timing.partSeconds[part.identifier] !== undefined)
273
+ .map((part) => durationVariable(`${part.identifier}.duration`, timing.partSeconds[part.identifier]!)),
274
+ ...Object.keys(plan.sections)
275
+ .filter((identifier) => timing.sectionSeconds[identifier] !== undefined)
276
+ .map((identifier) => durationVariable(`${identifier}.duration`, timing.sectionSeconds[identifier]!)),
277
+ ]
278
+ : [];
279
+ const testOutcomes = outcomeVariablesOf(state.testOutcomes, test.outcomeDeclarations);
280
+ const supports = pnpSupports(input.pnp);
281
+ const testResult: TestResultView = {
282
+ identifier: test.identifier,
283
+ datestamp: iso(nowMs),
284
+ ...(scopeDurations.length > 0 ? { responseVariables: scopeDurations } : {}),
285
+ ...(testOutcomes.length > 0 ? { outcomeVariables: testOutcomes } : {}),
286
+ ...(supports.length > 0 ? { supports } : {}),
287
+ };
288
+
289
+ const itemResults: ItemResultView[] = [];
290
+ let sequenceIndex = 0;
291
+
292
+ for (const part of plan.parts) {
293
+ for (const item of part.items) {
294
+ sequenceIndex += 1;
295
+
296
+ const details = input.itemDetails?.(item) ?? null;
297
+ const entries: ItemResultView[] = [];
298
+
299
+ // One final itemResult per committed attempt, stamped with its submit instant.
300
+ (state.attemptHistory?.[item.key] ?? []).forEach((attempt: RecordedAttempt, index) => {
301
+ const outcomeVariables = outcomeVariablesOf(attempt.outcomes, details?.outcomeDeclarations);
302
+
303
+ entries.push({
304
+ identifier: item.key,
305
+ sequenceIndex,
306
+ datestamp: iso(attempt.atMs),
307
+ sessionStatus: "final",
308
+ responseVariables: [
309
+ numAttemptsVariable(index + 1),
310
+ ...(attempt.durationSeconds !== undefined ? [durationVariable("duration", attempt.durationSeconds)] : []),
311
+ ...responseVariablesOf(attempt.responses, details),
312
+ ],
313
+ ...(outcomeVariables.length > 0 ? { outcomeVariables } : {}),
314
+ });
315
+ });
316
+
317
+ // An unflushed simultaneous submission: responses are in, outcomes are not
318
+ // committed until the part flushes — "after submission but before response
319
+ // processing" (SessionStatusEnum).
320
+ const pending = state.pendingItemResults?.[item.key];
321
+
322
+ if (pending) {
323
+ entries.push({
324
+ identifier: item.key,
325
+ sequenceIndex,
326
+ datestamp: iso(pending.submittedAtMs ?? nowMs),
327
+ sessionStatus: "pendingResponseProcessing",
328
+ responseVariables: [
329
+ numAttemptsVariable(1),
330
+ ...(pending.durationSeconds !== undefined ? [durationVariable("duration", pending.durationSeconds)] : []),
331
+ ...responseVariablesOf(pending.responses, details),
332
+ ],
333
+ });
334
+ }
335
+
336
+ if (entries.length === 0) {
337
+ // "initial … can only be used to describe sessions for which the response
338
+ // variable numAttempts is 0" — selected (in the plan) but never attempted.
339
+ const itemSeconds = timing?.itemSeconds[item.key];
340
+
341
+ entries.push({
342
+ identifier: item.key,
343
+ sequenceIndex,
344
+ datestamp: iso(nowMs),
345
+ sessionStatus: "initial",
346
+ responseVariables: [
347
+ numAttemptsVariable(0),
348
+ ...(itemSeconds !== undefined ? [durationVariable("duration", itemSeconds)] : []),
349
+ ],
350
+ });
351
+ }
352
+
353
+ // The candidate's comment (allowComment) rides the item's latest result.
354
+ const comment = state.itemComments?.[item.key];
355
+
356
+ if (comment !== undefined) {
357
+ entries[entries.length - 1] = { ...entries[entries.length - 1]!, candidateComment: comment };
358
+ }
359
+
360
+ itemResults.push(...entries);
361
+ }
362
+ }
363
+
364
+ return {
365
+ assessmentResult: {
366
+ context: input.context ?? {},
367
+ testResult,
368
+ ...(itemResults.length > 0 ? { itemResults } : {}),
369
+ },
370
+ };
371
+ }
372
+
373
+ /** Reshape a normalized assessmentResult document into the typed view (import side). */
374
+ export function assessmentResultFromNormalized(document: unknown): AssessmentResultView | null {
375
+ const root = (document as { assessmentResult?: AssessmentResultView } | null)?.assessmentResult;
376
+
377
+ return root && typeof root === "object" ? root : null;
378
+ }
@@ -7,11 +7,15 @@
7
7
  * persistence stays with the consumer (store the seed and `snapshot.state`).
8
8
  */
9
9
 
10
+ import type { PnpView } from "../pnp";
11
+ import { collectInteractionConstraints } from "../response-validity";
10
12
  import type { CustomOperatorImplementation, ResponseNormalization, TemplateRuleView } from "../rp";
11
13
  import type { AssessmentItemView } from "../runtime";
12
14
  import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "../store";
13
15
  import { isResponseRecord } from "../types";
14
16
  import type { ResponseValue } from "../types";
17
+ import { buildAssessmentResult } from "./results";
18
+ import type { AssessmentResultDocumentView, ResultContextView } from "./results";
15
19
  import type { AssessmentItemRefView, TestController, TestFeedbackView, TestPlanItem, TestSessionState } from "./types";
16
20
 
17
21
  export interface TestSessionStoreOptions {
@@ -27,6 +31,16 @@ export interface TestSessionStoreOptions {
27
31
  readonly normalization?: ResponseNormalization;
28
32
  /** Registered vendor `customOperator` implementations by class (opt-in). */
29
33
  readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
34
+ /**
35
+ * Millisecond clock for the item-session duration clocks (pair it with the
36
+ * controller's `now` for coherent timing). Defaults to Date.now.
37
+ */
38
+ readonly now?: () => number;
39
+ /**
40
+ * The candidate's AfA PNP — pair it with the controller's `pnp` (time-limit
41
+ * accommodations live there). The store reports it as result supports.
42
+ */
43
+ readonly pnp?: PnpView;
30
44
  }
31
45
 
32
46
  export interface TestSessionSnapshot {
@@ -48,6 +62,26 @@ export interface TestSessionStore {
48
62
  readonly canMoveTo: (itemKey: string) => boolean;
49
63
  readonly moveTo: (itemKey: string) => void;
50
64
  readonly end: () => void;
65
+ /** Fold elapsed time and apply time-limit expiries; consumers drive the cadence. */
66
+ readonly tick: () => void;
67
+ /** Post-end review navigation (allowReview); a no-op where review is barred. */
68
+ readonly review: (itemKey: string) => void;
69
+ /** Record a candidate comment (allowComment); a no-op where comments are barred. */
70
+ readonly setItemComment: (itemKey: string, comment: string) => void;
71
+ /** Suspend the session: every scope clock and the current item's clock stop. */
72
+ readonly suspend: () => void;
73
+ /** Resume a suspended session; the gap never accrues to any duration. */
74
+ readonly resume: () => void;
75
+ /**
76
+ * Build the QTI Results Reporting document for the session as it stands:
77
+ * per-attempt final itemResults, pending submissions, initial entries for
78
+ * everything else, typed by the resolved item views and the clones' correct
79
+ * responses. Serialize with qti-xml's `serializeQtiAssessmentResult`.
80
+ */
81
+ readonly assessmentResult: (options?: {
82
+ readonly context?: ResultContextView;
83
+ readonly nowMs?: number;
84
+ }) => AssessmentResultDocumentView;
51
85
  }
52
86
 
53
87
  /** Identifiers whose correct response arrives via `setCorrectResponse` in templates. */
@@ -153,10 +187,33 @@ export function createTestSessionStore(controller: TestController, options: Test
153
187
  };
154
188
  }
155
189
 
190
+ /** The item whose session clock should be running: current, in an open session. */
191
+ function activeKeyOf(sessionState: TestSessionState): string | null {
192
+ return sessionState.status === "in-progress" ? sessionState.currentItemKey : null;
193
+ }
194
+
156
195
  function emit(next: TestSessionState): void {
196
+ const previousActive = activeKeyOf(state);
197
+
157
198
  state = next;
158
199
  snapshot = buildSnapshot();
159
200
 
201
+ // Item sessions suspend on navigation and resume on return — "candidates may
202
+ // change their responses for an item and then leave it in the suspended state by
203
+ // navigating to a different item in the same part of the test" — and the whole
204
+ // session's suspended/ended states stop the current clock too.
205
+ const nextActive = activeKeyOf(next);
206
+
207
+ if (previousActive !== nextActive) {
208
+ if (previousActive !== null) {
209
+ itemStores.get(previousActive)?.suspend();
210
+ }
211
+
212
+ if (nextActive !== null) {
213
+ itemStores.get(nextActive)?.resume();
214
+ }
215
+ }
216
+
160
217
  for (const listener of listeners) {
161
218
  listener();
162
219
  }
@@ -177,16 +234,25 @@ export function createTestSessionStore(controller: TestController, options: Test
177
234
  }
178
235
 
179
236
  const view = itemView(itemKey);
237
+ const planItem = planItemsByKey.get(itemKey);
180
238
 
181
- if (!view) {
239
+ if (!view || !planItem) {
182
240
  itemStores.set(itemKey, null);
183
241
  return null;
184
242
  }
185
243
 
244
+ // validate-responses is "only applicable when the item is in a qti-test-part
245
+ // with individual submission mode".
246
+ const individual = controller.plan.parts.some(
247
+ (part) => part.identifier === planItem.partIdentifier && part.submissionMode === "individual",
248
+ );
249
+
186
250
  const store = createAttemptStore(
187
251
  view.responseDeclarations,
188
252
  {},
189
253
  {
254
+ constraints: collectInteractionConstraints(view.itemBody.content),
255
+ validateResponses: planItem.sessionControl.validateResponses && individual,
190
256
  outcomeDeclarations: view.outcomeDeclarations,
191
257
  responseProcessing: view.responseProcessing,
192
258
  templateDeclarations: view.templateDeclarations,
@@ -195,9 +261,18 @@ export function createTestSessionStore(controller: TestController, options: Test
195
261
  seed: deriveItemSeed(options.seed, itemKey),
196
262
  normalization: options.normalization,
197
263
  customOperators: options.customOperators,
264
+ // Test-level templateDefault values recorded by the controller (§5.152).
265
+ templateDefaultValues: state.templateDefaultValues?.[itemKey],
266
+ now: options.now,
198
267
  },
199
268
  );
200
269
 
270
+ // A store created for an item that is not the running current item starts with
271
+ // its session clock suspended; navigation (emit) resumes it when it is reached.
272
+ if (activeKeyOf(state) !== itemKey) {
273
+ store.suspend();
274
+ }
275
+
201
276
  const scorable = scorableIdentifiers(view);
202
277
 
203
278
  // Every submitted snapshot flows into the controller, which decides what it means:
@@ -213,6 +288,11 @@ export function createTestSessionStore(controller: TestController, options: Test
213
288
  outcomes: attempt.outcomes,
214
289
  ...resultFlags(attempt, scorable),
215
290
  ...(view.adaptive === true ? { adaptive: true } : {}),
291
+ // The item session's elapsed seconds feed the built-in ITEM.duration.
292
+ ...(attempt.durationSeconds !== null ? { durationSeconds: attempt.durationSeconds } : {}),
293
+ valid: attempt.responseViolations.length === 0,
294
+ // Recorded into the attempt history: candidateResponse in results reporting.
295
+ responses: attempt.responses,
216
296
  });
217
297
 
218
298
  if (next !== state) {
@@ -239,5 +319,33 @@ export function createTestSessionStore(controller: TestController, options: Test
239
319
  canMoveTo: (itemKey) => controller.canMoveTo(state, itemKey),
240
320
  moveTo: (itemKey) => emit(controller.moveTo(state, itemKey)),
241
321
  end: () => emit(controller.end(state)),
322
+ tick: () => emit(controller.tick(state)),
323
+ review: (itemKey) => emit(controller.review(state, itemKey)),
324
+ setItemComment: (itemKey, comment) => emit(controller.setItemComment(state, itemKey, comment)),
325
+ suspend: () => emit(controller.suspend(state)),
326
+ resume: () => emit(controller.resume(state)),
327
+ assessmentResult: (resultOptions) =>
328
+ buildAssessmentResult({
329
+ test: controller.test,
330
+ plan: controller.plan,
331
+ state,
332
+ ...(resultOptions?.context !== undefined ? { context: resultOptions.context } : {}),
333
+ nowMs: resultOptions?.nowMs ?? (options.now ?? Date.now)(),
334
+ ...(options.pnp !== undefined ? { pnp: options.pnp } : {}),
335
+ itemDetails: (item) => {
336
+ const view = itemView(item.key);
337
+
338
+ if (!view) {
339
+ return null;
340
+ }
341
+
342
+ return {
343
+ responseDeclarations: view.responseDeclarations,
344
+ ...(view.outcomeDeclarations !== undefined ? { outcomeDeclarations: view.outcomeDeclarations } : {}),
345
+ // The clone's template-resolved correct responses back `correctResponse`.
346
+ correctResponses: itemStore(item.key)?.getSnapshot().correctResponses ?? {},
347
+ };
348
+ },
349
+ }),
242
350
  };
243
351
  }