tessera-learn 0.0.1 → 0.0.2

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.
@@ -29,6 +29,8 @@
29
29
  const nav = new NavigationState(manifest, progress, config);
30
30
  let duration = $state(new DurationTracker(0));
31
31
 
32
+ const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
33
+
32
34
  // Page loading state
33
35
  let PageComponent = $state(null);
34
36
  let pageLoading = $state(true);
@@ -125,21 +127,18 @@
125
127
  }
126
128
 
127
129
  // ---- Branding ----
128
- function parseColor(ctx, color) {
129
- ctx.fillStyle = '#000';
130
- ctx.fillStyle = color;
131
- if (ctx.fillStyle === '#000000'
132
- && color.trim().toLowerCase() !== '#000000'
133
- && color.trim().toLowerCase() !== '#000'
134
- && color.trim().toLowerCase() !== 'black') {
130
+ function parseColor(color) {
131
+ if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
135
132
  return null;
136
133
  }
137
- const hex = ctx.fillStyle;
138
- return {
139
- r: parseInt(hex.slice(1, 3), 16),
140
- g: parseInt(hex.slice(3, 5), 16),
141
- b: parseInt(hex.slice(5, 7), 16),
142
- };
134
+ const el = document.createElement('span');
135
+ el.style.color = color;
136
+ document.documentElement.appendChild(el);
137
+ const computed = getComputedStyle(el).color;
138
+ el.remove();
139
+ const match = computed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
140
+ if (!match) return null;
141
+ return { r: +match[1], g: +match[2], b: +match[3] };
143
142
  }
144
143
 
145
144
  function rgbToHsl(r, g, b) {
@@ -160,11 +159,7 @@
160
159
  const el = document.documentElement;
161
160
  if (cfg.branding?.primaryColor) {
162
161
  el.style.setProperty('--tessera-primary', cfg.branding.primaryColor);
163
- // Create the canvas once here rather than inside parseColor to avoid
164
- // allocating a new element for every color resolved.
165
- const canvas = document.createElement('canvas');
166
- const ctx = canvas.getContext('2d');
167
- const rgb = ctx ? parseColor(ctx, cfg.branding.primaryColor) : null;
162
+ const rgb = parseColor(cfg.branding.primaryColor);
168
163
  if (rgb) {
169
164
  const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
170
165
  el.style.setProperty('--tessera-primary-light', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`);
@@ -297,14 +292,14 @@
297
292
  $effect(() => {
298
293
  const scores = progress.quizScores;
299
294
  if (!persistenceReady || scores.size === 0) return;
295
+ if (gradedQuizIndices.length === 0) return;
300
296
 
301
- const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
302
297
  const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
303
298
  if (completedGraded.length === 0) return;
304
299
 
305
300
  // Divide by total graded count — incomplete quizzes count as 0, matching
306
301
  // the recalculateSuccess logic in progress.svelte.ts.
307
- const average = completedGraded.reduce((sum, i) => sum + scores.get(i), 0) / gradedQuizIndices.length;
302
+ const average = completedGraded.reduce((sum, i) => sum + (scores.get(i) ?? 0), 0) / gradedQuizIndices.length;
308
303
 
309
304
  untrack(() => {
310
305
  adapter.setScore(Math.round(average));
@@ -8,7 +8,7 @@
8
8
  {#if error?.message}
9
9
  <p><small>{error.message}</small></p>
10
10
  {/if}
11
- <button class="tessera-error-retry" onclick={onretry}>
11
+ <button class="tessera-btn-primary tessera-error-retry" onclick={onretry}>
12
12
  Retry
13
13
  </button>
14
14
  </div>
@@ -10,6 +10,42 @@ export interface LMSErrorReporter {
10
10
  message(code: string): string;
11
11
  }
12
12
 
13
+ /** Default attempt count for LMS retry loops (one initial + two retries). */
14
+ export const RETRY_ATTEMPTS = 3;
15
+
16
+ /** Exponential backoff (0-indexed): 100, 200, 400, … ms. */
17
+ export function backoffMs(attempt: number): number {
18
+ return 100 * Math.pow(2, attempt);
19
+ }
20
+
21
+ // SCORM SetValue may return string "false" or boolean false; everything else is success.
22
+ function lmsCallSucceeded(result: unknown): boolean {
23
+ return result !== false && result !== 'false';
24
+ }
25
+
26
+ function readLastErrorCode(reporter: LMSErrorReporter | undefined): string {
27
+ if (!reporter) return '';
28
+ try { return reporter.code(); } catch { return ''; }
29
+ }
30
+
31
+ function logRetryGiveUp(
32
+ errorReporter: LMSErrorReporter | undefined,
33
+ lastErrCode: string,
34
+ context: string | undefined
35
+ ): 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
+ const ctx = context ? ` [${context}]` : '';
44
+ console.warn(
45
+ `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
46
+ );
47
+ }
48
+
13
49
  /**
14
50
  * Retry wrapper for LMS API calls.
15
51
  * Retries up to maxRetries times with exponential backoff.
@@ -27,36 +63,23 @@ export interface LMSErrorReporter {
27
63
  */
28
64
  export async function withRetry(
29
65
  fn: () => any,
30
- maxRetries = 3,
66
+ maxRetries = RETRY_ATTEMPTS,
31
67
  errorReporter?: LMSErrorReporter,
32
68
  context?: string
33
69
  ): Promise<boolean> {
34
70
  let lastErrCode = '';
35
71
  for (let attempt = 0; attempt < maxRetries; attempt++) {
36
72
  try {
37
- const result = fn();
38
- if (result !== false && result !== 'false') return true;
73
+ if (lmsCallSucceeded(fn())) return true;
39
74
  } catch {
40
75
  // API call threw — treat as failure
41
76
  }
42
- if (errorReporter) {
43
- try { lastErrCode = errorReporter.code(); } catch {}
44
- }
77
+ lastErrCode = readLastErrorCode(errorReporter);
45
78
  if (attempt < maxRetries - 1) {
46
- await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
79
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
47
80
  }
48
81
  }
49
- let detail = '';
50
- if (errorReporter && lastErrCode && lastErrCode !== '0') {
51
- try {
52
- const msg = errorReporter.message(lastErrCode);
53
- detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
54
- } catch {}
55
- }
56
- const ctx = context ? ` [${context}]` : '';
57
- console.warn(
58
- `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
59
- );
82
+ logRetryGiveUp(errorReporter, lastErrCode, context);
60
83
  return false;
61
84
  }
62
85
 
@@ -66,8 +89,7 @@ export async function withRetry(
66
89
  */
67
90
  export function callSync(fn: () => any): boolean {
68
91
  try {
69
- const result = fn();
70
- return result !== false && result !== 'false';
92
+ return lmsCallSucceeded(fn());
71
93
  } catch {
72
94
  return false;
73
95
  }
@@ -122,8 +144,6 @@ export class WriteQueue {
122
144
  this.#flushing = true;
123
145
  this.#aborted = false;
124
146
 
125
- const MAX_ATTEMPTS = 3;
126
-
127
147
  while (this.#queue.length > 0) {
128
148
  if (this.#aborted) {
129
149
  this.#flushing = false;
@@ -134,11 +154,10 @@ export class WriteQueue {
134
154
  let succeeded = false;
135
155
  let lastErrCode = '';
136
156
 
137
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
157
+ for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
138
158
  let ok = false;
139
159
  try {
140
- const result = entry.fn();
141
- ok = result !== false && result !== 'false';
160
+ ok = lmsCallSucceeded(entry.fn());
142
161
  } catch {
143
162
  // API call threw — treat as failure
144
163
  }
@@ -146,15 +165,13 @@ export class WriteQueue {
146
165
  succeeded = true;
147
166
  break;
148
167
  }
149
- if (this.errorReporter) {
150
- try { lastErrCode = this.errorReporter.code(); } catch {}
151
- }
152
- if (attempt < MAX_ATTEMPTS - 1) {
168
+ lastErrCode = readLastErrorCode(this.errorReporter);
169
+ if (attempt < RETRY_ATTEMPTS - 1) {
153
170
  // The next attempt is gated on a backoff timer that won't fire
154
171
  // during page unload. Mark in-flight so drainSync can re-run
155
172
  // the entry synchronously if it interrupts here.
156
173
  this.#inFlight = entry;
157
- await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
174
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
158
175
  this.#inFlight = null;
159
176
  if (this.#aborted) {
160
177
  this.#flushing = false;
@@ -164,17 +181,7 @@ export class WriteQueue {
164
181
  }
165
182
 
166
183
  if (!succeeded) {
167
- let detail = '';
168
- if (this.errorReporter && lastErrCode && lastErrCode !== '0') {
169
- try {
170
- const msg = this.errorReporter.message(lastErrCode);
171
- detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
172
- } catch {}
173
- }
174
- const ctx = entry.context ? ` [${entry.context}]` : '';
175
- console.warn(
176
- `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
177
- );
184
+ logRetryGiveUp(this.errorReporter, lastErrCode, entry.context);
178
185
  this.#queue.unshift(entry);
179
186
  this.#flushing = false;
180
187
  return;
@@ -0,0 +1,143 @@
1
+ import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
+ import type { Interaction } from '../interaction.js';
3
+ import { buildScormInteractionFields } from '../interaction-format.js';
4
+ import { WriteQueue, callSync, withRetry } from './retry.js';
5
+
6
+ /** Per-version differences shared between SCORM 1.2 and SCORM 2004 adapters. */
7
+ export interface ScormDialect<TApi> {
8
+ /** SCORM 1.2: `cmi.core.session_time`. SCORM 2004: `cmi.session_time`. */
9
+ sessionTimeKey: string;
10
+ /** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
11
+ formatDuration(seconds: number): string;
12
+ /** Per-interaction-row field config passed to `buildScormInteractionFields`. */
13
+ interactionFields: {
14
+ responseField: 'student_response' | 'learner_response';
15
+ timestampField: 'time' | 'timestamp';
16
+ /** Build the per-call timestamp string (HH:MM:SS for 1.2, ISO8601 for 2004). */
17
+ timestamp(): string;
18
+ typeValue(type: Interaction['type']): string;
19
+ resultLabels: { correct: string; incorrect: string };
20
+ };
21
+ /** API method wrappers — abstract over the `LMS*`-prefixed and bare names. */
22
+ initialize(api: TApi): string;
23
+ terminate(api: TApi): string;
24
+ getValue(api: TApi, key: string): string;
25
+ setValue(api: TApi, key: string, value: string): string;
26
+ commit(api: TApi): string;
27
+ getLastError(api: TApi): string;
28
+ getErrorString(api: TApi, code: string): string;
29
+ }
30
+
31
+ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
32
+ protected readonly api: TApi;
33
+ protected readonly dialect: ScormDialect<TApi>;
34
+ protected readonly queue = new WriteQueue();
35
+ #state: SavedState | null = null;
36
+ #terminated = false;
37
+ protected interactionCount = 0;
38
+
39
+ constructor(api: TApi, dialect: ScormDialect<TApi>) {
40
+ this.api = api;
41
+ this.dialect = dialect;
42
+ // Wire up GetLastError/GetErrorString so retry warnings can name the
43
+ // real LMS failure (e.g. "201 Invalid argument error") instead of a
44
+ // generic "LMS call failed" — production triage needs the code.
45
+ this.queue.errorReporter = {
46
+ code: () => this.dialect.getLastError(this.api),
47
+ message: (c) => this.dialect.getErrorString(this.api, c),
48
+ };
49
+ }
50
+
51
+ /** Expose the underlying SCORM API so xAPI actor synthesis can read learner fields. */
52
+ getAPI(): TApi {
53
+ return this.api;
54
+ }
55
+
56
+ async init(): Promise<void> {
57
+ await withRetry(() => this.dialect.initialize(this.api));
58
+
59
+ try {
60
+ const raw = this.dialect.getValue(this.api, 'cmi.suspend_data');
61
+ if (raw && raw.trim()) {
62
+ this.#state = JSON.parse(raw);
63
+ }
64
+ } catch {
65
+ this.#state = null;
66
+ }
67
+
68
+ // Continue cmi.interactions.n indexing where the previous session left
69
+ // off. Restarting at 0 would overwrite prior records (the LMS uses n
70
+ // as the array key, not an upsert field).
71
+ try {
72
+ const count = this.dialect.getValue(this.api, 'cmi.interactions._count');
73
+ const n = parseInt(count, 10);
74
+ if (Number.isFinite(n) && n >= 0) this.interactionCount = n;
75
+ } catch {
76
+ // Some LMSes throw on _count when no interactions exist — fall back to 0.
77
+ }
78
+ }
79
+
80
+ getState(): SavedState | null {
81
+ return this.#state;
82
+ }
83
+
84
+ saveState(state: SavedState): void {
85
+ this.#state = state;
86
+ const json = JSON.stringify(state);
87
+ this.queue.enqueue(() =>
88
+ this.dialect.setValue(this.api, 'cmi.suspend_data', json)
89
+ );
90
+ }
91
+
92
+ setDuration(seconds: number): void {
93
+ const formatted = this.dialect.formatDuration(seconds);
94
+ this.queue.enqueue(() =>
95
+ this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted)
96
+ );
97
+ }
98
+
99
+ reportInteraction(
100
+ questionId: string,
101
+ interaction: Interaction,
102
+ correct: boolean | null
103
+ ): void {
104
+ const n = this.interactionCount++;
105
+ const fields = buildScormInteractionFields(
106
+ `cmi.interactions.${n}`,
107
+ questionId,
108
+ interaction,
109
+ correct,
110
+ {
111
+ responseField: this.dialect.interactionFields.responseField,
112
+ timestampField: this.dialect.interactionFields.timestampField,
113
+ timestamp: this.dialect.interactionFields.timestamp(),
114
+ typeValue: this.dialect.interactionFields.typeValue(interaction.type),
115
+ resultLabels: this.dialect.interactionFields.resultLabels,
116
+ }
117
+ );
118
+ for (const [key, value] of fields) {
119
+ this.queue.enqueue(() => this.dialect.setValue(this.api, key, value));
120
+ }
121
+ }
122
+
123
+ commit(): void {
124
+ this.queue.enqueue(() => this.dialect.commit(this.api));
125
+ }
126
+
127
+ terminate(): void {
128
+ if (this.#terminated) return;
129
+ this.#terminated = true;
130
+ // During page unload, async retries can't run.
131
+ // Drain any pending queue operations synchronously (single attempt each),
132
+ // then commit and finish synchronously.
133
+ this.queue.drainSync();
134
+ callSync(() => this.dialect.commit(this.api));
135
+ callSync(() => this.dialect.terminate(this.api));
136
+ }
137
+
138
+ // The four operations that genuinely diverge between SCORM versions.
139
+ abstract setScore(score: number): void;
140
+ abstract setCompletionStatus(status: 'incomplete' | 'complete'): void;
141
+ abstract setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void;
142
+ abstract setExit(mode: 'suspend' | 'normal'): void;
143
+ }
@@ -1,7 +1,6 @@
1
- import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
- import type { Interaction } from '../interaction.js';
3
- import { buildScormInteractionFields, scorm12Type } from '../interaction-format.js';
4
- import { WriteQueue, callSync, withRetry, formatHHMMSS } from './retry.js';
1
+ import { scorm12Type } from '../interaction-format.js';
2
+ import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
3
+ import { formatHHMMSS } from './retry.js';
5
4
 
6
5
  /**
7
6
  * SCORM 1.2 API interface.
@@ -17,84 +16,51 @@ export interface SCORM12API {
17
16
  LMSGetDiagnostic(errorCode: string): string;
18
17
  }
19
18
 
19
+ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
20
+ sessionTimeKey: 'cmi.core.session_time',
21
+ formatDuration: formatHHMMSS,
22
+ interactionFields: {
23
+ responseField: 'student_response',
24
+ timestampField: 'time',
25
+ timestamp: () => new Date().toTimeString().slice(0, 8),
26
+ typeValue: (t) => scorm12Type(t),
27
+ resultLabels: { correct: 'correct', incorrect: 'wrong' },
28
+ },
29
+ initialize: (api) => api.LMSInitialize(''),
30
+ terminate: (api) => api.LMSFinish(''),
31
+ getValue: (api, key) => api.LMSGetValue(key),
32
+ setValue: (api, key, value) => api.LMSSetValue(key, value),
33
+ commit: (api) => api.LMSCommit(''),
34
+ getLastError: (api) => api.LMSGetLastError(),
35
+ getErrorString: (api, code) => api.LMSGetErrorString(code),
36
+ };
37
+
20
38
  /**
21
39
  * SCORM 1.2 persistence adapter.
22
40
  *
23
41
  * Uses a sequential write queue for all LMS SetValue/Commit calls.
24
42
  * On terminate, the queue is drained synchronously (single attempt)
25
43
  * since async retries cannot complete during page unload.
44
+ *
45
+ * SCORM 1.2 collapses completion + success into a single `lesson_status`
46
+ * field, so the two setters track their values separately and write the
47
+ * combined result through `#flushLessonStatus`.
26
48
  */
27
- export class SCORM12Adapter implements PersistenceAdapter {
28
- #api: SCORM12API;
29
- #queue = new WriteQueue();
30
- #state: SavedState | null = null;
31
- #terminated = false;
32
-
33
- // SCORM 1.2 combines completion and success into a single lesson_status field
49
+ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
50
+ // SCORM 1.2 combines completion and success into a single lesson_status field.
34
51
  #completionStatus: string = 'incomplete';
35
52
  #successStatus: string | null = null;
36
- #interactionCount = 0;
37
53
 
38
54
  constructor(api: SCORM12API) {
39
- this.#api = api;
40
- // Wire up GetLastError/GetErrorString so retry warnings can name the
41
- // real LMS failure (e.g. "201 Invalid argument error") instead of a
42
- // generic "LMS call failed" — production triage needs the code.
43
- this.#queue.errorReporter = {
44
- code: () => this.#api.LMSGetLastError(),
45
- message: (c) => this.#api.LMSGetErrorString(c),
46
- };
47
- }
48
-
49
- /** Expose the underlying SCORM 1.2 API so xAPI actor synthesis can read learner fields. */
50
- getAPI(): SCORM12API {
51
- return this.#api;
52
- }
53
-
54
- async init(): Promise<void> {
55
- await withRetry(() => this.#api.LMSInitialize(''));
56
-
57
- try {
58
- const raw = this.#api.LMSGetValue('cmi.suspend_data');
59
- if (raw && raw.trim()) {
60
- this.#state = JSON.parse(raw);
61
- }
62
- } catch {
63
- this.#state = null;
64
- }
65
-
66
- // Continue cmi.interactions.n indexing where the previous session left
67
- // off. Restarting at 0 would overwrite prior records (the LMS uses n
68
- // as the array key, not an upsert field).
69
- try {
70
- const count = this.#api.LMSGetValue('cmi.interactions._count');
71
- const n = parseInt(count, 10);
72
- if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
73
- } catch {
74
- // Some LMSes throw on _count when no interactions exist — fall back to 0.
75
- }
76
- }
77
-
78
- getState(): SavedState | null {
79
- return this.#state;
80
- }
81
-
82
- saveState(state: SavedState): void {
83
- this.#state = state;
84
- const json = JSON.stringify(state);
85
- this.#queue.enqueue(() => this.#api.LMSSetValue('cmi.suspend_data', json));
55
+ super(api, SCORM12_DIALECT);
86
56
  }
87
57
 
88
58
  setScore(score: number): void {
89
- this.#queue.enqueue(() =>
90
- this.#api.LMSSetValue('cmi.core.score.raw', String(score))
91
- );
92
- this.#queue.enqueue(() =>
93
- this.#api.LMSSetValue('cmi.core.score.min', '0')
94
- );
95
- this.#queue.enqueue(() =>
96
- this.#api.LMSSetValue('cmi.core.score.max', '100')
59
+ this.queue.enqueue(() =>
60
+ this.api.LMSSetValue('cmi.core.score.raw', String(score))
97
61
  );
62
+ this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.min', '0'));
63
+ this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.score.max', '100'));
98
64
  }
99
65
 
100
66
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
@@ -110,17 +76,10 @@ export class SCORM12Adapter implements PersistenceAdapter {
110
76
  }
111
77
 
112
78
  #flushLessonStatus(): void {
113
- // Success status takes priority — it's the more specific status
79
+ // Success status takes priority — it's the more specific status.
114
80
  const value = this.#successStatus ?? this.#completionStatus;
115
- this.#queue.enqueue(() =>
116
- this.#api.LMSSetValue('cmi.core.lesson_status', value)
117
- );
118
- }
119
-
120
- setDuration(seconds: number): void {
121
- const formatted = formatHHMMSS(seconds);
122
- this.#queue.enqueue(() =>
123
- this.#api.LMSSetValue('cmi.core.session_time', formatted)
81
+ this.queue.enqueue(() =>
82
+ this.api.LMSSetValue('cmi.core.lesson_status', value)
124
83
  );
125
84
  }
