tessera-learn 0.0.5 → 0.0.6

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.
@@ -1,9 +1,12 @@
1
+ import { SCORM2004_INTERACTION_FORMAT } from '../interaction-format.js';
2
+ import type { SavedState } from '../persistence.js';
1
3
  import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
2
- import { formatISO8601Duration } from './retry.js';
4
+ import {
5
+ formatISO8601Duration,
6
+ formatISO8601Timestamp,
7
+ formatReal107,
8
+ } from './retry.js';
3
9
 
4
- /**
5
- * SCORM 2004 API interface.
6
- */
7
10
  export interface SCORM2004API {
8
11
  Initialize(param: string): string;
9
12
  Terminate(param: string): string;
@@ -23,10 +26,11 @@ const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
23
26
  interactionFields: {
24
27
  responseField: 'learner_response',
25
28
  timestampField: 'timestamp',
26
- timestamp: () => new Date().toISOString(),
29
+ timestamp: () => formatISO8601Timestamp(new Date()),
27
30
  // SCORM 2004 accepts the canonical interaction `type` strings unchanged.
28
31
  typeValue: (t) => t,
29
32
  resultLabels: { correct: 'correct', incorrect: 'incorrect' },
33
+ format: SCORM2004_INTERACTION_FORMAT,
30
34
  },
31
35
  initialize: (api) => api.Initialize(''),
32
36
  terminate: (api) => api.Terminate(''),
@@ -35,49 +39,148 @@ const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
35
39
  commit: (api) => api.Commit(''),
36
40
  getLastError: (api) => api.GetLastError(),
37
41
  getErrorString: (api, code) => api.GetErrorString(code),
42
+ getDiagnostic: (api, code) => api.GetDiagnostic(code),
38
43
  };
39
44
 
45
+ /** SCORM 2004 4E §4.2.1.5 cmi.mode vocabulary. */
46
+ export type SCORM2004Mode = 'browse' | 'normal' | 'review';
47
+
40
48
  /**
41
- * SCORM 2004 persistence adapter.
42
- *
43
- * Uses a sequential write queue for all LMS SetValue/Commit calls.
44
- * On terminate, the queue is drained synchronously (single attempt)
45
- * since async retries cannot complete during page unload.
49
+ * Per §4.2.1.5, the SCO MUST NOT alter the learner record in `browse` or
50
+ * `review` mode — every write below is gated on `#mode === 'normal'`.
51
+ * `#masteryScore` (§4.2.4.3) and `#completionThreshold` (§4.2.4.4) are
52
+ * LMS-supplied thresholds in [0,1].
46
53
  */
47
54
  export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
55
+ #mode: SCORM2004Mode = 'normal';
56
+ #masteryScore: number | null = null;
57
+ #completionThreshold: number | null = null;
58
+
48
59
  constructor(api: SCORM2004API) {
49
60
  super(api, SCORM2004_DIALECT);
50
61
  }
51
62
 
