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
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
formatCorrectPattern,
|
|
6
6
|
XAPI_INTERACTION_FORMAT,
|
|
7
7
|
} from '../interaction-format.js';
|
|
8
|
-
import { formatISO8601Duration } from './
|
|
8
|
+
import { formatISO8601Duration } from './format.js';
|
|
9
9
|
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
10
10
|
import { X_API_VERSION } from '../xapi/version.js';
|
|
11
11
|
import type { XAPIAgent } from '../xapi/types.js';
|
|
@@ -26,7 +26,8 @@ const VERBS = {
|
|
|
26
26
|
// registration state when Terminated lands without Completed.
|
|
27
27
|
} as const;
|
|
28
28
|
|
|
29
|
-
const CMI_INTERACTION_TYPE =
|
|
29
|
+
const CMI_INTERACTION_TYPE =
|
|
30
|
+
'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
30
31
|
|
|
31
32
|
const CMI5_MASTERYSCORE_EXT =
|
|
32
33
|
'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
|
|
@@ -36,26 +37,10 @@ const CMI5_MASTERYSCORE_EXT =
|
|
|
36
37
|
// "failed" MUST additionally carry the "moveOn" Category. Without these, an
|
|
37
38
|
// LRS will accept the statement as an arbitrary xAPI verb but won't roll it
|
|
38
39
|
// up into cmi5 lifecycle state — the LMS never sees the AU as completed.
|
|
39
|
-
const CMI5_CATEGORY_CMI5 =
|
|
40
|
-
'https://w3id.org/xapi/cmi5/context/categories/cmi5';
|
|
40
|
+
const CMI5_CATEGORY_CMI5 = 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
|
|
41
41
|
const CMI5_CATEGORY_MOVEON =
|
|
42
42
|
'https://w3id.org/xapi/cmi5/context/categories/moveon';
|
|
43
43
|
|
|
44
|
-
export type CMI5MoveOn =
|
|
45
|
-
| 'Passed'
|
|
46
|
-
| 'Completed'
|
|
47
|
-
| 'CompletedAndPassed'
|
|
48
|
-
| 'CompletedOrPassed'
|
|
49
|
-
| 'NotApplicable';
|
|
50
|
-
|
|
51
|
-
const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
|
|
52
|
-
'Passed',
|
|
53
|
-
'Completed',
|
|
54
|
-
'CompletedAndPassed',
|
|
55
|
-
'CompletedOrPassed',
|
|
56
|
-
'NotApplicable',
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
44
|
/** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
|
|
60
45
|
export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
|
|
61
46
|
const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
|
|
@@ -87,31 +72,24 @@ interface CMI5LaunchData {
|
|
|
87
72
|
};
|
|
88
73
|
launchMode?: CMI5LaunchMode;
|
|
89
74
|
launchMethod?: 'OwnWindow' | 'AnyWindow';
|
|
90
|
-
launchParameters?: string;
|
|
91
75
|
returnURL?: string;
|
|
92
76
|
masteryScore?: number;
|
|
93
|
-
moveOn?: CMI5MoveOn;
|
|
94
77
|
entitlementKey?: Record<string, string>;
|
|
95
78
|
[k: string]: unknown;
|
|
96
79
|
}
|
|
97
80
|
|
|
98
|
-
/** cmi5 §11.1 Learner Preferences Agent Profile document. */
|
|
99
|
-
interface CMI5LearnerPreferences {
|
|
100
|
-
languagePreference?: string;
|
|
101
|
-
audioPreference?: 'on' | 'off';
|
|
102
|
-
[k: string]: unknown;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
81
|
/** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
|
|
106
82
|
function warnOnLRSReject(
|
|
107
|
-
verbName: string
|
|
108
|
-
): (res: {
|
|
83
|
+
verbName: string,
|
|
84
|
+
): (res: {
|
|
85
|
+
destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
|
|
86
|
+
}) => void {
|
|
109
87
|
return (res) => {
|
|
110
88
|
const dest = res.destinations?.[0];
|
|
111
89
|
if (dest && !dest.ok) {
|
|
112
90
|
console.warn(
|
|
113
91
|
`Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
|
|
114
|
-
dest.error
|
|
92
|
+
dest.error,
|
|
115
93
|
);
|
|
116
94
|
}
|
|
117
95
|
};
|
|
@@ -148,10 +126,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
148
126
|
#terminated = false;
|
|
149
127
|
|
|
150
128
|
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
151
|
-
// course's manifest passingScore for this launch — the LMS is the
|
|
152
|
-
// authority. moveOn drives the optional Satisfied statement (§9.5.3).
|
|
129
|
+
// course's manifest passingScore for this launch — the LMS is the authority.
|
|
153
130
|
#masteryScore: number | null = null;
|
|
154
|
-
#moveOn: CMI5MoveOn = 'NotApplicable';
|
|
155
131
|
|
|
156
132
|
// cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
|
|
157
133
|
// (§9.6.2) — Publisher Activity and session id live there, and strict
|
|
@@ -161,10 +137,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
161
137
|
#launchMode: CMI5LaunchMode = 'Normal';
|
|
162
138
|
/** cmi5 §10.2.6 — AU redirects here on `exit()`. */
|
|
163
139
|
#returnURL: string | undefined;
|
|
164
|
-
/** cmi5 §10.2.3 — opaque per-launch content config string. */
|
|
165
|
-
#launchParameters: string | undefined;
|
|
166
|
-
/** cmi5 §11.1 Learner Preferences. */
|
|
167
|
-
#learnerPreferences: CMI5LearnerPreferences | null = null;
|
|
168
140
|
|
|
169
141
|
async init(): Promise<void> {
|
|
170
142
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -184,18 +156,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
184
156
|
this.#masteryScore = m;
|
|
185
157
|
} else {
|
|
186
158
|
console.warn(
|
|
187
|
-
`Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const rawMoveOn = params.get('moveOn');
|
|
193
|
-
if (rawMoveOn !== null && rawMoveOn !== '') {
|
|
194
|
-
if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
|
|
195
|
-
this.#moveOn = rawMoveOn as CMI5MoveOn;
|
|
196
|
-
} else {
|
|
197
|
-
console.warn(
|
|
198
|
-
`Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
|
|
159
|
+
`Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`,
|
|
199
160
|
);
|
|
200
161
|
}
|
|
201
162
|
}
|
|
@@ -212,7 +173,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
212
173
|
this.#actor = parsed as XAPIAgent;
|
|
213
174
|
} catch (err) {
|
|
214
175
|
throw new Error(
|
|
215
|
-
`Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON
|
|
176
|
+
`Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
|
|
177
|
+
{ cause: err },
|
|
216
178
|
);
|
|
217
179
|
}
|
|
218
180
|
|
|
@@ -221,7 +183,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
221
183
|
// Fail loud at launch instead of dribbling errors per statement.
|
|
222
184
|
if (!fetchUrl) {
|
|
223
185
|
throw new Error(
|
|
224
|
-
"Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token."
|
|
186
|
+
"Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token.",
|
|
225
187
|
);
|
|
226
188
|
}
|
|
227
189
|
let resp: Response;
|
|
@@ -229,12 +191,13 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
229
191
|
resp = await fetch(fetchUrl, { method: 'POST' });
|
|
230
192
|
} catch (err) {
|
|
231
193
|
throw new Error(
|
|
232
|
-
`Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry
|
|
194
|
+
`Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
|
|
195
|
+
{ cause: err },
|
|
233
196
|
);
|
|
234
197
|
}
|
|
235
198
|
if (!resp.ok) {
|
|
236
199
|
throw new Error(
|
|
237
|
-
`Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry
|
|
200
|
+
`Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
|
|
238
201
|
);
|
|
239
202
|
}
|
|
240
203
|
const text = (await resp.text()).trim();
|
|
@@ -255,15 +218,21 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
255
218
|
if (typeof obj['auth-token'] === 'string') {
|
|
256
219
|
token = (obj['auth-token'] as string).trim();
|
|
257
220
|
} else {
|
|
258
|
-
const code =
|
|
259
|
-
|
|
221
|
+
const code =
|
|
222
|
+
typeof obj['error-code'] === 'string'
|
|
223
|
+
? obj['error-code']
|
|
224
|
+
: undefined;
|
|
225
|
+
const errText =
|
|
226
|
+
typeof obj['error-text'] === 'string'
|
|
227
|
+
? obj['error-text']
|
|
228
|
+
: undefined;
|
|
260
229
|
const detail =
|
|
261
230
|
code !== undefined || errText !== undefined
|
|
262
231
|
? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
|
|
263
232
|
: '';
|
|
264
233
|
throw new Error(
|
|
265
234
|
`Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
|
|
266
|
-
'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
|
|
235
|
+
'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.',
|
|
267
236
|
);
|
|
268
237
|
}
|
|
269
238
|
}
|
|
@@ -274,15 +243,15 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
274
243
|
this.#authToken = token;
|
|
275
244
|
if (!this.#authToken) {
|
|
276
245
|
throw new Error(
|
|
277
|
-
'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
|
|
246
|
+
'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.',
|
|
278
247
|
);
|
|
279
248
|
}
|
|
280
249
|
|
|
281
250
|
// cmi5 §10 — LaunchData is the only spec-defined channel for the
|
|
282
251
|
// session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
|
|
283
|
-
// validates against, plus launchMode/returnURL/
|
|
284
|
-
//
|
|
285
|
-
//
|
|
252
|
+
// validates against, plus launchMode/returnURL/masteryScore (§10.2).
|
|
253
|
+
// LaunchData values override the URL masteryScore parsed earlier
|
|
254
|
+
// (§10.2.4 makes it authoritative).
|
|
286
255
|
this.#launchData = await this.#fetchLaunchData();
|
|
287
256
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
288
257
|
let sessionId: string | undefined;
|
|
@@ -303,9 +272,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
303
272
|
) {
|
|
304
273
|
this.#returnURL = this.#launchData.returnURL;
|
|
305
274
|
}
|
|
306
|
-
if (typeof this.#launchData.launchParameters === 'string') {
|
|
307
|
-
this.#launchParameters = this.#launchData.launchParameters;
|
|
308
|
-
}
|
|
309
275
|
if (
|
|
310
276
|
typeof this.#launchData.masteryScore === 'number' &&
|
|
311
277
|
Number.isFinite(this.#launchData.masteryScore) &&
|
|
@@ -314,18 +280,12 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
314
280
|
) {
|
|
315
281
|
this.#masteryScore = this.#launchData.masteryScore;
|
|
316
282
|
}
|
|
317
|
-
if (
|
|
318
|
-
typeof this.#launchData.moveOn === 'string' &&
|
|
319
|
-
VALID_MOVE_ON.has(this.#launchData.moveOn)
|
|
320
|
-
) {
|
|
321
|
-
this.#moveOn = this.#launchData.moveOn;
|
|
322
|
-
}
|
|
323
283
|
}
|
|
324
284
|
|
|
325
285
|
// cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
|
|
326
286
|
// LRSes track the GET and reject Initialized otherwise. A 404 here
|
|
327
287
|
// is legitimate (no prefs set); the GET itself is what's required.
|
|
328
|
-
|
|
288
|
+
await this.#fetchLearnerPreferences();
|
|
329
289
|
|
|
330
290
|
this.#publisher = new XAPIPublisher({
|
|
331
291
|
endpoint: this.#endpoint,
|
|
@@ -361,12 +321,12 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
361
321
|
this.#state = await resp.json();
|
|
362
322
|
} else if (resp.status !== 404) {
|
|
363
323
|
console.warn(
|
|
364
|
-
`Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch
|
|
324
|
+
`Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`,
|
|
365
325
|
);
|
|
366
326
|
}
|
|
367
327
|
} catch (err) {
|
|
368
328
|
console.warn(
|
|
369
|
-
`Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch
|
|
329
|
+
`Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
|
|
370
330
|
);
|
|
371
331
|
this.#state = null;
|
|
372
332
|
}
|
|
@@ -391,31 +351,11 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
391
351
|
return this.#masteryScore;
|
|
392
352
|
}
|
|
393
353
|
|
|
394
|
-
/** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
|
|
395
|
-
getMoveOn(): CMI5MoveOn {
|
|
396
|
-
return this.#moveOn;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
354
|
/** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
|
|
400
355
|
getLaunchMode(): CMI5LaunchMode {
|
|
401
356
|
return this.#launchMode;
|
|
402
357
|
}
|
|
403
358
|
|
|
404
|
-
/** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
|
|
405
|
-
getReturnURL(): string | undefined {
|
|
406
|
-
return this.#returnURL;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** cmi5 §10.2.3 — opaque per-launch content-config string. */
|
|
410
|
-
getLaunchParameters(): string | undefined {
|
|
411
|
-
return this.#launchParameters;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
|
|
415
|
-
getLearnerPreferences(): CMI5LearnerPreferences | null {
|
|
416
|
-
return this.#learnerPreferences;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
359
|
getState(): SavedState | null {
|
|
420
360
|
return this.#state;
|
|
421
361
|
}
|
|
@@ -425,7 +365,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
425
365
|
if (!this.#publisher) return;
|
|
426
366
|
// Chain the State PUT onto the publisher's queue so it lands before
|
|
427
367
|
// Terminated. We can't use sendStatement here (different URL/verb).
|
|
428
|
-
this.#publisher.chainTask(async () => {
|
|
368
|
+
void this.#publisher.chainTask(async () => {
|
|
429
369
|
try {
|
|
430
370
|
const resp = await this.#xapiFetch(this.#buildStateUrl(), {
|
|
431
371
|
method: 'PUT',
|
|
@@ -434,7 +374,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
434
374
|
});
|
|
435
375
|
if (!resp.ok) {
|
|
436
376
|
console.warn(
|
|
437
|
-
`Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist
|
|
377
|
+
`Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`,
|
|
438
378
|
);
|
|
439
379
|
}
|
|
440
380
|
} catch (err) {
|
|
@@ -455,7 +395,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
455
395
|
|
|
456
396
|
seedLifecycle(
|
|
457
397
|
completion: 'incomplete' | 'complete',
|
|
458
|
-
success: 'unknown' | 'passed' | 'failed'
|
|
398
|
+
success: 'unknown' | 'passed' | 'failed',
|
|
459
399
|
): void {
|
|
460
400
|
if (completion === 'complete') this.#completedEmitted = true;
|
|
461
401
|
if (success === 'passed' || success === 'failed') {
|
|
@@ -464,7 +404,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
464
404
|
}
|
|
465
405
|
|
|
466
406
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
467
|
-
if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
|
|
407
|
+
if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
|
|
408
|
+
return;
|
|
468
409
|
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
|
|
469
410
|
if (this.#launchMode !== 'Normal') return;
|
|
470
411
|
this.#completedEmitted = true;
|
|
@@ -505,14 +446,16 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
505
446
|
// The author asserted the verb, so on contradiction we keep the
|
|
506
447
|
// verb and drop the score (and warn).
|
|
507
448
|
if (this.#masteryScore !== null) {
|
|
508
|
-
const violatesPassed =
|
|
509
|
-
|
|
449
|
+
const violatesPassed =
|
|
450
|
+
status === 'passed' && scaled < this.#masteryScore;
|
|
451
|
+
const violatesFailed =
|
|
452
|
+
status === 'failed' && scaled >= this.#masteryScore;
|
|
510
453
|
if (violatesPassed || violatesFailed) {
|
|
511
454
|
console.warn(
|
|
512
455
|
`Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
|
|
513
456
|
`${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
|
|
514
457
|
`per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
|
|
515
|
-
`Statement will be sent without a score
|
|
458
|
+
`Statement will be sent without a score.`,
|
|
516
459
|
);
|
|
517
460
|
} else {
|
|
518
461
|
result.score = { scaled };
|
|
@@ -545,7 +488,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
545
488
|
reportInteraction(
|
|
546
489
|
questionId: string,
|
|
547
490
|
interaction: Interaction,
|
|
548
|
-
correct: boolean | null
|
|
491
|
+
correct: boolean | null,
|
|
549
492
|
): void {
|
|
550
493
|
if (!this.#publisher) return;
|
|
551
494
|
const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
|
|
@@ -637,10 +580,13 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
637
580
|
* for Passed/Failed (§9.6.3.2).
|
|
638
581
|
*/
|
|
639
582
|
#cmi5Context(
|
|
640
|
-
opts: { moveOn?: boolean; mastery?: boolean } = {}
|
|
583
|
+
opts: { moveOn?: boolean; mastery?: boolean } = {},
|
|
641
584
|
): Record<string, unknown> {
|
|
642
585
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
643
|
-
const tmplActivities = (tmpl.contextActivities ?? {}) as Record<
|
|
586
|
+
const tmplActivities = (tmpl.contextActivities ?? {}) as Record<
|
|
587
|
+
string,
|
|
588
|
+
unknown
|
|
589
|
+
>;
|
|
644
590
|
|
|
645
591
|
// Concat-dedupe category to preserve any template-supplied entries
|
|
646
592
|
// (§10.2.1 forbids overwriting them).
|
|
@@ -652,8 +598,14 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
652
598
|
category.push({ id, objectType: 'Activity' });
|
|
653
599
|
}
|
|
654
600
|
};
|
|
655
|
-
const templateCategory = Array.isArray(
|
|
656
|
-
|
|
601
|
+
const templateCategory = Array.isArray(
|
|
602
|
+
(tmplActivities as { category?: unknown }).category,
|
|
603
|
+
)
|
|
604
|
+
? (
|
|
605
|
+
tmplActivities as {
|
|
606
|
+
category: Array<{ id: string; objectType?: string }>;
|
|
607
|
+
}
|
|
608
|
+
).category
|
|
657
609
|
: [];
|
|
658
610
|
for (const c of templateCategory) {
|
|
659
611
|
if (c && typeof c.id === 'string') push(c.id);
|
|
@@ -715,35 +667,35 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
715
667
|
console.warn(
|
|
716
668
|
`Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
|
|
717
669
|
'cmi5 Defined Statements may be rejected by strict LRSes ' +
|
|
718
|
-
'(missing Publisher Activity / session id).'
|
|
670
|
+
'(missing Publisher Activity / session id).',
|
|
719
671
|
);
|
|
720
672
|
} catch (err) {
|
|
721
673
|
console.warn(
|
|
722
|
-
`Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)})
|
|
674
|
+
`Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`,
|
|
723
675
|
);
|
|
724
676
|
}
|
|
725
677
|
return null;
|
|
726
678
|
}
|
|
727
679
|
|
|
728
|
-
/**
|
|
729
|
-
|
|
680
|
+
/**
|
|
681
|
+
* GET cmi5 §11.1 Learner Preferences. The GET itself is the §11
|
|
682
|
+
* obligation (it must precede Initialized); the response body is not
|
|
683
|
+
* consumed. 404 is normal (no prefs set).
|
|
684
|
+
*/
|
|
685
|
+
async #fetchLearnerPreferences(): Promise<void> {
|
|
730
686
|
try {
|
|
731
687
|
const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
|
|
732
688
|
const resp = await this.#xapiFetch(url, { method: 'GET' });
|
|
733
|
-
if (resp.ok) {
|
|
734
|
-
return (await resp.json()) as CMI5LearnerPreferences;
|
|
735
|
-
}
|
|
736
|
-
if (resp.status !== 404) {
|
|
689
|
+
if (!resp.ok && resp.status !== 404) {
|
|
737
690
|
console.warn(
|
|
738
|
-
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}
|
|
691
|
+
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`,
|
|
739
692
|
);
|
|
740
693
|
}
|
|
741
694
|
} catch (err) {
|
|
742
695
|
console.warn(
|
|
743
|
-
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)})
|
|
696
|
+
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`,
|
|
744
697
|
);
|
|
745
698
|
}
|
|
746
|
-
return null;
|
|
747
699
|
}
|
|
748
700
|
|
|
749
701
|
async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time / number formatters for SCORM & cmi5 data-model writes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
|
|
7
|
+
*
|
|
8
|
+
* `DurationTracker.sessionSeconds` always feeds integer seconds via
|
|
9
|
+
* `Math.floor`, so the centisecond field is always `.00`. The format
|
|
10
|
+
* still includes it because `CMITimespan` is defined that way and some
|
|
11
|
+
* older LMS importers reject the bare HHHH:MM:SS form.
|
|
12
|
+
*/
|
|
13
|
+
export function formatHHMMSS(totalSeconds: number): string {
|
|
14
|
+
const whole = Math.floor(totalSeconds);
|
|
15
|
+
const hours = Math.floor(whole / 3600);
|
|
16
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
17
|
+
const seconds = whole % 60;
|
|
18
|
+
const hh = String(hours).padStart(4, '0');
|
|
19
|
+
const mm = String(minutes).padStart(2, '0');
|
|
20
|
+
const ss = String(seconds).padStart(2, '0');
|
|
21
|
+
return `${hh}:${mm}:${ss}.00`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
|
|
26
|
+
* `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
|
|
27
|
+
* then trims trailing zeros (no padded "0.8500000" forms).
|
|
28
|
+
*/
|
|
29
|
+
export function formatReal107(value: number): string {
|
|
30
|
+
if (!Number.isFinite(value)) return '0';
|
|
31
|
+
const rounded = Math.round(value * 1e7) / 1e7;
|
|
32
|
+
return rounded
|
|
33
|
+
.toFixed(7)
|
|
34
|
+
.replace(/(\.\d*?)0+$/, '$1')
|
|
35
|
+
.replace(/\.$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
|
|
40
|
+
* zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
|
|
41
|
+
* seconds with error 406. UTC components are used so writes don't drift
|
|
42
|
+
* across local-TZ flips even though the format is zone-free.
|
|
43
|
+
*/
|
|
44
|
+
export function formatISO8601Timestamp(date: Date): string {
|
|
45
|
+
const yyyy = date.getUTCFullYear();
|
|
46
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
47
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
48
|
+
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
49
|
+
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
50
|
+
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
|
51
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format seconds as ISO 8601 duration: PT1H30M45S
|
|
56
|
+
*/
|
|
57
|
+
export function formatISO8601Duration(totalSeconds: number): string {
|
|
58
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
59
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
60
|
+
const seconds = totalSeconds % 60;
|
|
61
|
+
|
|
62
|
+
let result = 'PT';
|
|
63
|
+
if (hours > 0) result += `${hours}H`;
|
|
64
|
+
if (minutes > 0) result += `${minutes}M`;
|
|
65
|
+
if (seconds > 0 || result === 'PT') result += `${seconds}S`;
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -20,7 +20,7 @@ export class LMSAdapterError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function missingApiError(
|
|
23
|
-
standard: 'scorm12' | 'scorm2004' | 'cmi5'
|
|
23
|
+
standard: 'scorm12' | 'scorm2004' | 'cmi5',
|
|
24
24
|
): LMSAdapterError {
|
|
25
25
|
const label =
|
|
26
26
|
standard === 'scorm12'
|
|
@@ -36,7 +36,7 @@ function missingApiError(
|
|
|
36
36
|
standard,
|
|
37
37
|
`Tessera: this course is configured for ${label} but ${detail} ` +
|
|
38
38
|
`The course must be launched from an LMS that provides the ${label} runtime. ` +
|
|
39
|
-
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web"
|
|
39
|
+
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -60,40 +60,51 @@ export interface CreateAdapterOptions {
|
|
|
60
60
|
* In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
|
|
61
61
|
* can still iterate locally.
|
|
62
62
|
*/
|
|
63
|
+
type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
|
|
64
|
+
|
|
65
|
+
/** Per-standard LMS detection. `detect` returns an adapter when the LMS runtime is reachable, else null. */
|
|
66
|
+
const LMS_ADAPTERS: Record<
|
|
67
|
+
LMSStandard,
|
|
68
|
+
{ detect: () => PersistenceAdapter | null; label: string }
|
|
69
|
+
> = {
|
|
70
|
+
scorm12: {
|
|
71
|
+
detect: () => {
|
|
72
|
+
const api = findSCORM12API();
|
|
73
|
+
return api ? new SCORM12Adapter(api) : null;
|
|
74
|
+
},
|
|
75
|
+
label: 'SCORM 1.2 API',
|
|
76
|
+
},
|
|
77
|
+
scorm2004: {
|
|
78
|
+
detect: () => {
|
|
79
|
+
const api = findSCORM2004API();
|
|
80
|
+
return api ? new SCORM2004Adapter(api) : null;
|
|
81
|
+
},
|
|
82
|
+
label: 'SCORM 2004 API',
|
|
83
|
+
},
|
|
84
|
+
cmi5: {
|
|
85
|
+
detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
|
|
86
|
+
label: 'cmi5 launch parameters',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
63
90
|
export function createAdapter(
|
|
64
91
|
config: CourseConfig,
|
|
65
|
-
options: CreateAdapterOptions = {}
|
|
92
|
+
options: CreateAdapterOptions = {},
|
|
66
93
|
): PersistenceAdapter {
|
|
67
|
-
const allowFallback =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (api) return new SCORM2004Adapter(api);
|
|
82
|
-
if (!allowFallback) throw missingApiError('scorm2004');
|
|
83
|
-
console.warn(
|
|
84
|
-
'Tessera (dev): SCORM 2004 API not found — falling back to localStorage'
|
|
85
|
-
);
|
|
86
|
-
return new WebAdapter(config);
|
|
87
|
-
}
|
|
88
|
-
case 'cmi5': {
|
|
89
|
-
if (hasCMI5LaunchParams()) return new CMI5Adapter();
|
|
90
|
-
if (!allowFallback) throw missingApiError('cmi5');
|
|
91
|
-
console.warn(
|
|
92
|
-
'Tessera (dev): cmi5 launch parameters not found — falling back to localStorage'
|
|
93
|
-
);
|
|
94
|
-
return new WebAdapter(config);
|
|
95
|
-
}
|
|
96
|
-
default:
|
|
97
|
-
return new WebAdapter(config);
|
|
94
|
+
const allowFallback = options.allowFallback ?? import.meta.env?.DEV === true;
|
|
95
|
+
const standard = config.export?.standard;
|
|
96
|
+
if (
|
|
97
|
+
standard === 'scorm12' ||
|
|
98
|
+
standard === 'scorm2004' ||
|
|
99
|
+
standard === 'cmi5'
|
|
100
|
+
) {
|
|
101
|
+
const entry = LMS_ADAPTERS[standard];
|
|
102
|
+
const adapter = entry.detect();
|
|
103
|
+
if (adapter) return adapter;
|
|
104
|
+
if (!allowFallback) throw missingApiError(standard);
|
|
105
|
+
console.warn(
|
|
106
|
+
`Tessera (dev): ${entry.label} not found — falling back to localStorage`,
|
|
107
|
+
);
|
|
98
108
|
}
|
|
109
|
+
return new WebAdapter(config);
|
|
99
110
|
}
|