tessera-learn 0.0.10 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +6 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +22 -5
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +25 -20
- package/src/components/util.ts +4 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-BxWAMMnJ.js.map +0 -1
|
@@ -24,29 +24,37 @@ function lmsCallSucceeded(result: unknown): boolean {
|
|
|
24
24
|
|
|
25
25
|
function readLastErrorCode(reporter: LMSErrorReporter | undefined): string {
|
|
26
26
|
if (!reporter) return '';
|
|
27
|
-
try {
|
|
27
|
+
try {
|
|
28
|
+
return reporter.code();
|
|
29
|
+
} catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function logRetryGiveUp(
|
|
31
35
|
errorReporter: LMSErrorReporter | undefined,
|
|
32
36
|
lastErrCode: string,
|
|
33
|
-
context: string | undefined
|
|
37
|
+
context: string | undefined,
|
|
34
38
|
): void {
|
|
35
39
|
const ctx = context ? ` [${context}]` : '';
|
|
36
40
|
console.warn(
|
|
37
|
-
`Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence
|
|
41
|
+
`Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`,
|
|
38
42
|
);
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function formatLMSErrorDetail(
|
|
42
46
|
errorReporter: LMSErrorReporter | undefined,
|
|
43
|
-
code: string
|
|
47
|
+
code: string,
|
|
44
48
|
): string {
|
|
45
49
|
if (!errorReporter || !code || code === '0') return '';
|
|
46
50
|
let msg = '';
|
|
47
51
|
let diag = '';
|
|
48
|
-
try {
|
|
49
|
-
|
|
52
|
+
try {
|
|
53
|
+
msg = errorReporter.message(code);
|
|
54
|
+
} catch {}
|
|
55
|
+
try {
|
|
56
|
+
diag = errorReporter.diagnostic?.(code) ?? '';
|
|
57
|
+
} catch {}
|
|
50
58
|
let detail = ` (LMS error ${code}`;
|
|
51
59
|
if (msg) detail += `: ${msg}`;
|
|
52
60
|
if (diag && diag !== msg) detail += ` — ${diag}`;
|
|
@@ -56,24 +64,21 @@ export function formatLMSErrorDetail(
|
|
|
56
64
|
|
|
57
65
|
/** Sync call that warns with the LMS error code on failure (terminate-path). */
|
|
58
66
|
export function callSyncOrWarn(
|
|
59
|
-
fn: () =>
|
|
67
|
+
fn: () => unknown,
|
|
60
68
|
context: string,
|
|
61
|
-
errorReporter?: LMSErrorReporter
|
|
69
|
+
errorReporter?: LMSErrorReporter,
|
|
62
70
|
): boolean {
|
|
63
|
-
let ok
|
|
71
|
+
let ok: boolean;
|
|
64
72
|
try {
|
|
65
73
|
ok = lmsCallSucceeded(fn());
|
|
66
74
|
} catch (err) {
|
|
67
|
-
console.warn(
|
|
68
|
-
`Tessera: LMS call threw [${context}] during terminate`,
|
|
69
|
-
err
|
|
70
|
-
);
|
|
75
|
+
console.warn(`Tessera: LMS call threw [${context}] during terminate`, err);
|
|
71
76
|
return false;
|
|
72
77
|
}
|
|
73
78
|
if (!ok) {
|
|
74
79
|
const code = readLastErrorCode(errorReporter);
|
|
75
80
|
console.warn(
|
|
76
|
-
`Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}
|
|
81
|
+
`Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`,
|
|
77
82
|
);
|
|
78
83
|
}
|
|
79
84
|
return ok;
|
|
@@ -95,10 +100,10 @@ export function callSyncOrWarn(
|
|
|
95
100
|
* for SCORM adapters where the underlying API calls are synchronous.
|
|
96
101
|
*/
|
|
97
102
|
export async function withRetry(
|
|
98
|
-
fn: () =>
|
|
103
|
+
fn: () => unknown,
|
|
99
104
|
maxRetries = RETRY_ATTEMPTS,
|
|
100
105
|
errorReporter?: LMSErrorReporter,
|
|
101
|
-
context?: string
|
|
106
|
+
context?: string,
|
|
102
107
|
): Promise<boolean> {
|
|
103
108
|
let lastErrCode = '';
|
|
104
109
|
let threw = false;
|
|
@@ -128,7 +133,7 @@ export async function withRetry(
|
|
|
128
133
|
* Synchronous single-attempt LMS call. Used during page unload
|
|
129
134
|
* where async retries cannot run.
|
|
130
135
|
*/
|
|
131
|
-
export function callSync(fn: () =>
|
|
136
|
+
export function callSync(fn: () => unknown): boolean {
|
|
132
137
|
try {
|
|
133
138
|
return lmsCallSucceeded(fn());
|
|
134
139
|
} catch {
|
|
@@ -143,7 +148,7 @@ export function callSync(fn: () => any): boolean {
|
|
|
143
148
|
* the failed operation on the next flush trigger.
|
|
144
149
|
*/
|
|
145
150
|
interface QueueEntry {
|
|
146
|
-
fn: () =>
|
|
151
|
+
fn: () => unknown;
|
|
147
152
|
context?: string;
|
|
148
153
|
}
|
|
149
154
|
|
|
@@ -164,10 +169,10 @@ export class WriteQueue {
|
|
|
164
169
|
/**
|
|
165
170
|
* Enqueue an operation and trigger a flush.
|
|
166
171
|
*/
|
|
167
|
-
enqueue(fn: () =>
|
|
172
|
+
enqueue(fn: () => unknown, context?: string): void {
|
|
168
173
|
this.#queue.push({ fn, context });
|
|
169
174
|
if (!this.#flushing) {
|
|
170
|
-
this.#flush();
|
|
175
|
+
void this.#flush();
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
|
|
@@ -296,67 +301,3 @@ export function findLMSAPI(propName: string): unknown {
|
|
|
296
301
|
// Check window.parent chain (iframe launch pattern)
|
|
297
302
|
return scan(window);
|
|
298
303
|
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
|
|
302
|
-
*
|
|
303
|
-
* `DurationTracker.sessionSeconds` always feeds integer seconds via
|
|
304
|
-
* `Math.floor`, so the centisecond field is always `.00`. The format
|
|
305
|
-
* still includes it because `CMITimespan` is defined that way and some
|
|
306
|
-
* older LMS importers reject the bare HHHH:MM:SS form.
|
|
307
|
-
*/
|
|
308
|
-
export function formatHHMMSS(totalSeconds: number): string {
|
|
309
|
-
const whole = Math.floor(totalSeconds);
|
|
310
|
-
const hours = Math.floor(whole / 3600);
|
|
311
|
-
const minutes = Math.floor((whole % 3600) / 60);
|
|
312
|
-
const seconds = whole % 60;
|
|
313
|
-
const hh = String(hours).padStart(4, '0');
|
|
314
|
-
const mm = String(minutes).padStart(2, '0');
|
|
315
|
-
const ss = String(seconds).padStart(2, '0');
|
|
316
|
-
return `${hh}:${mm}:${ss}.00`;
|
|
317
|
-
}
|
|
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
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Format seconds as ISO 8601 duration: PT1H30M45S
|
|
351
|
-
*/
|
|
352
|
-
export function formatISO8601Duration(totalSeconds: number): string {
|
|
353
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
354
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
355
|
-
const seconds = totalSeconds % 60;
|
|
356
|
-
|
|
357
|
-
let result = 'PT';
|
|
358
|
-
if (hours > 0) result += `${hours}H`;
|
|
359
|
-
if (minutes > 0) result += `${minutes}M`;
|
|
360
|
-
if (seconds > 0 || result === 'PT') result += `${seconds}S`;
|
|
361
|
-
return result;
|
|
362
|
-
}
|
|
@@ -71,11 +71,11 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
71
71
|
() => this.dialect.initialize(this.api),
|
|
72
72
|
undefined,
|
|
73
73
|
this.errorReporter,
|
|
74
|
-
'Initialize'
|
|
74
|
+
'Initialize',
|
|
75
75
|
);
|
|
76
76
|
if (!initialized) {
|
|
77
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.'
|
|
78
|
+
'Tessera: LMS Initialize failed — all subsequent persistence calls will fail with error 301 (Not Initialized). Reload the launch from the LMS.',
|
|
79
79
|
);
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
@@ -86,7 +86,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
86
86
|
} catch (err) {
|
|
87
87
|
console.warn(
|
|
88
88
|
'Tessera: LMS threw on GetValue(cmi.suspend_data); resume disabled for this launch',
|
|
89
|
-
err
|
|
89
|
+
err,
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
if (raw && raw.trim()) {
|
|
@@ -95,7 +95,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
95
95
|
} catch (err) {
|
|
96
96
|
console.warn(
|
|
97
97
|
'Tessera: cmi.suspend_data is not valid JSON; resume disabled for this launch (the LMS may have truncated a prior write)',
|
|
98
|
-
err
|
|
98
|
+
err,
|
|
99
99
|
);
|
|
100
100
|
this.#state = null;
|
|
101
101
|
}
|
|
@@ -103,13 +103,13 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
103
103
|
|
|
104
104
|
// n indexing must continue from _count — restarting at 0 would overwrite
|
|
105
105
|
// the prior session's records (the LMS uses n as the array key).
|
|
106
|
-
let countRaw
|
|
106
|
+
let countRaw: string;
|
|
107
107
|
try {
|
|
108
108
|
countRaw = this.dialect.getValue(this.api, 'cmi.interactions._count');
|
|
109
109
|
} catch (err) {
|
|
110
110
|
console.warn(
|
|
111
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
|
|
112
|
+
err,
|
|
113
113
|
);
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
@@ -119,7 +119,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
119
119
|
this.interactionCount = n;
|
|
120
120
|
} else {
|
|
121
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
|
|
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
123
|
);
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -141,12 +141,12 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
141
141
|
`${this.dialect.suspendDataLimitLabel} limit. The LMS will likely ` +
|
|
142
142
|
`truncate it and the next resume will lose state. Reduce ` +
|
|
143
143
|
`usePersistence() payloads or switch export.standard to a ` +
|
|
144
|
-
`larger-limit standard (scorm2004/cmi5)
|
|
144
|
+
`larger-limit standard (scorm2004/cmi5).`,
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
this.queue.enqueue(
|
|
148
148
|
() => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
|
|
149
|
-
'cmi.suspend_data'
|
|
149
|
+
'cmi.suspend_data',
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -155,14 +155,14 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
155
155
|
this.queue.enqueue(
|
|
156
156
|
() =>
|
|
157
157
|
this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
|
|
158
|
-
this.dialect.sessionTimeKey
|
|
158
|
+
this.dialect.sessionTimeKey,
|
|
159
159
|
);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
reportInteraction(
|
|
163
163
|
questionId: string,
|
|
164
164
|
interaction: Interaction,
|
|
165
|
-
correct: boolean | null
|
|
165
|
+
correct: boolean | null,
|
|
166
166
|
): void {
|
|
167
167
|
const n = this.interactionCount++;
|
|
168
168
|
const fields = buildScormInteractionFields(
|
|
@@ -177,12 +177,12 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
177
177
|
typeValue: this.dialect.interactionFields.typeValue(interaction.type),
|
|
178
178
|
resultLabels: this.dialect.interactionFields.resultLabels,
|
|
179
179
|
format: this.dialect.interactionFields.format,
|
|
180
|
-
}
|
|
180
|
+
},
|
|
181
181
|
);
|
|
182
182
|
for (const [key, value] of fields) {
|
|
183
183
|
this.queue.enqueue(
|
|
184
184
|
() => this.dialect.setValue(this.api, key, value),
|
|
185
|
-
key
|
|
185
|
+
key,
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
}
|
|
@@ -196,11 +196,15 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
196
196
|
this.#terminated = true;
|
|
197
197
|
// Async retries can't run during page unload — drain + commit + finish synchronously.
|
|
198
198
|
this.queue.drainSync();
|
|
199
|
-
callSyncOrWarn(
|
|
199
|
+
callSyncOrWarn(
|
|
200
|
+
() => this.dialect.commit(this.api),
|
|
201
|
+
'Commit',
|
|
202
|
+
this.errorReporter,
|
|
203
|
+
);
|
|
200
204
|
callSyncOrWarn(
|
|
201
205
|
() => this.dialect.terminate(this.api),
|
|
202
206
|
'Terminate',
|
|
203
|
-
this.errorReporter
|
|
207
|
+
this.errorReporter,
|
|
204
208
|
);
|
|
205
209
|
}
|
|
206
210
|
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from '../interaction-format.js';
|
|
5
5
|
import type { SavedState } from '../persistence.js';
|
|
6
6
|
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
7
|
-
import { formatHHMMSS, formatReal107 } from './
|
|
7
|
+
import { formatHHMMSS, formatReal107 } from './format.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* SCORM 1.2 API interface.
|
|
@@ -67,24 +67,23 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
67
67
|
super.saveState(state);
|
|
68
68
|
// §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
|
|
69
69
|
this.queue.enqueue(
|
|
70
|
-
() =>
|
|
71
|
-
|
|
72
|
-
'cmi.core.lesson_location'
|
|
70
|
+
() => this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
|
|
71
|
+
'cmi.core.lesson_location',
|
|
73
72
|
);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
setScore(score: number): void {
|
|
77
76
|
this.queue.enqueue(
|
|
78
77
|
() => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
|
|
79
|
-
'cmi.core.score.raw'
|
|
78
|
+
'cmi.core.score.raw',
|
|
80
79
|
);
|
|
81
80
|
this.queue.enqueue(
|
|
82
81
|
() => this.api.LMSSetValue('cmi.core.score.min', '0'),
|
|
83
|
-
'cmi.core.score.min'
|
|
82
|
+
'cmi.core.score.min',
|
|
84
83
|
);
|
|
85
84
|
this.queue.enqueue(
|
|
86
85
|
() => this.api.LMSSetValue('cmi.core.score.max', '100'),
|
|
87
|
-
'cmi.core.score.max'
|
|
86
|
+
'cmi.core.score.max',
|
|
88
87
|
);
|
|
89
88
|
}
|
|
90
89
|
|
|
@@ -104,7 +103,7 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
104
103
|
const value = this.#successStatus ?? this.#completionStatus;
|
|
105
104
|
this.queue.enqueue(
|
|
106
105
|
() => this.api.LMSSetValue('cmi.core.lesson_status', value),
|
|
107
|
-
'cmi.core.lesson_status'
|
|
106
|
+
'cmi.core.lesson_status',
|
|
108
107
|
);
|
|
109
108
|
}
|
|
110
109
|
|
|
@@ -113,7 +112,7 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
113
112
|
const value = mode === 'suspend' ? 'suspend' : '';
|
|
114
113
|
this.queue.enqueue(
|
|
115
114
|
() => this.api.LMSSetValue('cmi.core.exit', value),
|
|
116
|
-
'cmi.core.exit'
|
|
115
|
+
'cmi.core.exit',
|
|
117
116
|
);
|
|
118
117
|
}
|
|
119
118
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
formatISO8601Duration,
|
|
6
6
|
formatISO8601Timestamp,
|
|
7
7
|
formatReal107,
|
|
8
|
-
} from './
|
|
8
|
+
} from './format.js';
|
|
9
9
|
|
|
10
10
|
export interface SCORM2004API {
|
|
11
11
|
Initialize(param: string): string;
|
|
@@ -48,13 +48,11 @@ export type SCORM2004Mode = 'browse' | 'normal' | 'review';
|
|
|
48
48
|
/**
|
|
49
49
|
* Per §4.2.1.5, the SCO MUST NOT alter the learner record in `browse` or
|
|
50
50
|
* `review` mode — every write below is gated on `#mode === 'normal'`.
|
|
51
|
-
* `#masteryScore` (§4.2.4.3)
|
|
52
|
-
* LMS-supplied thresholds in [0,1].
|
|
51
|
+
* `#masteryScore` (§4.2.4.3) is the LMS-supplied pass threshold in [0,1].
|
|
53
52
|
*/
|
|
54
53
|
export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
55
54
|
#mode: SCORM2004Mode = 'normal';
|
|
56
55
|
#masteryScore: number | null = null;
|
|
57
|
-
#completionThreshold: number | null = null;
|
|
58
56
|
|
|
59
57
|
constructor(api: SCORM2004API) {
|
|
60
58
|
super(api, SCORM2004_DIALECT);
|
|
@@ -64,9 +62,6 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
64
62
|
await super.init();
|
|
65
63
|
this.#mode = this.#readMode();
|
|
66
64
|
this.#masteryScore = this.#readScaledThreshold('cmi.scaled_passing_score');
|
|
67
|
-
this.#completionThreshold = this.#readScaledThreshold(
|
|
68
|
-
'cmi.completion_threshold'
|
|
69
|
-
);
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
getLaunchMode(): SCORM2004Mode {
|
|
@@ -78,8 +73,8 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
78
73
|
return this.#masteryScore;
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
|
|
82
|
-
return this.#
|
|
76
|
+
get #canWrite(): boolean {
|
|
77
|
+
return this.#mode === 'normal';
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
#readMode(): SCORM2004Mode {
|
|
@@ -91,7 +86,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
#readScaledThreshold(key: string): number | null {
|
|
94
|
-
let raw
|
|
89
|
+
let raw: string;
|
|
95
90
|
try {
|
|
96
91
|
raw = this.api.GetValue(key);
|
|
97
92
|
} catch {
|
|
@@ -104,83 +99,80 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
104
99
|
}
|
|
105
100
|
|
|
106
101
|
saveState(state: SavedState): void {
|
|
107
|
-
if (this.#
|
|
102
|
+
if (!this.#canWrite) return;
|
|
108
103
|
super.saveState(state);
|
|
109
104
|
// §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
|
|
110
105
|
this.queue.enqueue(
|
|
111
106
|
() => this.api.SetValue('cmi.location', String(state.b)),
|
|
112
|
-
'cmi.location'
|
|
107
|
+
'cmi.location',
|
|
113
108
|
);
|
|
114
109
|
}
|
|
115
110
|
|
|
116
111
|
setDuration(seconds: number): void {
|
|
117
|
-
if (this.#
|
|
112
|
+
if (!this.#canWrite) return;
|
|
118
113
|
super.setDuration(seconds);
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
reportInteraction(
|
|
122
117
|
questionId: string,
|
|
123
118
|
interaction: import('../interaction.js').Interaction,
|
|
124
|
-
correct: boolean | null
|
|
119
|
+
correct: boolean | null,
|
|
125
120
|
): void {
|
|
126
|
-
if (this.#
|
|
121
|
+
if (!this.#canWrite) return;
|
|
127
122
|
super.reportInteraction(questionId, interaction, correct);
|
|
128
123
|
}
|
|
129
124
|
|
|
130
125
|
setScore(score: number): void {
|
|
131
|
-
if (this.#
|
|
126
|
+
if (!this.#canWrite) return;
|
|
132
127
|
const raw = formatReal107(score);
|
|
133
128
|
// §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
|
|
134
129
|
const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
|
|
135
130
|
this.queue.enqueue(
|
|
136
131
|
() => this.api.SetValue('cmi.score.raw', raw),
|
|
137
|
-
'cmi.score.raw'
|
|
132
|
+
'cmi.score.raw',
|
|
138
133
|
);
|
|
139
134
|
this.queue.enqueue(
|
|
140
135
|
() => this.api.SetValue('cmi.score.min', '0'),
|
|
141
|
-
'cmi.score.min'
|
|
136
|
+
'cmi.score.min',
|
|
142
137
|
);
|
|
143
138
|
this.queue.enqueue(
|
|
144
139
|
() => this.api.SetValue('cmi.score.max', '100'),
|
|
145
|
-
'cmi.score.max'
|
|
140
|
+
'cmi.score.max',
|
|
146
141
|
);
|
|
147
142
|
this.queue.enqueue(
|
|
148
143
|
() => this.api.SetValue('cmi.score.scaled', scaled),
|
|
149
|
-
'cmi.score.scaled'
|
|
144
|
+
'cmi.score.scaled',
|
|
150
145
|
);
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
154
|
-
if (this.#
|
|
149
|
+
if (!this.#canWrite) return;
|
|
155
150
|
const value = status === 'complete' ? 'completed' : 'incomplete';
|
|
156
151
|
this.queue.enqueue(
|
|
157
152
|
() => this.api.SetValue('cmi.completion_status', value),
|
|
158
|
-
'cmi.completion_status'
|
|
153
|
+
'cmi.completion_status',
|
|
159
154
|
);
|
|
160
155
|
// §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
|
|
161
156
|
if (status === 'complete') {
|
|
162
157
|
this.queue.enqueue(
|
|
163
158
|
() => this.api.SetValue('cmi.progress_measure', '1'),
|
|
164
|
-
'cmi.progress_measure'
|
|
159
|
+
'cmi.progress_measure',
|
|
165
160
|
);
|
|
166
161
|
}
|
|
167
162
|
}
|
|
168
163
|
|
|
169
164
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
170
|
-
if (this.#
|
|
165
|
+
if (!this.#canWrite) return;
|
|
171
166
|
// Setting "unknown" explicitly prevents SCORM Cloud from rolling up
|
|
172
167
|
// a null status to "passed".
|
|
173
168
|
this.queue.enqueue(
|
|
174
169
|
() => this.api.SetValue('cmi.success_status', status),
|
|
175
|
-
'cmi.success_status'
|
|
170
|
+
'cmi.success_status',
|
|
176
171
|
);
|
|
177
172
|
}
|
|
178
173
|
|
|
179
174
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
180
|
-
if (this.#
|
|
181
|
-
this.queue.enqueue(
|
|
182
|
-
() => this.api.SetValue('cmi.exit', mode),
|
|
183
|
-
'cmi.exit'
|
|
184
|
-
);
|
|
175
|
+
if (!this.#canWrite) return;
|
|
176
|
+
this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
|
|
185
177
|
}
|
|
186
178
|
}
|
|
@@ -51,7 +51,7 @@ export class WebAdapter implements PersistenceAdapter {
|
|
|
51
51
|
reportInteraction(
|
|
52
52
|
_questionId: string,
|
|
53
53
|
_interaction: Interaction,
|
|
54
|
-
_correct: boolean | null
|
|
54
|
+
_correct: boolean | null,
|
|
55
55
|
): void {
|
|
56
56
|
// Web adapter has no external LMS; learner interaction data lives only
|
|
57
57
|
// in memory. Authors who want to persist per-question state can use
|