tessera-learn 0.0.3 → 0.0.5
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 +128 -12
- package/dist/plugin/index.js +91 -13
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/plugin/export.ts +6 -2
- package/src/plugin/index.ts +13 -2
- package/src/plugin/manifest.ts +5 -3
- package/src/plugin/validation.ts +137 -7
- package/src/runtime/App.svelte +66 -2
- package/src/runtime/adapters/cmi5.ts +441 -44
- package/src/runtime/adapters/scorm-base.ts +24 -0
- package/src/runtime/adapters/scorm12.ts +2 -0
- package/src/runtime/adapters/scorm2004.ts +2 -0
- package/src/runtime/hooks.svelte.ts +39 -0
- package/src/runtime/persistence.ts +2 -0
- package/src/runtime/progress.svelte.ts +25 -0
- package/src/runtime/types.ts +23 -4
- package/src/runtime/xapi/publisher.ts +24 -5
|
@@ -15,12 +15,104 @@ const VERBS = {
|
|
|
15
15
|
completed: 'http://adlnet.gov/expapi/verbs/completed',
|
|
16
16
|
passed: 'http://adlnet.gov/expapi/verbs/passed',
|
|
17
17
|
failed: 'http://adlnet.gov/expapi/verbs/failed',
|
|
18
|
-
suspended: 'http://adlnet.gov/expapi/verbs/suspended',
|
|
19
18
|
terminated: 'http://adlnet.gov/expapi/verbs/terminated',
|
|
19
|
+
// Intentionally absent: "satisfied" (LMS-only, §9.3.9) and
|
|
20
|
+
// "suspended" (not a cmi5-defined verb — §9.3 enumerates nine, none
|
|
21
|
+
// of them Suspended). The LMS infers Abandoned vs resume from
|
|
22
|
+
// registration state when Terminated lands without Completed.
|
|
20
23
|
} as const;
|
|
21
24
|
|
|
22
25
|
const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
23
26
|
|
|
27
|
+
const CMI5_MASTERYSCORE_EXT =
|
|
28
|
+
'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
|
|
29
|
+
|
|
30
|
+
// cmi5 §9.6 — every cmi5 Defined Statement MUST carry the "cmi5" Category
|
|
31
|
+
// Activity in context.contextActivities.category, and "completed", "passed",
|
|
32
|
+
// "failed" MUST additionally carry the "moveOn" Category. Without these, an
|
|
33
|
+
// LRS will accept the statement as an arbitrary xAPI verb but won't roll it
|
|
34
|
+
// up into cmi5 lifecycle state — the LMS never sees the AU as completed.
|
|
35
|
+
const CMI5_CATEGORY_CMI5 =
|
|
36
|
+
'https://w3id.org/xapi/cmi5/context/categories/cmi5';
|
|
37
|
+
const CMI5_CATEGORY_MOVEON =
|
|
38
|
+
'https://w3id.org/xapi/cmi5/context/categories/moveon';
|
|
39
|
+
|
|
40
|
+
export type CMI5MoveOn =
|
|
41
|
+
| 'Passed'
|
|
42
|
+
| 'Completed'
|
|
43
|
+
| 'CompletedAndPassed'
|
|
44
|
+
| 'CompletedOrPassed'
|
|
45
|
+
| 'NotApplicable';
|
|
46
|
+
|
|
47
|
+
const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
|
|
48
|
+
'Passed',
|
|
49
|
+
'Completed',
|
|
50
|
+
'CompletedAndPassed',
|
|
51
|
+
'CompletedOrPassed',
|
|
52
|
+
'NotApplicable',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
|
|
56
|
+
export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
|
|
57
|
+
const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
|
|
58
|
+
'Normal',
|
|
59
|
+
'Browse',
|
|
60
|
+
'Review',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** State doc id (cmi5 §10) the LMS pre-populates with launch metadata. */
|
|
64
|
+
const LMS_LAUNCH_DATA_STATE_ID = 'LMS.LaunchData';
|
|
65
|
+
|
|
66
|
+
/** Agent Profile id (cmi5 §11) where the LMS stores learner preferences. */
|
|
67
|
+
const CMI5_LEARNER_PREFS_PROFILE_ID = 'cmi5LearnerPreferences';
|
|
68
|
+
|
|
69
|
+
/** xAPI cmi5 sessionid context extension IRI (cmi5 §9.6.3.1). */
|
|
70
|
+
const CMI5_SESSIONID_EXT_IRI =
|
|
71
|
+
'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
|
|
72
|
+
|
|
73
|
+
/** cmi5 §10 `LMS.LaunchData` document. `contextTemplate` is the base context for every Defined Statement (§9.6.2). */
|
|
74
|
+
interface CMI5LaunchData {
|
|
75
|
+
contextTemplate?: {
|
|
76
|
+
contextActivities?: {
|
|
77
|
+
category?: Array<{ id: string; objectType?: string }>;
|
|
78
|
+
grouping?: Array<{ id: string }>;
|
|
79
|
+
[k: string]: unknown;
|
|
80
|
+
};
|
|
81
|
+
extensions?: Record<string, unknown>;
|
|
82
|
+
[k: string]: unknown;
|
|
83
|
+
};
|
|
84
|
+
launchMode?: CMI5LaunchMode;
|
|
85
|
+
launchMethod?: 'OwnWindow' | 'AnyWindow';
|
|
86
|
+
launchParameters?: string;
|
|
87
|
+
returnURL?: string;
|
|
88
|
+
masteryScore?: number;
|
|
89
|
+
moveOn?: CMI5MoveOn;
|
|
90
|
+
entitlementKey?: Record<string, string>;
|
|
91
|
+
[k: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** cmi5 §11.1 Learner Preferences Agent Profile document. */
|
|
95
|
+
interface CMI5LearnerPreferences {
|
|
96
|
+
languagePreference?: string;
|
|
97
|
+
audioPreference?: 'on' | 'off';
|
|
98
|
+
[k: string]: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
|
|
102
|
+
function warnOnLRSReject(
|
|
103
|
+
verbName: string
|
|
104
|
+
): (res: { destinations?: Array<{ ok?: boolean; status?: number; error?: Error }> }) => void {
|
|
105
|
+
return (res) => {
|
|
106
|
+
const dest = res.destinations?.[0];
|
|
107
|
+
if (dest && !dest.ok) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
|
|
110
|
+
dest.error
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
24
116
|
/**
|
|
25
117
|
* CMI5 persistence adapter using xAPI.
|
|
26
118
|
*
|
|
@@ -48,10 +140,28 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
48
140
|
#durationSeconds = 0;
|
|
49
141
|
#state: SavedState | null = null;
|
|
50
142
|
#completedSent = false;
|
|
51
|
-
#completionStatus: 'incomplete' | 'complete' = 'incomplete';
|
|
52
143
|
#successSent = false;
|
|
53
144
|
#terminated = false;
|
|
54
145
|
|
|
146
|
+
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
147
|
+
// course's manifest passingScore for this launch — the LMS is the
|
|
148
|
+
// authority. moveOn drives the optional Satisfied statement (§9.5.3).
|
|
149
|
+
#masteryScore: number | null = null;
|
|
150
|
+
#moveOn: CMI5MoveOn = 'NotApplicable';
|
|
151
|
+
|
|
152
|
+
// cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
|
|
153
|
+
// (§9.6.2) — Publisher Activity and session id live there, and strict
|
|
154
|
+
// LRSes validate every Defined Statement against it.
|
|
155
|
+
#launchData: CMI5LaunchData | null = null;
|
|
156
|
+
/** cmi5 §10.2.2 — Browse/Review forbid every Defined Statement except Initialized/Terminated. */
|
|
157
|
+
#launchMode: CMI5LaunchMode = 'Normal';
|
|
158
|
+
/** cmi5 §10.2.6 — AU redirects here on `exit()`. */
|
|
159
|
+
#returnURL: string | undefined;
|
|
160
|
+
/** cmi5 §10.2.3 — opaque per-launch content config string. */
|
|
161
|
+
#launchParameters: string | undefined;
|
|
162
|
+
/** cmi5 §11.1 Learner Preferences. */
|
|
163
|
+
#learnerPreferences: CMI5LearnerPreferences | null = null;
|
|
164
|
+
|
|
55
165
|
async init(): Promise<void> {
|
|
56
166
|
const params = new URLSearchParams(window.location.search);
|
|
57
167
|
const fetchUrl = params.get('fetch');
|
|
@@ -63,6 +173,29 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
63
173
|
this.#registration = reg ? reg : undefined;
|
|
64
174
|
this.#activityId = params.get('activityId') || '';
|
|
65
175
|
|
|
176
|
+
const rawMastery = params.get('masteryScore');
|
|
177
|
+
if (rawMastery !== null && rawMastery !== '') {
|
|
178
|
+
const m = Number(rawMastery);
|
|
179
|
+
if (Number.isFinite(m) && m >= 0 && m <= 1) {
|
|
180
|
+
this.#masteryScore = m;
|
|
181
|
+
} else {
|
|
182
|
+
console.warn(
|
|
183
|
+
`Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rawMoveOn = params.get('moveOn');
|
|
189
|
+
if (rawMoveOn !== null && rawMoveOn !== '') {
|
|
190
|
+
if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
|
|
191
|
+
this.#moveOn = rawMoveOn as CMI5MoveOn;
|
|
192
|
+
} else {
|
|
193
|
+
console.warn(
|
|
194
|
+
`Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
66
199
|
// Malformed actor JSON is a launch-time failure: an empty {} actor
|
|
67
200
|
// would fail every Identified-Agent check downstream and produce
|
|
68
201
|
// confusing 400s on every send. Fail loud here instead.
|
|
@@ -100,27 +233,105 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
100
233
|
`Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
|
|
101
234
|
);
|
|
102
235
|
}
|
|
103
|
-
const text = await resp.text();
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
236
|
+
const text = (await resp.text()).trim();
|
|
237
|
+
// cmi5 §11.2 — response is `{"auth-token": "..."}`. Some
|
|
238
|
+
// non-conformant LMSes return bare text with an `auth-token=`
|
|
239
|
+
// prefix, so we fall back to that. The token value is the literal
|
|
240
|
+
// Basic credential (already base64); we don't re-encode.
|
|
241
|
+
let token = '';
|
|
242
|
+
if (text.startsWith('{')) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(text);
|
|
245
|
+
if (parsed && typeof parsed['auth-token'] === 'string') {
|
|
246
|
+
token = parsed['auth-token'].trim();
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// fall through to legacy parsing
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!token) {
|
|
253
|
+
token = text.replace(/^auth-token=/, '').trim();
|
|
254
|
+
}
|
|
255
|
+
this.#authToken = token;
|
|
108
256
|
if (!this.#authToken) {
|
|
109
257
|
throw new Error(
|
|
110
|
-
'Tessera cmi5: fetch token request returned an empty
|
|
258
|
+
'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
|
|
111
259
|
);
|
|
112
260
|
}
|
|
113
261
|
|
|
262
|
+
// cmi5 §10 — LaunchData is the only spec-defined channel for the
|
|
263
|
+
// session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
|
|
264
|
+
// validates against, plus launchMode/returnURL/launchParameters/
|
|
265
|
+
// masteryScore/moveOn (§10.2). LaunchData values override the URL
|
|
266
|
+
// masteryScore parsed earlier (§10.2.4 makes it authoritative).
|
|
267
|
+
this.#launchData = await this.#fetchLaunchData();
|
|
268
|
+
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
269
|
+
let sessionId: string | undefined;
|
|
270
|
+
const launchSession = (tmpl.extensions ?? {})[CMI5_SESSIONID_EXT_IRI];
|
|
271
|
+
if (typeof launchSession === 'string' && launchSession.trim()) {
|
|
272
|
+
sessionId = launchSession.trim();
|
|
273
|
+
}
|
|
274
|
+
if (this.#launchData) {
|
|
275
|
+
if (
|
|
276
|
+
typeof this.#launchData.launchMode === 'string' &&
|
|
277
|
+
VALID_LAUNCH_MODE.has(this.#launchData.launchMode)
|
|
278
|
+
) {
|
|
279
|
+
this.#launchMode = this.#launchData.launchMode;
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
typeof this.#launchData.returnURL === 'string' &&
|
|
283
|
+
this.#launchData.returnURL
|
|
284
|
+
) {
|
|
285
|
+
this.#returnURL = this.#launchData.returnURL;
|
|
286
|
+
}
|
|
287
|
+
if (typeof this.#launchData.launchParameters === 'string') {
|
|
288
|
+
this.#launchParameters = this.#launchData.launchParameters;
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
typeof this.#launchData.masteryScore === 'number' &&
|
|
292
|
+
Number.isFinite(this.#launchData.masteryScore) &&
|
|
293
|
+
this.#launchData.masteryScore >= 0 &&
|
|
294
|
+
this.#launchData.masteryScore <= 1
|
|
295
|
+
) {
|
|
296
|
+
this.#masteryScore = this.#launchData.masteryScore;
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
typeof this.#launchData.moveOn === 'string' &&
|
|
300
|
+
VALID_MOVE_ON.has(this.#launchData.moveOn)
|
|
301
|
+
) {
|
|
302
|
+
this.#moveOn = this.#launchData.moveOn;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
|
|
307
|
+
// LRSes track the GET and reject Initialized otherwise. A 404 here
|
|
308
|
+
// is legitimate (no prefs set); the GET itself is what's required.
|
|
309
|
+
this.#learnerPreferences = await this.#fetchLearnerPreferences();
|
|
310
|
+
|
|
114
311
|
this.#publisher = new XAPIPublisher({
|
|
115
312
|
endpoint: this.#endpoint,
|
|
116
313
|
auth: this.#authToken,
|
|
117
314
|
actor: this.#actor,
|
|
118
315
|
activityId: this.#activityId,
|
|
119
316
|
registration: this.#registration,
|
|
317
|
+
sessionId,
|
|
120
318
|
cmi5Mode: true,
|
|
121
319
|
});
|
|
122
320
|
await this.#publisher.init();
|
|
123
321
|
|
|
322
|
+
// cmi5 §9.3.2 — queue Initialized before the resume State GET so a
|
|
323
|
+
// slow LRS can't push it past the spec's "reasonable period". The
|
|
324
|
+
// publisher queue keeps it ordered before any later Defined Statement.
|
|
325
|
+
this.#publisher
|
|
326
|
+
.sendStatement({
|
|
327
|
+
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
328
|
+
context: this.#cmi5Context(),
|
|
329
|
+
})
|
|
330
|
+
.then(warnOnLRSReject('Initialized'))
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
console.warn('Tessera cmi5: failed to send Initialized statement', err);
|
|
333
|
+
});
|
|
334
|
+
|
|
124
335
|
// Retrieve saved state from xAPI State API. The State API is a different
|
|
125
336
|
// URL than statements/, so it doesn't go through the publisher's send
|
|
126
337
|
// path — but we still use the same auth/headers.
|
|
@@ -129,22 +340,17 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
129
340
|
const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
|
|
130
341
|
if (resp.ok) {
|
|
131
342
|
this.#state = await resp.json();
|
|
343
|
+
} else if (resp.status !== 404) {
|
|
344
|
+
console.warn(
|
|
345
|
+
`Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`
|
|
346
|
+
);
|
|
132
347
|
}
|
|
133
|
-
} catch {
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.warn(
|
|
350
|
+
`Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`
|
|
351
|
+
);
|
|
134
352
|
this.#state = null;
|
|
135
353
|
}
|
|
136
|
-
|
|
137
|
-
// Send Initialized statement (queued through publisher). Log failures
|
|
138
|
-
// here too — the publisher's per-destination outcome covers transport
|
|
139
|
-
// errors but won't surface to console; lifecycle statements are rare
|
|
140
|
-
// enough that an explicit warning helps production triage.
|
|
141
|
-
await this.#publisher
|
|
142
|
-
.sendStatement({
|
|
143
|
-
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
144
|
-
})
|
|
145
|
-
.catch((err) => {
|
|
146
|
-
console.warn('Tessera cmi5: failed to send Initialized statement', err);
|
|
147
|
-
});
|
|
148
354
|
}
|
|
149
355
|
|
|
150
356
|
/**
|
|
@@ -156,6 +362,41 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
156
362
|
return this.#publisher;
|
|
157
363
|
}
|
|
158
364
|
|
|
365
|
+
/**
|
|
366
|
+
* LMS-supplied masteryScore from the cmi5 launch URL (a decimal in
|
|
367
|
+
* [0, 1]), or null when omitted. When present, the runtime should treat
|
|
368
|
+
* it as the authoritative pass threshold for this session, overriding
|
|
369
|
+
* `course.config.js scoring.passingScore`.
|
|
370
|
+
*/
|
|
371
|
+
getMasteryScore(): number | null {
|
|
372
|
+
return this.#masteryScore;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
|
|
376
|
+
getMoveOn(): CMI5MoveOn {
|
|
377
|
+
return this.#moveOn;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
|
|
381
|
+
getLaunchMode(): CMI5LaunchMode {
|
|
382
|
+
return this.#launchMode;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
|
|
386
|
+
getReturnURL(): string | undefined {
|
|
387
|
+
return this.#returnURL;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** cmi5 §10.2.3 — opaque per-launch content-config string. */
|
|
391
|
+
getLaunchParameters(): string | undefined {
|
|
392
|
+
return this.#launchParameters;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
|
|
396
|
+
getLearnerPreferences(): CMI5LearnerPreferences | null {
|
|
397
|
+
return this.#learnerPreferences;
|
|
398
|
+
}
|
|
399
|
+
|
|
159
400
|
getState(): SavedState | null {
|
|
160
401
|
return this.#state;
|
|
161
402
|
}
|
|
@@ -167,11 +408,16 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
167
408
|
// Terminated. We can't use sendStatement here (different URL/verb).
|
|
168
409
|
this.#publisher.chainTask(async () => {
|
|
169
410
|
try {
|
|
170
|
-
await this.#xapiFetch(this.#buildStateUrl(), {
|
|
411
|
+
const resp = await this.#xapiFetch(this.#buildStateUrl(), {
|
|
171
412
|
method: 'PUT',
|
|
172
413
|
headers: { 'Content-Type': 'application/json' },
|
|
173
414
|
body: JSON.stringify(state),
|
|
174
415
|
});
|
|
416
|
+
if (!resp.ok) {
|
|
417
|
+
console.warn(
|
|
418
|
+
`Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
175
421
|
} catch (err) {
|
|
176
422
|
console.warn('Tessera: Failed to save CMI5 state', err);
|
|
177
423
|
}
|
|
@@ -179,25 +425,32 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
179
425
|
}
|
|
180
426
|
|
|
181
427
|
setScore(score: number): void {
|
|
182
|
-
|
|
428
|
+
// Clamped to [0, 100] so the /100 division yields a spec-legal
|
|
429
|
+
// scaled value in [0, 1] (xAPI).
|
|
430
|
+
if (!Number.isFinite(score)) {
|
|
431
|
+
this.#score = null;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.#score = Math.max(0, Math.min(100, score));
|
|
183
435
|
}
|
|
184
436
|
|
|
185
437
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
186
|
-
this.#completionStatus = status;
|
|
187
438
|
if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
|
|
439
|
+
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
|
|
440
|
+
if (this.#launchMode !== 'Normal') return;
|
|
188
441
|
this.#completedSent = true;
|
|
442
|
+
// cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
|
|
189
443
|
const result: Record<string, unknown> = {
|
|
190
444
|
completion: true,
|
|
191
445
|
duration: formatISO8601Duration(this.#durationSeconds),
|
|
192
446
|
};
|
|
193
|
-
if (this.#score !== null) {
|
|
194
|
-
result.score = { scaled: this.#score / 100 };
|
|
195
|
-
}
|
|
196
447
|
this.#publisher
|
|
197
448
|
.sendStatement({
|
|
198
449
|
verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
|
|
199
450
|
result,
|
|
451
|
+
context: this.#cmi5Context({ moveOn: true }),
|
|
200
452
|
})
|
|
453
|
+
.then(warnOnLRSReject('Completed'))
|
|
201
454
|
.catch((err) => {
|
|
202
455
|
console.warn('Tessera cmi5: failed to send Completed statement', err);
|
|
203
456
|
});
|
|
@@ -205,6 +458,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
205
458
|
|
|
206
459
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
207
460
|
if (status === 'unknown' || this.#successSent || !this.#publisher) return;
|
|
461
|
+
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
|
|
462
|
+
if (this.#launchMode !== 'Normal') return;
|
|
208
463
|
this.#successSent = true;
|
|
209
464
|
|
|
210
465
|
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
@@ -214,13 +469,35 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
214
469
|
duration: formatISO8601Duration(this.#durationSeconds),
|
|
215
470
|
};
|
|
216
471
|
if (this.#score !== null) {
|
|
217
|
-
|
|
472
|
+
const scaled = this.#score / 100;
|
|
473
|
+
// cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
|
|
474
|
+
// masteryScore; Failed-with-score requires scaled < masteryScore.
|
|
475
|
+
// The author asserted the verb, so on contradiction we keep the
|
|
476
|
+
// verb and drop the score (and warn).
|
|
477
|
+
if (this.#masteryScore !== null) {
|
|
478
|
+
const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
|
|
479
|
+
const violatesFailed = status === 'failed' && scaled >= this.#masteryScore;
|
|
480
|
+
if (violatesPassed || violatesFailed) {
|
|
481
|
+
console.warn(
|
|
482
|
+
`Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
|
|
483
|
+
`${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
|
|
484
|
+
`per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
|
|
485
|
+
`Statement will be sent without a score.`
|
|
486
|
+
);
|
|
487
|
+
} else {
|
|
488
|
+
result.score = { scaled };
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
result.score = { scaled };
|
|
492
|
+
}
|
|
218
493
|
}
|
|
219
494
|
this.#publisher
|
|
220
495
|
.sendStatement({
|
|
221
496
|
verb: { id: verb, display: { 'en-US': verbName } },
|
|
222
497
|
result,
|
|
498
|
+
context: this.#cmi5Context({ moveOn: true, mastery: true }),
|
|
223
499
|
})
|
|
500
|
+
.then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
|
|
224
501
|
.catch((err) => {
|
|
225
502
|
console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
|
|
226
503
|
});
|
|
@@ -264,7 +541,10 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
264
541
|
},
|
|
265
542
|
result,
|
|
266
543
|
})
|
|
267
|
-
.
|
|
544
|
+
.then(warnOnLRSReject('Answered'))
|
|
545
|
+
.catch((err) => {
|
|
546
|
+
console.warn('Tessera cmi5: failed to send Answered statement', err);
|
|
547
|
+
});
|
|
268
548
|
}
|
|
269
549
|
|
|
270
550
|
commit(): void {
|
|
@@ -280,37 +560,102 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
280
560
|
// must be the last statement of the cmi5 session (§9.3.6).
|
|
281
561
|
this.#publisher.markUnloading();
|
|
282
562
|
const duration = formatISO8601Duration(this.#durationSeconds);
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
.sendStatement({
|
|
288
|
-
verb: { id: VERBS.suspended, display: { 'en-US': 'suspended' } },
|
|
289
|
-
result: { duration },
|
|
290
|
-
})
|
|
291
|
-
.catch((err) => {
|
|
292
|
-
console.warn('Tessera cmi5: failed to send Suspended statement', err);
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
// cmi5 §9.5.4.1: Terminated MUST include result.duration.
|
|
563
|
+
// No Suspended — cmi5 doesn't define that verb (§9.3); the LMS
|
|
564
|
+
// handles resume vs Abandoned itself when a new session opens
|
|
565
|
+
// against an active registration (§9.3.6).
|
|
566
|
+
// cmi5 §9.5.4.1 — Terminated MUST include result.duration.
|
|
296
567
|
this.#publisher
|
|
297
568
|
.sendStatement({
|
|
298
569
|
verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
|
|
299
570
|
result: { duration },
|
|
571
|
+
context: this.#cmi5Context(),
|
|
300
572
|
})
|
|
573
|
+
.then(warnOnLRSReject('Terminated'))
|
|
301
574
|
.catch((err) => {
|
|
302
575
|
console.warn('Tessera cmi5: failed to send Terminated statement', err);
|
|
303
576
|
});
|
|
304
577
|
}
|
|
305
578
|
|
|
579
|
+
/**
|
|
580
|
+
* cmi5 §10.2.6 — explicit-Exit path. Terminate, wait for the
|
|
581
|
+
* publisher queue to drain so Terminated lands first, then redirect
|
|
582
|
+
* to `returnURL`. `terminate()` alone (called from pagehide) can't
|
|
583
|
+
* redirect — the page is already unloading.
|
|
584
|
+
*/
|
|
585
|
+
async exit(): Promise<void> {
|
|
586
|
+
this.terminate();
|
|
587
|
+
if (this.#publisher) {
|
|
588
|
+
// chainTask with a no-op task awaits the queue head.
|
|
589
|
+
try {
|
|
590
|
+
await this.#publisher.chainTask(async () => {});
|
|
591
|
+
} catch {
|
|
592
|
+
// never rejects today; don't let a refactor block redirect.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (this.#returnURL && typeof window !== 'undefined') {
|
|
596
|
+
window.location.assign(this.#returnURL);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
306
600
|
// ---- Private helpers ----
|
|
307
601
|
|
|
308
|
-
|
|
602
|
+
/**
|
|
603
|
+
* Build the cmi5 context for a Defined Statement, starting from the
|
|
604
|
+
* LMS contextTemplate (§9.6.2 — AU MUST NOT overwrite). Adds the
|
|
605
|
+
* cmi5 Category Activity (§9.6.2.1), the moveOn Category for
|
|
606
|
+
* Completed/Passed/Failed (§9.6.2.2), and the masteryScore extension
|
|
607
|
+
* for Passed/Failed (§9.6.3.2).
|
|
608
|
+
*/
|
|
609
|
+
#cmi5Context(
|
|
610
|
+
opts: { moveOn?: boolean; mastery?: boolean } = {}
|
|
611
|
+
): Record<string, unknown> {
|
|
612
|
+
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
613
|
+
const tmplActivities = (tmpl.contextActivities ?? {}) as Record<string, unknown>;
|
|
614
|
+
|
|
615
|
+
// Concat-dedupe category to preserve any template-supplied entries
|
|
616
|
+
// (§10.2.1 forbids overwriting them).
|
|
617
|
+
const seen = new Set<string>();
|
|
618
|
+
const category: Array<{ id: string; objectType: string }> = [];
|
|
619
|
+
const push = (id: string) => {
|
|
620
|
+
if (!seen.has(id)) {
|
|
621
|
+
seen.add(id);
|
|
622
|
+
category.push({ id, objectType: 'Activity' });
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
const templateCategory = Array.isArray((tmplActivities as { category?: unknown }).category)
|
|
626
|
+
? ((tmplActivities as { category: Array<{ id: string; objectType?: string }> }).category)
|
|
627
|
+
: [];
|
|
628
|
+
for (const c of templateCategory) {
|
|
629
|
+
if (c && typeof c.id === 'string') push(c.id);
|
|
630
|
+
}
|
|
631
|
+
push(CMI5_CATEGORY_CMI5);
|
|
632
|
+
if (opts.moveOn) push(CMI5_CATEGORY_MOVEON);
|
|
633
|
+
|
|
634
|
+
const contextActivities: Record<string, unknown> = {
|
|
635
|
+
...tmplActivities,
|
|
636
|
+
category,
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const ctx: Record<string, unknown> = {
|
|
640
|
+
...(tmpl as Record<string, unknown>),
|
|
641
|
+
contextActivities,
|
|
642
|
+
};
|
|
643
|
+
// cmi5 §9.6.3.2 — masteryScore extension is scoped to Passed/Failed.
|
|
644
|
+
if (opts.mastery && this.#masteryScore !== null) {
|
|
645
|
+
ctx.extensions = {
|
|
646
|
+
...((tmpl.extensions ?? {}) as Record<string, unknown>),
|
|
647
|
+
[CMI5_MASTERYSCORE_EXT]: this.#masteryScore,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return ctx;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
#buildStateUrl(stateId: string = 'tessera-state'): string {
|
|
309
654
|
const agentJson = JSON.stringify(this.#actor);
|
|
310
655
|
const params = new URLSearchParams({
|
|
311
656
|
activityId: this.#activityId,
|
|
312
657
|
agent: agentJson,
|
|
313
|
-
stateId
|
|
658
|
+
stateId,
|
|
314
659
|
});
|
|
315
660
|
// registration is optional per CMI5 spec — omit it when not provided
|
|
316
661
|
if (this.#registration) {
|
|
@@ -319,6 +664,58 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
319
664
|
return `${this.#endpoint}activities/state?${params.toString()}`;
|
|
320
665
|
}
|
|
321
666
|
|
|
667
|
+
/** cmi5 §11 — Agent Profile URL. Scoped to agent only (no activity/registration). */
|
|
668
|
+
#buildAgentProfileUrl(profileId: string): string {
|
|
669
|
+
const agentJson = JSON.stringify(this.#actor);
|
|
670
|
+
const params = new URLSearchParams({
|
|
671
|
+
agent: agentJson,
|
|
672
|
+
profileId,
|
|
673
|
+
});
|
|
674
|
+
return `${this.#endpoint}agents/profile?${params.toString()}`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** GET the cmi5 §10 `LMS.LaunchData` document. Null if absent — strict LRSes will then reject Defined Statements. */
|
|
678
|
+
async #fetchLaunchData(): Promise<CMI5LaunchData | null> {
|
|
679
|
+
try {
|
|
680
|
+
const url = this.#buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
|
|
681
|
+
const resp = await this.#xapiFetch(url, { method: 'GET' });
|
|
682
|
+
if (resp.ok) {
|
|
683
|
+
return (await resp.json()) as CMI5LaunchData;
|
|
684
|
+
}
|
|
685
|
+
console.warn(
|
|
686
|
+
`Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
|
|
687
|
+
'cmi5 Defined Statements may be rejected by strict LRSes ' +
|
|
688
|
+
'(missing Publisher Activity / session id).'
|
|
689
|
+
);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
console.warn(
|
|
692
|
+
`Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** GET cmi5 §11.1 Learner Preferences. 404 is normal (no prefs set). */
|
|
699
|
+
async #fetchLearnerPreferences(): Promise<CMI5LearnerPreferences | null> {
|
|
700
|
+
try {
|
|
701
|
+
const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
|
|
702
|
+
const resp = await this.#xapiFetch(url, { method: 'GET' });
|
|
703
|
+
if (resp.ok) {
|
|
704
|
+
return (await resp.json()) as CMI5LearnerPreferences;
|
|
705
|
+
}
|
|
706
|
+
if (resp.status !== 404) {
|
|
707
|
+
console.warn(
|
|
708
|
+
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
} catch (err) {
|
|
712
|
+
console.warn(
|
|
713
|
+
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
322
719
|
async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
323
720
|
const headers = new Headers(options.headers);
|
|
324
721
|
if (this.#authToken) {
|
|
@@ -9,6 +9,16 @@ export interface ScormDialect<TApi> {
|
|
|
9
9
|
sessionTimeKey: string;
|
|
10
10
|
/** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
|
|
11
11
|
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
|
+
suspendDataLimit: number;
|
|
20
|
+
/** Human label for the limit warning, e.g. "SCORM 1.2 (4096 chars)". */
|
|
21
|
+
suspendDataLimitLabel: string;
|
|
12
22
|
/** Per-interaction-row field config passed to `buildScormInteractionFields`. */
|
|
13
23
|
interactionFields: {
|
|
14
24
|
responseField: 'student_response' | 'learner_response';
|
|
@@ -34,6 +44,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
34
44
|
protected readonly queue = new WriteQueue();
|
|
35
45
|
#state: SavedState | null = null;
|
|
36
46
|
#terminated = false;
|
|
47
|
+
#suspendOverflowWarned = false;
|
|
37
48
|
protected interactionCount = 0;
|
|
38
49
|
|
|
39
50
|
constructor(api: TApi, dialect: ScormDialect<TApi>) {
|
|
@@ -84,6 +95,19 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
84
95
|
saveState(state: SavedState): void {
|
|
85
96
|
this.#state = state;
|
|
86
97
|
const json = JSON.stringify(state);
|
|
98
|
+
if (
|
|
99
|
+
!this.#suspendOverflowWarned &&
|
|
100
|
+
json.length > this.dialect.suspendDataLimit
|
|
101
|
+
) {
|
|
102
|
+
this.#suspendOverflowWarned = true;
|
|
103
|
+
console.warn(
|
|
104
|
+
`Tessera: cmi.suspend_data is ${json.length} chars, over the ` +
|
|
105
|
+
`${this.dialect.suspendDataLimitLabel} limit. The LMS will likely ` +
|
|
106
|
+
`truncate it and the next resume will lose state. Reduce ` +
|
|
107
|
+
`usePersistence() payloads or switch export.standard to a ` +
|
|
108
|
+
`larger-limit standard (scorm2004/cmi5).`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
87
111
|
this.queue.enqueue(() =>
|
|
88
112
|
this.dialect.setValue(this.api, 'cmi.suspend_data', json)
|
|
89
113
|
);
|
|
@@ -19,6 +19,8 @@ export interface SCORM12API {
|
|
|
19
19
|
const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
|
|
20
20
|
sessionTimeKey: 'cmi.core.session_time',
|
|
21
21
|
formatDuration: formatHHMMSS,
|
|
22
|
+
suspendDataLimit: 4096,
|
|
23
|
+
suspendDataLimitLabel: 'SCORM 1.2 cmi.suspend_data 4096-char',
|
|
22
24
|
interactionFields: {
|
|
23
25
|
responseField: 'student_response',
|
|
24
26
|
timestampField: 'time',
|