63
+ async init(): Promise<void> {
64
+ await super.init();
65
+ this.#mode = this.#readMode();
66
+ this.#masteryScore = this.#readScaledThreshold('cmi.scaled_passing_score');
67
+ this.#completionThreshold = this.#readScaledThreshold(
68
+ 'cmi.completion_threshold'
69
+ );
70
+ }
71
+
72
+ getLaunchMode(): SCORM2004Mode {
73
+ return this.#mode;
74
+ }
75
+
76
+ /** Read by App.svelte to override `course.config.js scoring.passingScore`. */
77
+ getMasteryScore(): number | null {
78
+ return this.#masteryScore;
79
+ }
80
+
81
+ getCompletionThreshold(): number | null {
82
+ return this.#completionThreshold;
83
+ }
84
+
85
+ #readMode(): SCORM2004Mode {
86
+ try {
87
+ const v = this.api.GetValue('cmi.mode');
88
+ if (v === 'browse' || v === 'review' || v === 'normal') return v;
89
+ } catch {}
90
+ return 'normal';
91
+ }
92
+
93
+ #readScaledThreshold(key: string): number | null {
94
+ let raw = '';
95
+ try {
96
+ raw = this.api.GetValue(key);
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (!raw) return null;
101
+ const n = Number(raw);
102
+ if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
103
+ return null;
104
+ }
105
+
106
+ saveState(state: SavedState): void {
107
+ if (this.#mode !== 'normal') return;
108
+ super.saveState(state);
109
+ // §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
110
+ this.queue.enqueue(
111
+ () => this.api.SetValue('cmi.location', String(state.b)),
112
+ 'cmi.location'
113
+ );
114
+ }
115
+
116
+ setDuration(seconds: number): void {
117
+ if (this.#mode !== 'normal') return;
118
+ super.setDuration(seconds);
119
+ }
120
+
121
+ reportInteraction(
122
+ questionId: string,
123
+ interaction: import('../interaction.js').Interaction,
124
+ correct: boolean | null
125
+ ): void {
126
+ if (this.#mode !== 'normal') return;
127
+ super.reportInteraction(questionId, interaction, correct);
128
+ }
129
+
52
130
  setScore(score: number): void {
53
- this.queue.enqueue(() =>
54
- this.api.SetValue('cmi.score.raw', String(score))
131
+ if (this.#mode !== 'normal') return;
132
+ const raw = formatReal107(score);
133
+ // §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
134
+ const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
135
+ this.queue.enqueue(
136
+ () => this.api.SetValue('cmi.score.raw', raw),
137
+ 'cmi.score.raw'
138
+ );
139
+ this.queue.enqueue(
140
+ () => this.api.SetValue('cmi.score.min', '0'),
141
+ 'cmi.score.min'
55
142
  );
56
- this.queue.enqueue(() => this.api.SetValue('cmi.score.min', '0'));
57
- this.queue.enqueue(() => this.api.SetValue('cmi.score.max', '100'));
58
- this.queue.enqueue(() =>
59
- this.api.SetValue('cmi.score.scaled', String(score / 100))
143
+ this.queue.enqueue(
144
+ () => this.api.SetValue('cmi.score.max', '100'),
145
+ 'cmi.score.max'
146
+ );
147
+ this.queue.enqueue(
148
+ () => this.api.SetValue('cmi.score.scaled', scaled),
149
+ 'cmi.score.scaled'
60
150
  );
61
151
  }
62
152
 
63
- // Note: cmi.completion_threshold and cmi.scaled_passing_score are typically
64
- // set by the LMS, not the SCO. Tessera manages completion and passing
65
- // logic internally via course.config.js settings.
66
153
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
154
+ if (this.#mode !== 'normal') return;
67
155
  const value = status === 'complete' ? 'completed' : 'incomplete';
68
- this.queue.enqueue(() =>
69
- this.api.SetValue('cmi.completion_status', value)
156
+ this.queue.enqueue(
157
+ () => this.api.SetValue('cmi.completion_status', value),
158
+ 'cmi.completion_status'
70
159
  );
160
+ // §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
161
+ if (status === 'complete') {
162
+ this.queue.enqueue(
163
+ () => this.api.SetValue('cmi.progress_measure', '1'),
164
+ 'cmi.progress_measure'
165
+ );
166
+ }
71
167
  }
72
168
 
73
169
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
74
- // "unknown" is a valid SCORM 2004 value — setting it explicitly prevents
75
- // LMSes (notably SCORM Cloud) from rolling up a null status to "passed".
76
- this.queue.enqueue(() => this.api.SetValue('cmi.success_status', status));
170
+ if (this.#mode !== 'normal') return;
171
+ // Setting "unknown" explicitly prevents SCORM Cloud from rolling up
172
+ // a null status to "passed".
173
+ this.queue.enqueue(
174
+ () => this.api.SetValue('cmi.success_status', status),
175
+ 'cmi.success_status'
176
+ );
77
177
  }
78
178
 
79
179
  setExit(mode: 'suspend' | 'normal'): void {
80
- // SCORM 2004 §4.2 cmi.exit vocabulary: time-out, suspend, logout, normal, "".
81
- this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode));
180
+ if (this.#mode !== 'normal') return;
181
+ this.queue.enqueue(
182
+ () => this.api.SetValue('cmi.exit', mode),
183
+ 'cmi.exit'
184
+ );
82
185
  }
83
186
  }
@@ -1,79 +1,125 @@
1
1
  /**
2
- * Format `Interaction` payloads for SCORM 2004 / xAPI `cmi.interactions.n.*`
3
- * writes. Delimiters follow SCORM 2004 4th Edition RTE §4.2.7 — `cmi5` (xAPI)
4
- * reuses the same encoding for `cmi.interaction` activity statements.
5
- *
6
- * ITEM delimiter [,]
7
- * PAIR delimiter [.]
8
- * RANGE delimiter [:]
2
+ * SCORM 1.2 RTE §3.4.7 vs SCORM 2004 4E RTE §4.2.7 differ in delimiter
3
+ * encoding and identifier rules; cmi5 (xAPI) reuses the 2004 encoding.
9
4
  */
10
5
 
11
6
  import type { Interaction } from './interaction.js';
12
7
 
8
+ export interface InteractionFormat {
9
+ itemDelim: string;
10
+ pairDelim: string;
11
+ rangeDelim: string;
12
+ /**
13
+ * SCORM 1.2 has no numeric range syntax — `correct_responses.n.pattern`
14
+ * is a single CMIDecimal. SCORM 2004 supports `min[:]max`.
15
+ */
16
+ supportsNumericRange: boolean;
17
+ formatBoolean(value: boolean): string;
18
+ identifier(value: string): string;
19
+ }
20
+
21
+ export const SCORM12_INTERACTION_FORMAT: InteractionFormat = {
22
+ itemDelim: ',',
23
+ pairDelim: '.',
24
+ rangeDelim: ':',
25
+ supportsNumericRange: false,
26
+ formatBoolean: (v) => (v ? 't' : 'f'),
27
+ identifier: shortIdentifier,
28
+ };
29
+
30
+ /**
31
+ * Bracketed delimiters are literal text, not regex. xAPI parses them the
32
+ * same way.
33
+ */
34
+ export const SCORM2004_INTERACTION_FORMAT: InteractionFormat = {
35
+ itemDelim: '[,]',
36
+ pairDelim: '[.]',
37
+ rangeDelim: '[:]',
38
+ supportsNumericRange: true,
39
+ formatBoolean: (v) => (v ? 'true' : 'false'),
40
+ identifier: shortIdentifier,
41
+ };
42
+
13
43
  /**
14
- * Serialize the learner response to the `learner_response` / `student_response`
15
- * field format expected by SCORM 2004 and mirrored by xAPI.
44
+ * SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
45
+ * underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
46
+ * option labels with spaces or punctuation with error 405/406.
16
47
  */
17
- export function formatResponse(i: Interaction): string {
48
+ function shortIdentifier(value: string): string {
49
+ const cleaned = value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
50
+ const trimmed = cleaned.slice(0, 250);
51
+ return trimmed || '_';
52
+ }
53
+
54
+ export function formatResponse(
55
+ i: Interaction,
56
+ fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
57
+ ): string {
18
58
  switch (i.type) {
19
59
  case 'choice':
20
60
  case 'sequencing':
21
- return i.response.join('[,]');
61
+ return i.response.map(fmt.identifier).join(fmt.itemDelim);
22
62
  case 'true-false':
23
- return i.response ? 'true' : 'false';
63
+ return fmt.formatBoolean(i.response);
24
64
  case 'fill-in':
25
65
  case 'long-fill-in':
26
66
  case 'likert':
27
67
  case 'other':
28
68
  return i.response;
29
69
  case 'matching':
30
- return i.response.map(([l, r]) => `${l}[.]${r}`).join('[,]');
70
+ return i.response
71
+ .map(([l, r]) => `${fmt.identifier(l)}${fmt.pairDelim}${fmt.identifier(r)}`)
72
+ .join(fmt.itemDelim);
31
73
  case 'numeric':
32
74
  return String(i.response);
33
75
  case 'performance':
34
- return i.response.map(([s, v]) => `${s}[.]${v}`).join('[,]');
76
+ return i.response
77
+ .map(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
78
+ .join(fmt.itemDelim);
35
79
  }
36
80
  }
37
81
 
38
- /**
39
- * Serialize the `correct_responses.0.pattern` for this interaction. Returns
40
- * `null` if no correct pattern was provided.
41
- */
42
- export function formatCorrectPattern(i: Interaction): string | null {
82
+ /** Returns null when no correct pattern was provided. */
83
+ export function formatCorrectPattern(
84
+ i: Interaction,
85
+ fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
86
+ ): string | null {
43
87
  if (i.correct === undefined) return null;
44
88
  switch (i.type) {
45
89
  case 'choice':
46
90
  case 'sequencing':
47
- return (i.correct as string[]).join('[,]');
91
+ return (i.correct as string[]).map(fmt.identifier).join(fmt.itemDelim);
48
92
  case 'true-false':
49
- return (i.correct as boolean) ? 'true' : 'false';
93
+ return fmt.formatBoolean(i.correct as boolean);
50
94
  case 'fill-in':
51
95
  case 'long-fill-in':
52
- // SCORM 2004 accepts multiple acceptable patterns joined with `[,]`.
53
- return (i.correct as string[]).join('[,]');
96
+ return (i.correct as string[]).join(fmt.itemDelim);
54
97
  case 'matching':
55
- return (i.correct as Array<[string, string]>).map(([l, r]) => `${l}[.]${r}`).join('[,]');
98
+ return (i.correct as Array<[string, string]>)
99
+ .map(([l, r]) => `${fmt.identifier(l)}${fmt.pairDelim}${fmt.identifier(r)}`)
100
+ .join(fmt.itemDelim);
56
101
  case 'numeric': {
57
102
  const c = i.correct as { min?: number; max?: number };
58
- const min = c.min ?? '';
59
- const max = c.max ?? '';
60
- return `${min}[:]${max}`;
103
+ if (c.min !== undefined && c.max !== undefined && c.min === c.max) {
104
+ return String(c.min);
105
+ }
106
+ if (c.min !== undefined && c.max === undefined) return String(c.min);
107
+ if (c.min === undefined && c.max !== undefined) return String(c.max);
108
+ // True range — drop the pattern in 1.2 (rely on `result` for pass/fail).
109
+ if (!fmt.supportsNumericRange) return null;
110
+ return `${c.min ?? ''}${fmt.rangeDelim}${c.max ?? ''}`;
61
111
  }
62
112
  case 'likert':
63
113
  case 'other':
64
114
  return i.correct as string;
65
115
  case 'performance':
66
116
  return (i.correct as Array<[string, string | number]>)
67
- .map(([s, v]) => `${s}[.]${v}`)
68
- .join('[,]');
117
+ .map(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
118
+ .join(fmt.itemDelim);
69
119
  }
70
120
  }
71
121
 
72
- /**
73
- * Map Tessera interaction types to SCORM 1.2's narrower vocabulary. SCORM 1.2
74
- * does not define `long-fill-in`; fall back to `fill-in`. `other` is not in
75
- * the spec either — fall back to `fill-in` (free text).
76
- */
122
+ /** SCORM 1.2 has no `long-fill-in` or `other` — both fall back to `fill-in`. */
77
123
  export function scorm12Type(type: Interaction['type']): string {
78
124
  switch (type) {
79
125
  case 'long-fill-in':
@@ -85,26 +131,15 @@ export function scorm12Type(type: Interaction['type']): string {
85
131
  }
86
132
  }
87
133
 
88
- /**
89
- * Per-standard differences in how `cmi.interactions.n.*` is written. The
90
- * SCORM 1.2 vs 2004 deltas are: response field name, result vocabulary,
91
- * timestamp field name+format, and the type vocabulary mapping.
92
- */
93
134
  export interface ScormInteractionSpec {
94
135
  responseField: 'student_response' | 'learner_response';
95
136
  timestampField: 'time' | 'timestamp';
96
- /** Wall-clock value formatted to whichever style the standard expects. */
97
137
  timestamp: string;
98
- /** Mapped interaction type — already narrowed for SCORM 1.2 callers. */
99
138
  typeValue: string;
100
139
  resultLabels: { correct: string; incorrect: string };
140
+ format: InteractionFormat;
101
141
  }
102
142
 
103
- /**
104
- * Build the ordered list of `cmi.interactions.n.*` writes that SCORM 1.2 and
105
- * SCORM 2004 adapters share. Caller wires each pair through its own LMS
106
- * SetValue queue (the queueing semantics differ between adapters).
107
- */
108
143
  export function buildScormInteractionFields(
109
144
  prefix: string,
110
145
  questionId: string,
@@ -115,9 +150,9 @@ export function buildScormInteractionFields(
115
150
  const fields: Array<[string, string]> = [
116
151
  [`${prefix}.id`, questionId],
117
152
  [`${prefix}.type`, spec.typeValue],
118
- [`${prefix}.${spec.responseField}`, formatResponse(interaction)],
153
+ [`${prefix}.${spec.responseField}`, formatResponse(interaction, spec.format)],
119
154
  ];
120
- const pattern = formatCorrectPattern(interaction);
155
+ const pattern = formatCorrectPattern(interaction, spec.format);
121
156
  if (pattern !== null) {
122
157
  fields.push([`${prefix}.correct_responses.0.pattern`, pattern]);
123
158
  }