@longsightgroup/qti3-core 0.1.2 → 0.2.1

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/session.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  QtiDiagnostic,
6
6
  QtiDocument,
7
7
  QtiModalFeedback,
8
+ QtiPortableCustomStateValue,
8
9
  QtiProcessingExpression,
9
10
  QtiRecordValue,
10
11
  QtiResponseCondition,
@@ -49,6 +50,8 @@ export interface QtiItemSession {
49
50
  readonly item: QtiAssessmentItem;
50
51
  correctResponses(): Record<string, QtiValue>;
51
52
  respond(identifier: string, value: QtiValue): void;
53
+ setInteractionState(identifier: string, state: QtiPortableCustomStateValue): void;
54
+ interactionState(identifier: string): QtiPortableCustomStateValue | undefined;
52
55
  setStatus(status: QtiAttemptStatus): void;
53
56
  score(): QtiScoreResult;
54
57
  serialize(): QtiAttemptStateV1;
@@ -76,14 +79,24 @@ export function createItemSession(
76
79
  const priorResponses = cloneValueRecord(priorState?.responses ?? {});
77
80
  const priorOutcomes = cloneValueRecord(priorState?.outcomes ?? {});
78
81
  const priorTemplateValues = cloneValueRecord(priorState?.templateValues ?? {});
82
+ const priorInteractionStates = clonePortableCustomStateRecord(
83
+ priorState?.interactionStates ?? {},
84
+ );
79
85
  let validationMessages = cloneDiagnostics(priorState?.validationMessages ?? []);
80
86
  const responses: Record<string, QtiValue> = {};
81
87
  const outcomes: Record<string, QtiValue> = {};
82
88
  const templateValues: Record<string, QtiValue> = {};
89
+ const interactionStates: Record<string, QtiPortableCustomStateValue> = {};
83
90
  const correctResponses: Record<string, QtiValue> = {};
84
91
  let status: QtiAttemptStatus = priorState?.status ?? "initialized";
85
92
  const random = seededRandom(options.randomSeed ?? document.item.identifier);
86
93
  const customOperators = options.customOperators ?? {};
94
+ const portableCustomResponseIdentifiers = new Set(
95
+ document.item.interactions
96
+ .filter((interaction) => interaction.type === "portableCustom")
97
+ .map((interaction) => interaction.responseIdentifier)
98
+ .filter((identifier): identifier is string => Boolean(identifier)),
99
+ );
87
100
 
88
101
  for (const declaration of document.item.responseDeclarations) {
89
102
  correctResponses[declaration.identifier] = cloneValue(declaration.correctResponse);
@@ -132,6 +145,7 @@ export function createItemSession(
132
145
  const defaultOutcomes = cloneValueRecord(outcomes);
133
146
  Object.assign(responses, priorResponses);
134
147
  Object.assign(outcomes, priorOutcomes);
148
+ Object.assign(interactionStates, priorInteractionStates);
135
149
 
136
150
  return {
137
151
  item: document.item,
@@ -143,6 +157,18 @@ export function createItemSession(
143
157
  validationMessages = [];
144
158
  startAttempt();
145
159
  },
160
+ setInteractionState(identifier: string, state: QtiPortableCustomStateValue) {
161
+ if (!portableCustomResponseIdentifiers.has(identifier)) {
162
+ throw new Error(`Cannot set interaction state for non-PCI response ${identifier}.`);
163
+ }
164
+ interactionStates[identifier] = clonePortableCustomState(state);
165
+ validationMessages = [];
166
+ startAttempt();
167
+ },
168
+ interactionState(identifier: string) {
169
+ const state = interactionStates[identifier];
170
+ return state === undefined ? undefined : clonePortableCustomState(state);
171
+ },
146
172
  setStatus(nextStatus: QtiAttemptStatus) {
147
173
  status = nextStatus;
148
174
  },
@@ -173,6 +199,7 @@ export function createItemSession(
173
199
  responses,
174
200
  outcomes,
175
201
  templateValues,
202
+ interactionStates,
176
203
  diagnostics,
177
204
  );
178
205
  return { outcomes: cloneValueRecord(outcomes), diagnostics, state };
@@ -184,6 +211,7 @@ export function createItemSession(
184
211
  responses,
185
212
  outcomes,
186
213
  templateValues,
214
+ interactionStates,
187
215
  validationMessages,
188
216
  );
189
217
  },
@@ -234,9 +262,19 @@ function assertCompatiblePriorState(
234
262
  const templateIdentifiers = new Set(
235
263
  document.item.templateDeclarations.map((declaration) => declaration.identifier),
236
264
  );
265
+ const interactionStateIdentifiers = new Set(
266
+ document.item.interactions
267
+ .filter((interaction) => interaction.type === "portableCustom")
268
+ .map((interaction) => interaction.responseIdentifier)
269
+ .filter((identifier): identifier is string => Boolean(identifier)),
270
+ );
237
271
  assertKnownStateIdentifiers("response", priorState.responses, responseIdentifiers);
238
272
  assertKnownStateIdentifiers("outcome", priorState.outcomes, outcomeIdentifiers);
239
273
  assertKnownStateIdentifiers("template", priorState.templateValues ?? {}, templateIdentifiers);
274
+ assertKnownPortableCustomStateIdentifiers(
275
+ priorState.interactionStates ?? {},
276
+ interactionStateIdentifiers,
277
+ );
240
278
  for (const message of priorState.validationMessages) {
241
279
  if (message.path && !responseIdentifiers.has(message.path)) {
242
280
  throw new Error(`Cannot restore validation message for unknown response ${message.path}.`);
@@ -272,6 +310,14 @@ function assertKnownStateIdentifiers(
272
310
  if (unknown) throw new Error(`Cannot restore unknown ${kind} identifier ${unknown}.`);
273
311
  }
274
312
 
313
+ function assertKnownPortableCustomStateIdentifiers(
314
+ record: Record<string, QtiPortableCustomStateValue>,
315
+ allowed: Set<string>,
316
+ ): void {
317
+ const unknown = Object.keys(record).find((identifier) => !allowed.has(identifier));
318
+ if (unknown) throw new Error(`Cannot restore unknown interaction state identifier ${unknown}.`);
319
+ }
320
+
275
321
  function assertRestoredValueMatchesDeclaration(
276
322
  kind: string,
277
323
  declaration: QtiVariableDeclaration,
@@ -369,6 +415,12 @@ function attemptStateErrors(value: unknown): string[] {
369
415
  if (value.templateValues !== undefined && !isQtiValueRecord(value.templateValues)) {
370
416
  errors.push("QTI attempt state templateValues must be a record of QTI values.");
371
417
  }
418
+ if (
419
+ value.interactionStates !== undefined &&
420
+ !isPortableCustomStateRecord(value.interactionStates)
421
+ ) {
422
+ errors.push("QTI attempt state interactionStates must be a record of JSON values.");
423
+ }
372
424
  if (!isDiagnosticArray(value.validationMessages)) {
373
425
  errors.push("QTI attempt state validationMessages must be an array of diagnostics.");
374
426
  }
@@ -1793,6 +1845,7 @@ function serialize(
1793
1845
  responses: Record<string, QtiValue>,
1794
1846
  outcomes: Record<string, QtiValue>,
1795
1847
  templateValues: Record<string, QtiValue>,
1848
+ interactionStates: Record<string, QtiPortableCustomStateValue>,
1796
1849
  validationMessages: QtiDiagnostic[],
1797
1850
  ): QtiAttemptStateV1 {
1798
1851
  return {
@@ -1802,6 +1855,7 @@ function serialize(
1802
1855
  responses: cloneValueRecord(responses),
1803
1856
  outcomes: cloneValueRecord(outcomes),
1804
1857
  templateValues: cloneValueRecord(templateValues),
1858
+ interactionStates: clonePortableCustomStateRecord(interactionStates),
1805
1859
  validationMessages: cloneDiagnostics(validationMessages),
1806
1860
  };
1807
1861
  }
@@ -1810,12 +1864,26 @@ function cloneValueRecord(record: Record<string, QtiValue>): Record<string, QtiV
1810
1864
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, cloneValue(value)]));
1811
1865
  }
1812
1866
 
1867
+ function clonePortableCustomStateRecord(
1868
+ record: Record<string, QtiPortableCustomStateValue>,
1869
+ ): Record<string, QtiPortableCustomStateValue> {
1870
+ return Object.fromEntries(
1871
+ Object.entries(record).map(([key, value]) => [key, clonePortableCustomState(value)]),
1872
+ );
1873
+ }
1874
+
1813
1875
  function cloneValue(value: QtiValue): QtiValue {
1814
1876
  if (Array.isArray(value)) return [...value];
1815
1877
  if (isRecordValue(value)) return cloneValueRecord(value);
1816
1878
  return value;
1817
1879
  }
1818
1880
 
1881
+ function clonePortableCustomState(value: QtiPortableCustomStateValue): QtiPortableCustomStateValue {
1882
+ if (Array.isArray(value)) return value.map(clonePortableCustomState);
1883
+ if (isPortableCustomStateObject(value)) return clonePortableCustomStateRecord(value);
1884
+ return value;
1885
+ }
1886
+
1819
1887
  function cloneDiagnostics(diagnostics: QtiDiagnostic[]): QtiDiagnostic[] {
1820
1888
  return diagnostics.map((diagnostic) => ({
1821
1889
  ...diagnostic,
@@ -1937,6 +2005,28 @@ function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
1937
2005
  return Object.values(value).every(isQtiValue);
1938
2006
  }
1939
2007
 
2008
+ function isPortableCustomState(value: unknown): value is QtiPortableCustomStateValue {
2009
+ if (value === null) return true;
2010
+ if (typeof value === "string" || typeof value === "boolean") return true;
2011
+ if (typeof value === "number") return Number.isFinite(value);
2012
+ if (Array.isArray(value)) return value.every(isPortableCustomState);
2013
+ if (isRecord(value)) return Object.values(value).every(isPortableCustomState);
2014
+ return false;
2015
+ }
2016
+
2017
+ function isPortableCustomStateObject(
2018
+ value: QtiPortableCustomStateValue,
2019
+ ): value is { [key: string]: QtiPortableCustomStateValue } {
2020
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2021
+ }
2022
+
2023
+ function isPortableCustomStateRecord(
2024
+ value: unknown,
2025
+ ): value is Record<string, QtiPortableCustomStateValue> {
2026
+ if (!isRecord(value)) return false;
2027
+ return Object.values(value).every(isPortableCustomState);
2028
+ }
2029
+
1940
2030
  function isDiagnosticArray(value: unknown): value is QtiDiagnostic[] {
1941
2031
  if (!Array.isArray(value)) return false;
1942
2032
  return value.every((item) => {
package/src/support.ts CHANGED
@@ -22,7 +22,7 @@ export const interactionSupport: QtiInteractionElementSupport[] = [
22
22
  entry("qti-media-interaction", "media"),
23
23
  entry("qti-order-interaction", "order"),
24
24
  entry("qti-position-object-interaction", "positionObject"),
25
- entry("qti-portable-custom-interaction", "portableCustom"),
25
+ pciEntry(),
26
26
  entry("qti-select-point-interaction", "selectPoint"),
27
27
  entry("qti-slider-interaction", "slider"),
28
28
  entry("qti-text-entry-interaction", "textEntry"),
@@ -225,6 +225,14 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
225
225
  };
226
226
  }
227
227
 
228
+ function pciEntry(): QtiInteractionElementSupport {
229
+ return {
230
+ ...entry("qti-portable-custom-interaction", "portableCustom"),
231
+ notes:
232
+ "Parses and validates PCI metadata, exposes a browser host contract, scores captured responses, and preserves opaque interaction state. Production module execution policy belongs to the host delivery runtime.",
233
+ };
234
+ }
235
+
228
236
  function processingEntry(
229
237
  qtiName: string,
230
238
  test: string,