126
85
 
@@ -128,45 +87,6 @@ export class SCORM12Adapter implements PersistenceAdapter {
128
87
  // SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
129
88
  // We only map 'suspend' and the empty/normal case.
130
89
  const value = mode === 'suspend' ? 'suspend' : '';
131
- this.#queue.enqueue(() => this.#api.LMSSetValue('cmi.core.exit', value));
132
- }
133
-
134
- reportInteraction(
135
- questionId: string,
136
- interaction: Interaction,
137
- correct: boolean | null
138
- ): void {
139
- const n = this.#interactionCount++;
140
- const fields = buildScormInteractionFields(
141
- `cmi.interactions.${n}`,
142
- questionId,
143
- interaction,
144
- correct,
145
- {
146
- responseField: 'student_response',
147
- timestampField: 'time',
148
- timestamp: new Date().toTimeString().slice(0, 8),
149
- typeValue: scorm12Type(interaction.type),
150
- resultLabels: { correct: 'correct', incorrect: 'wrong' },
151
- }
152
- );
153
- for (const [key, value] of fields) {
154
- this.#queue.enqueue(() => this.#api.LMSSetValue(key, value));
155
- }
156
- }
157
-
158
- commit(): void {
159
- this.#queue.enqueue(() => this.#api.LMSCommit(''));
160
- }
161
-
162
- terminate(): void {
163
- if (this.#terminated) return;
164
- this.#terminated = true;
165
- // During page unload, async retries can't run.
166
- // Drain any pending queue operations synchronously (single attempt each),
167
- // then commit and finish synchronously.
168
- this.#queue.drainSync();
169
- callSync(() => this.#api.LMSCommit(''));
170
- callSync(() => this.#api.LMSFinish(''));
90
+ this.queue.enqueue(() => this.api.LMSSetValue('cmi.core.exit', value));
171
91
  }
172
92
  }