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.
- package/AGENTS.md +17 -3
- package/dist/plugin/index.js +7 -3
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugin/export.ts +12 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tessera-learn",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web), your choice of components.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
package/src/plugin/export.ts
CHANGED
|
@@ -77,6 +77,8 @@ interface ScormManifestDialect {
|
|
|
77
77
|
schemaversion: string;
|
|
78
78
|
/** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */
|
|
79
79
|
scormTypeAttr: 'scormtype' | 'scormType';
|
|
80
|
+
/** Whitespace-separated namespace+XSD pairs for xsi:schemaLocation. */
|
|
81
|
+
schemaLocation: string;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
const SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {
|
|
@@ -85,12 +87,19 @@ const SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {
|
|
|
85
87
|
adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',
|
|
86
88
|
schemaversion: '1.2',
|
|
87
89
|
scormTypeAttr: 'scormtype',
|
|
90
|
+
schemaLocation:
|
|
91
|
+
'http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd ' +
|
|
92
|
+
'http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd ' +
|
|
93
|
+
'http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd',
|
|
88
94
|
},
|
|
89
95
|
'2004': {
|
|
90
96
|
rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',
|
|
91
97
|
adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',
|
|
92
98
|
schemaversion: '2004 4th Edition',
|
|
93
99
|
scormTypeAttr: 'scormType',
|
|
100
|
+
schemaLocation:
|
|
101
|
+
'http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd ' +
|
|
102
|
+
'http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd',
|
|
94
103
|
},
|
|
95
104
|
};
|
|
96
105
|
|
|
@@ -109,7 +118,9 @@ export function generateScormManifest(
|
|
|
109
118
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
110
119
|
<manifest identifier="tessera-course" version="1.0"
|
|
111
120
|
xmlns="${dialect.rootNs}"
|
|
112
|
-
xmlns:adlcp="${dialect.adlcpNs}"
|
|
121
|
+
xmlns:adlcp="${dialect.adlcpNs}"
|
|
122
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
123
|
+
xsi:schemaLocation="${dialect.schemaLocation}">
|
|
113
124
|
<metadata>
|
|
114
125
|
<schema>ADL SCORM</schema>
|
|
115
126
|
<schemaversion>${dialect.schemaversion}</schemaversion>
|
|
@@ -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
|
}
|