tessera-learn 0.0.4 → 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,13 +1,12 @@
1
1
  /**
2
- * Optional callback that surfaces the LMS's last-error code/message after
3
- * a failed call, so warning logs can name the actual cause instead of a
4
- * generic "LMS call failed".
2
+ * Surfaces LMSGetLastError / LMSGetErrorString / LMSGetDiagnostic so failure
3
+ * logs can name the cause instead of a generic "LMS call failed". SCORM
4
+ * Cloud uses the diagnostic to name the offending data-model element.
5
5
  */
6
6
  export interface LMSErrorReporter {
7
- /** Last error from `LMSGetLastError` / `GetLastError`. */
8
7
  code(): string;
9
- /** Human-readable message from `LMSGetErrorString` / `GetErrorString`. */
10
8
  message(code: string): string;
9
+ diagnostic?(code: string): string;
11
10
  }
12
11
 
13
12
  /** Default attempt count for LMS retry loops (one initial + two retries). */
@@ -33,19 +32,53 @@ function logRetryGiveUp(
33
32
  lastErrCode: string,
34
33
  context: string | undefined
35
34
  ): void {
36
- let detail = '';
37
- if (errorReporter && lastErrCode && lastErrCode !== '0') {
38
- try {
39
- const msg = errorReporter.message(lastErrCode);
40
- detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
41
- } catch {}
42
- }
43
35
  const ctx = context ? ` [${context}]` : '';
44
36
  console.warn(
45
- `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
37
+ `Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`
46
38
  );
47
39
  }
48
40
 
41
+ export function formatLMSErrorDetail(
42
+ errorReporter: LMSErrorReporter | undefined,
43
+ code: string
44
+ ): string {
45
+ if (!errorReporter || !code || code === '0') return '';
46
+ let msg = '';
47
+ let diag = '';
48
+ try { msg = errorReporter.message(code); } catch {}
49
+ try { diag = errorReporter.diagnostic?.(code) ?? ''; } catch {}
50
+ let detail = ` (LMS error ${code}`;
51
+ if (msg) detail += `: ${msg}`;
52
+ if (diag && diag !== msg) detail += ` — ${diag}`;
53
+ detail += ')';
54
+ return detail;
55
+ }
56
+
57
+ /** Sync call that warns with the LMS error code on failure (terminate-path). */
58
+ export function callSyncOrWarn(
59
+ fn: () => any,
60
+ context: string,
61
+ errorReporter?: LMSErrorReporter
62
+ ): boolean {
63
+ let ok = false;
64
+ try {
65
+ ok = lmsCallSucceeded(fn());
66
+ } catch (err) {
67
+ console.warn(
68
+ `Tessera: LMS call threw [${context}] during terminate`,
69
+ err
70
+ );
71
+ return false;
72
+ }
73
+ if (!ok) {
74
+ const code = readLastErrorCode(errorReporter);
75
+ console.warn(
76
+ `Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`
77
+ );
78
+ }
79
+ return ok;
80
+ }
81
+
49
82
  /**
50
83
  * Retry wrapper for LMS API calls.
51
84
  * Retries up to maxRetries times with exponential backoff.
@@ -68,17 +101,25 @@ export async function withRetry(
68
101
  context?: string
69
102
  ): Promise<boolean> {
70
103
  let lastErrCode = '';
104
+ let threw = false;
105
+ let lastError: unknown;
71
106
  for (let attempt = 0; attempt < maxRetries; attempt++) {
107
+ threw = false;
72
108
  try {
73
109
  if (lmsCallSucceeded(fn())) return true;
74
- } catch {
75
- // API call threw treat as failure
110
+ } catch (err) {
111
+ threw = true;
112
+ lastError = err;
76
113
  }
77
114
  lastErrCode = readLastErrorCode(errorReporter);
78
115
  if (attempt < maxRetries - 1) {
79
116
  await new Promise((r) => setTimeout(r, backoffMs(attempt)));
80
117
  }
81
118
  }
119
+ if (threw) {
120
+ const ctx = context ? ` [${context}]` : '';
121
+ console.warn(`Tessera: LMS call threw${ctx} on final retry`, lastError);
122
+ }
82
123
  logRetryGiveUp(errorReporter, lastErrCode, context);
83
124
  return false;
84
125
  }
@@ -275,6 +316,36 @@ export function formatHHMMSS(totalSeconds: number): string {
275
316
  return `${hh}:${mm}:${ss}.00`;
276
317
  }
277
318
 
319
+ /**
320
+ * SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
321
+ * `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
322
+ * then trims trailing zeros (no padded "0.8500000" forms).
323
+ */
324
+ export function formatReal107(value: number): string {
325
+ if (!Number.isFinite(value)) return '0';
326
+ const rounded = Math.round(value * 1e7) / 1e7;
327
+ return rounded
328
+ .toFixed(7)
329
+ .replace(/(\.\d*?)0+$/, '$1')
330
+ .replace(/\.$/, '');
331
+ }
332
+
333
+ /**
334
+ * SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
335
+ * zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
336
+ * seconds with error 406. UTC components are used so writes don't drift
337
+ * across local-TZ flips even though the format is zone-free.
338
+ */
339
+ export function formatISO8601Timestamp(date: Date): string {
340
+ const yyyy = date.getUTCFullYear();
341
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
342
+ const dd = String(date.getUTCDate()).padStart(2, '0');
343
+ const hh = String(date.getUTCHours()).padStart(2, '0');
344
+ const mi = String(date.getUTCMinutes()).padStart(2, '0');
345
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
346
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
347
+ }
348
+
278
349
  /**
279
350
  * Format seconds as ISO 8601 duration: PT1H30M45S
280
351
  */
@@ -1,34 +1,33 @@
1
1
  import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
2
  import type { Interaction } from '../interaction.js';
3
- import { buildScormInteractionFields } from '../interaction-format.js';
4
- import { WriteQueue, callSync, withRetry } from './retry.js';
3
+ import {
4
+ buildScormInteractionFields,
5
+ type InteractionFormat,
6
+ } from '../interaction-format.js';
7
+ import { WriteQueue, callSyncOrWarn, withRetry } from './retry.js';
8
+ import type { LMSErrorReporter } from './retry.js';
5
9
 
6
- /** Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters. */
10
+ /**
11
+ * Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters.
12
+ *
13
+ * `suspendDataLimit` is per-spec characters: SCORM 1.2 RTE §3.4.5.2 = 4096;
14
+ * SCORM 2004 4E §4.2 = 64000. The `LMS*`-prefixed (1.2) vs bare (2004)
15
+ * method names are abstracted here so the base class can stay version-
16
+ * agnostic.
17
+ */
7
18
  export interface ScormDialect<TApi> {
8
- /** SCORM 1.2: `cmi.core.session_time`. SCORM 2004: `cmi.session_time`. */
9
19
  sessionTimeKey: string;
10
- /** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
11
20
  formatDuration(seconds: number): string;
12
- /**
13
- * Per-spec maximum byte length for `cmi.suspend_data` (SCORM 1.2 RTE
14
- * §3.4.5.2 = 4096; SCORM 2004 4E §4.2 = 64000). Used by `saveState` to
15
- * warn once when the serialized payload would be silently truncated by
16
- * the LMS. Treated as "characters" since SCORM data-model lengths are
17
- * specified in characters and Tessera stores ASCII-safe JSON.
18
- */
19
21
  suspendDataLimit: number;
20
- /** Human label for the limit warning, e.g. "SCORM 1.2 (4096 chars)". */
21
22
  suspendDataLimitLabel: string;
22
- /** Per-interaction-row field config passed to `buildScormInteractionFields`. */
23
23
  interactionFields: {
24
24
  responseField: 'student_response' | 'learner_response';
25
25
  timestampField: 'time' | 'timestamp';
26
- /** Build the per-call timestamp string (HH:MM:SS for 1.2, ISO8601 for 2004). */
27
26
  timestamp(): string;
28
27
  typeValue(type: Interaction['type']): string;
29
28
  resultLabels: { correct: string; incorrect: string };
29
+ format: InteractionFormat;
30
30
  };
31
- /** API method wrappers — abstract over the `LMS*`-prefixed and bare names. */
32
31
  initialize(api: TApi): string;
33
32
  terminate(api: TApi): string;
34
33
  getValue(api: TApi, key: string): string;
@@ -36,12 +35,14 @@ export interface ScormDialect<TApi> {
36
35
  commit(api: TApi): string;
37
36
  getLastError(api: TApi): string;
38
37
  getErrorString(api: TApi, code: string): string;
38
+ getDiagnostic?(api: TApi, code: string): string;
39
39
  }
40
40
 
41
41
  export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
42
42
  protected readonly api: TApi;
43
43
  protected readonly dialect: ScormDialect<TApi>;
44
44
  protected readonly queue = new WriteQueue();
45
+ protected readonly errorReporter: LMSErrorReporter;
45
46
  #state: SavedState | null = null;
46
47
  #terminated = false;
47
48
  #suspendOverflowWarned = false;
@@ -50,41 +51,76 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
50
51
  constructor(api: TApi, dialect: ScormDialect<TApi>) {
51
52
  this.api = api;
52
53
  this.dialect = dialect;
53
- // Wire up GetLastError/GetErrorString so retry warnings can name the
54
- // real LMS failure (e.g. "201 Invalid argument error") instead of a
55
- // generic "LMS call failed" — production triage needs the code.
56
- this.queue.errorReporter = {
54
+ this.errorReporter = {
57
55
  code: () => this.dialect.getLastError(this.api),
58
56
  message: (c) => this.dialect.getErrorString(this.api, c),
57
+ diagnostic: this.dialect.getDiagnostic
58
+ ? (c) => this.dialect.getDiagnostic!(this.api, c)
59
+ : undefined,
59
60
  };
61
+ this.queue.errorReporter = this.errorReporter;
60
62
  }
61
63
 
62
- /** Expose the underlying SCORM API so xAPI actor synthesis can read learner fields. */
64
+ /** Exposed for xAPI actor synthesis (reads learner fields off the API). */
63
65
  getAPI(): TApi {
64
66
  return this.api;
65
67
  }
66
68
 
67
69
  async init(): Promise<void> {
68
- await withRetry(() => this.dialect.initialize(this.api));
70
+ const initialized = await withRetry(
71
+ () => this.dialect.initialize(this.api),
72
+ undefined,
73
+ this.errorReporter,
74
+ 'Initialize'
75
+ );
76
+ if (!initialized) {
77
+ console.warn(
78
+ 'Tessera: LMS Initialize failed — all subsequent persistence calls will fail with error 301 (Not Initialized). Reload the launch from the LMS.'
79
+ );
80
+ return;
81
+ }
69
82
 
83
+ let raw = '';
70
84
  try {
71
- const raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
72
- if (raw && raw.trim()) {
85
+ raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
86
+ } catch (err) {
87
+ console.warn(
88
+ 'Tessera: LMS threw on GetValue(cmi.suspend_data); resume disabled for this launch',
89
+ err
90
+ );
91
+ }
92
+ if (raw && raw.trim()) {
93
+ try {
73
94
  this.#state = JSON.parse(raw);
95
+ } catch (err) {
96
+ console.warn(
97
+ 'Tessera: cmi.suspend_data is not valid JSON; resume disabled for this launch (the LMS may have truncated a prior write)',
98
+ err
99
+ );
100
+ this.#state = null;
74
101
  }
75
- } catch {
76
- this.#state = null;
77
102
  }
78
103
 
79
- // Continue cmi.interactions.n indexing where the previous session left
80
- // off. Restarting at 0 would overwrite prior records (the LMS uses n
81
- // as the array key, not an upsert field).
104
+ // n indexing must continue from _count — restarting at 0 would overwrite
105
+ // the prior session's records (the LMS uses n as the array key).
106
+ let countRaw = '';
82
107
  try {
83
- const count = this.dialect.getValue(this.api, 'cmi.interactions._count');
84
- const n = parseInt(count, 10);
85
- if (Number.isFinite(n) && n >= 0) this.interactionCount = n;
86
- } catch {
87
- // Some LMSes throw on _count when no interactions exist — fall back to 0.
108
+ countRaw = this.dialect.getValue(this.api, 'cmi.interactions._count');
109
+ } catch (err) {
110
+ console.warn(
111
+ 'Tessera: LMS threw on GetValue(cmi.interactions._count); new interactions will be written from index 0 and may overwrite prior session records',
112
+ err
113
+ );
114
+ return;
115
+ }
116
+ if (countRaw === '' || countRaw === '0') return;
117
+ const n = parseInt(countRaw, 10);
118
+ if (Number.isFinite(n) && n >= 0) {
119
+ this.interactionCount = n;
120
+ } else {
121
+ console.warn(
122
+ `Tessera: LMS returned non-numeric cmi.interactions._count="${countRaw}"; new interactions will be written from index 0 and may overwrite prior session records`
123
+ );
88
124
  }
89
125
  }
90
126
 
@@ -108,15 +144,18 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
108
144
  `larger-limit standard (scorm2004/cmi5).`
109
145
  );
110
146
  }
111
- this.queue.enqueue(() =>
112
- this.dialect.setValue(this.api, 'cmi.suspend_data', json)
147
+ this.queue.enqueue(
148
+ () => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
149
+ 'cmi.suspend_data'
113
150
  );
114
151
  }
115
152
 
116
153
  setDuration(seconds: number): void {
117
154
  const formatted = this.dialect.formatDuration(seconds);
118
- this.queue.enqueue(() =>
119
- this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted)
155
+ this.queue.enqueue(
156
+ () =>
157
+ this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
158
+ this.dialect.sessionTimeKey
120
159
  );
121
160
  }
122
161
 
@@ -137,29 +176,34 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
137
176
  timestamp: this.dialect.interactionFields.timestamp(),
138
177
  typeValue: this.dialect.interactionFields.typeValue(interaction.type),
139
178
  resultLabels: this.dialect.interactionFields.resultLabels,
179
+ format: this.dialect.interactionFields.format,
140
180
  }
141
181
  );
142
182
  for (const [key, value] of fields) {
143
- this.queue.enqueue(() => this.dialect.setValue(this.api, key, value));
183
+ this.queue.enqueue(
184
+ () => this.dialect.setValue(this.api, key, value),
185
+ key
186
+ );
144
187
  }
145
188
  }
146
189
 
147
190
  commit(): void {
148
- this.queue.enqueue(() => this.dialect.commit(this.api));
191
+ this.queue.enqueue(() => this.dialect.commit(this.api), 'Commit');
149
192
  }
150
193
 
151
194
  terminate(): void {
152
195
  if (this.#terminated) return;
153
196
  this.#terminated = true;
154
- // During page unload, async retries can't run.
155
- // Drain any pending queue operations synchronously (single attempt each),
156
- // then commit and finish synchronously.
197
+ // Async retries can't run during page unload drain + commit + finish synchronously.
157
198
  this.queue.drainSync();
158
- callSync(() => this.dialect.commit(this.api));
159
- callSync(() => this.dialect.terminate(this.api));
199
+ callSyncOrWarn(() => this.dialect.commit(this.api), 'Commit', this.errorReporter);
200
+ callSyncOrWarn(
201
+ () => this.dialect.terminate(this.api),
202
+ 'Terminate',
203
+ this.errorReporter
204
+ );
160
205
  }
161
206
 
162
- // The four operations that genuinely diverge between SCORM versions.
163
207
  abstract setScore(score: number): void;
164
208
  abstract setCompletionStatus(status: 'incomplete' | 'complete'): void;
165
209
  abstract setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void;
@@ -1,6 +1,10 @@
1
- import { scorm12Type } from '../interaction-format.js';
1
+ import {
2
+ SCORM12_INTERACTION_FORMAT,
3
+ scorm12Type,
4
+ } from '../interaction-format.js';
5
+ import type { SavedState } from '../persistence.js';
2
6
  import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
3
- import { formatHHMMSS } from './retry.js';
7
+ import { formatHHMMSS, formatReal107 } from './retry.js';
4
8
 
5
9
  /**
6
10
  * SCORM 1.2 API interface.
@@ -27,6 +31,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
27
31
  timestamp: () => new Date().toTimeString().slice(0, 8),
28
32
  typeValue: (t) => scorm12Type(t),
29
33
  resultLabels: { correct: 'correct', incorrect: 'wrong' },
34
+ format: SCORM12_INTERACTION_FORMAT,
30
35
  },
31
36
  initialize: (api) => api.LMSInitialize(''),
32
37
  terminate: (api) => api.LMSFinish(''),
@@ -35,6 +40,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
35
40
  commit: (api) => api.LMSCommit(''),
36
41
  getLastError: (api) => api.LMSGetLastError(),
37
42
  getErrorString: (api, code) => api.LMSGetErrorString(code),
43
+ getDiagnostic: (api, code) => api.LMSGetDiagnostic(code),
38
44
  };
39
45
 
40
46
  /**
@@ -57,12 +63,29 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
57
63
  super(api, SCORM12_DIALECT);
58
64
  }
59
65
 
66
+ saveState(state: SavedState): void {
67
+ super.saveState(state);
68
+ // §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
69
+ this.queue.enqueue(
70
+ () =>
71
+ this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
72
+ 'cmi.core.lesson_location'
73
+ );
74
+ }
75
+
60
76
  setScore(score: number): void {
61
- this.queue.enqueue(() =>
62
- this.api.LMSSetValue('cmi.core.score.raw', String(score))
77
+ this.queue.enqueue(
78
+ () => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
79
+ 'cmi.core.score.raw'
80
+ );
81
+ this.queue.enqueue(
82
+ () => this.api.LMSSetValue('cmi.core.score.min', '0'),
83
+ 'cmi.core.score.min'
84
+ );
85
+ this.queue.enqueue(
86
+ () => this.api.LMSSetValue('cmi.core.score.max', '100'),
87
+ 'cmi.core.score.max'
63
88
  );
64
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.min', '0'));
65
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.max', '100'));
66
89
  }
67
90
 
68
91
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
@@ -78,17 +101,19 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
78
101
  }
79
102
 
80
103
  #flushLessonStatus(): void {
81
- // Success status takes priority — it's the more specific status.
82
104
  const value = this.#successStatus ?? this.#completionStatus;
83
- this.queue.enqueue(() =>
84
- this.api.LMSSetValue('cmi.core.lesson_status', value)
105
+ this.queue.enqueue(
106
+ () => this.api.LMSSetValue('cmi.core.lesson_status', value),
107
+ 'cmi.core.lesson_status'
85
108
  );
86
109
  }
87
110
 
88
111
  setExit(mode: 'suspend' | 'normal'): void {
89
112
  // SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
90
- // We only map 'suspend' and the empty/normal case.
91
113
  const value = mode === 'suspend' ? 'suspend' : '';
92
- this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.exit', value));
114
+ this.queue.enqueue(
115
+ () => this.api.LMSSetValue('cmi.core.exit', value),
116
+ 'cmi.core.exit'
117
+ );
93
118
  }
94
119
  }
@@ -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
  }