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.
- package/AGENTS.md +35 -11
- package/dist/plugin/index.js +10 -5
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugin/export.ts +18 -3
- package/src/runtime/adapters/cmi5.ts +371 -94
- package/src/runtime/adapters/retry.ts +86 -15
- package/src/runtime/adapters/scorm-base.ts +90 -46
- package/src/runtime/adapters/scorm12.ts +36 -11
- package/src/runtime/adapters/scorm2004.ts +129 -26
- package/src/runtime/interaction-format.ts +83 -48
- package/src/runtime/xapi/publisher.ts +24 -5
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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}${
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
159
|
-
|
|
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 {
|
|
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',
|
|
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(
|
|
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 {
|
|
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()
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
54
|
-
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
this.queue.enqueue(
|
|
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
|
}
|