tessera-learn 0.2.3 → 0.4.0
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 +50 -21
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +91 -49
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +287 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +96 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +138 -93
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +24 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +255 -238
- package/src/runtime/App.svelte +14 -9
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -54
- package/src/runtime/adapters/web.ts +11 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +22 -1
- package/src/runtime/xapi/publisher.ts +16 -15
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- package/dist/build-commands-Qyrlsp3n.js.map +0 -1
- package/dist/inline-config-DqAKsCNl.js.map +0 -1
- package/dist/plugin-B-aiL9-V.js.map +0 -1
package/src/runtime/App.svelte
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { applyBranding } from './branding.js';
|
|
15
15
|
import { DurationTracker } from './duration.js';
|
|
16
16
|
import { createAdapter } from 'virtual:tessera-adapter';
|
|
17
|
+
import { structureFingerprint, shouldRestore } from './fingerprint.js';
|
|
17
18
|
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
18
19
|
import { registerXAPIClient } from './xapi/registry.js';
|
|
19
20
|
import {
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
} from './contexts.js';
|
|
25
26
|
|
|
26
27
|
// ---- Persistence ----
|
|
27
|
-
const adapter = createAdapter(config);
|
|
28
|
+
const adapter = createAdapter(config, { manifest });
|
|
29
|
+
const currentFingerprint = structureFingerprint(manifest);
|
|
28
30
|
let persistenceReady = $state(false);
|
|
29
31
|
// Holds the resolved xAPI client for unload-time markUnloading. Set
|
|
30
32
|
// after adapter.init() resolves and registered globally so useXAPI()
|
|
@@ -206,13 +208,16 @@
|
|
|
206
208
|
}
|
|
207
209
|
return {
|
|
208
210
|
b: nav.currentPageIndex,
|
|
211
|
+
f: currentFingerprint,
|
|
209
212
|
v: [...progress.visitedPages],
|
|
210
213
|
q,
|
|
211
214
|
d: duration.totalSeconds,
|
|
212
|
-
c,
|
|
213
|
-
s,
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
...(progress.chunkProgress.size > 0 ? { c } : {}),
|
|
216
|
+
...(progress.standaloneQuestionScores.size > 0 ? { s } : {}),
|
|
217
|
+
...(progress.gradedStandalonePages.size > 0
|
|
218
|
+
? { gs: [...progress.gradedStandalonePages] }
|
|
219
|
+
: {}),
|
|
220
|
+
...(Object.keys(userState).length > 0 ? { u: { ...userState } } : {}),
|
|
216
221
|
...(progress.manuallyCompleted ? { m: 1 } : {}),
|
|
217
222
|
};
|
|
218
223
|
}
|
|
@@ -297,7 +302,7 @@
|
|
|
297
302
|
|
|
298
303
|
// ---- Persistence: report score/completion/success to adapter ----
|
|
299
304
|
// These are no-ops for WebAdapter but used by LMS adapters (Step 10)
|
|
300
|
-
let prevReportedScore =
|
|
305
|
+
let prevReportedScore = null;
|
|
301
306
|
$effect(() => {
|
|
302
307
|
void progress.version;
|
|
303
308
|
if (!persistenceReady) return;
|
|
@@ -322,7 +327,7 @@
|
|
|
322
327
|
});
|
|
323
328
|
});
|
|
324
329
|
|
|
325
|
-
let prevCompletionStatus =
|
|
330
|
+
let prevCompletionStatus = 'incomplete';
|
|
326
331
|
$effect(() => {
|
|
327
332
|
const status = progress.completionStatus;
|
|
328
333
|
if (!persistenceReady) return;
|
|
@@ -335,7 +340,7 @@
|
|
|
335
340
|
});
|
|
336
341
|
});
|
|
337
342
|
|
|
338
|
-
let prevSuccessStatus =
|
|
343
|
+
let prevSuccessStatus = 'unknown';
|
|
339
344
|
$effect(() => {
|
|
340
345
|
const status = progress.successStatus;
|
|
341
346
|
if (!persistenceReady) return;
|
|
@@ -401,7 +406,7 @@
|
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
const saved = adapter.getState();
|
|
404
|
-
if (saved) {
|
|
409
|
+
if (saved && shouldRestore(saved, currentFingerprint, config.resume)) {
|
|
405
410
|
restoreState(saved);
|
|
406
411
|
prevCompletionStatus = progress.completionStatus;
|
|
407
412
|
prevSuccessStatus = progress.successStatus;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
|
|
2
4
|
let { manifest, config, currentPageIndex, nav, onnavigate, onclose } =
|
|
3
5
|
$props();
|
|
4
6
|
|
|
5
7
|
// Track which sections are collapsed. All expanded by default.
|
|
6
|
-
|
|
8
|
+
const collapsedSections = new SvelteSet();
|
|
7
9
|
|
|
8
10
|
function toggleSection(slug) {
|
|
9
11
|
if (collapsedSections.has(slug)) {
|
|
@@ -1,42 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
formatResponse,
|
|
5
|
-
formatCorrectPattern,
|
|
6
|
-
XAPI_INTERACTION_FORMAT,
|
|
7
|
-
} from '../interaction-format.js';
|
|
8
|
-
import { formatISO8601Duration, parseScaled01 } from './format.js';
|
|
9
|
-
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
10
|
-
import { X_API_VERSION } from '../xapi/version.js';
|
|
11
|
-
import type { XAPIAgent } from '../xapi/types.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* xAPI verb IRIs used by CMI5.
|
|
15
|
-
*/
|
|
16
|
-
const VERBS = {
|
|
17
|
-
initialized: 'http://adlnet.gov/expapi/verbs/initialized',
|
|
18
|
-
answered: 'http://adlnet.gov/expapi/verbs/answered',
|
|
19
|
-
completed: 'http://adlnet.gov/expapi/verbs/completed',
|
|
20
|
-
passed: 'http://adlnet.gov/expapi/verbs/passed',
|
|
21
|
-
failed: 'http://adlnet.gov/expapi/verbs/failed',
|
|
22
|
-
terminated: 'http://adlnet.gov/expapi/verbs/terminated',
|
|
23
|
-
// Intentionally absent: "satisfied" (LMS-only, §9.3.9) and
|
|
24
|
-
// "suspended" (not a cmi5-defined verb — §9.3 enumerates nine, none
|
|
25
|
-
// of them Suspended). The LMS infers Abandoned vs resume from
|
|
26
|
-
// registration state when Terminated lands without Completed.
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
|
-
const CMI_INTERACTION_TYPE =
|
|
30
|
-
'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
1
|
+
import { parseScaled01 } from './format.js';
|
|
2
|
+
import { BaseXAPILaunchAdapter } from './xapi-launch-base.js';
|
|
31
3
|
|
|
32
4
|
const CMI5_MASTERYSCORE_EXT =
|
|
33
5
|
'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
|
|
34
6
|
|
|
35
|
-
// cmi5 §9.6 —
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// LRS will accept the statement as an arbitrary xAPI verb but won't roll it
|
|
39
|
-
// up into cmi5 lifecycle state — the LMS never sees the AU as completed.
|
|
7
|
+
// cmi5 §9.6.2 — Defined Statements MUST carry the "cmi5" Category; completed/
|
|
8
|
+
// passed/failed MUST also carry "moveOn", or the LRS won't roll them up into
|
|
9
|
+
// cmi5 lifecycle state and the LMS never sees the AU as completed.
|
|
40
10
|
const CMI5_CATEGORY_CMI5 = 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
|
|
41
11
|
const CMI5_CATEGORY_MOVEON =
|
|
42
12
|
'https://w3id.org/xapi/cmi5/context/categories/moveon';
|
|
@@ -78,23 +48,6 @@ interface CMI5LaunchData {
|
|
|
78
48
|
[k: string]: unknown;
|
|
79
49
|
}
|
|
80
50
|
|
|
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. */
|
|
82
|
-
function warnOnLRSReject(
|
|
83
|
-
verbName: string,
|
|
84
|
-
): (res: {
|
|
85
|
-
destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
|
|
86
|
-
}) => void {
|
|
87
|
-
return (res) => {
|
|
88
|
-
const dest = res.destinations?.[0];
|
|
89
|
-
if (dest && !dest.ok) {
|
|
90
|
-
console.warn(
|
|
91
|
-
`Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
|
|
92
|
-
dest.error,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
51
|
/**
|
|
99
52
|
* CMI5 persistence adapter using xAPI.
|
|
100
53
|
*
|
|
@@ -105,49 +58,33 @@ function warnOnLRSReject(
|
|
|
105
58
|
* and `cmi5Mode` injects the required `sessionid` context extension on
|
|
106
59
|
* every statement (cmi5 §9.6.1.1).
|
|
107
60
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
61
|
+
* The version-neutral launch lifecycle lives in BaseXAPILaunchAdapter; this
|
|
62
|
+
* class layers cmi5 specifics on top: fetch-token auth, LMS.LaunchData, the
|
|
63
|
+
* cmi5 context (Category/moveOn/masteryScore), launch-mode gating, and the
|
|
64
|
+
* Agent Profile GET.
|
|
111
65
|
*/
|
|
112
|
-
export class CMI5Adapter
|
|
113
|
-
#publisher: XAPIPublisher | null = null;
|
|
114
|
-
#endpoint = '';
|
|
115
|
-
#activityId = '';
|
|
116
|
-
#actor: XAPIAgent | null = null;
|
|
117
|
-
#registration: string | undefined;
|
|
118
|
-
#authToken = '';
|
|
119
|
-
|
|
120
|
-
// Stored internally for inclusion in statements
|
|
121
|
-
#score: number | null = null;
|
|
122
|
-
#durationSeconds = 0;
|
|
123
|
-
#state: SavedState | null = null;
|
|
124
|
-
#completedEmitted = false;
|
|
125
|
-
#lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
|
|
126
|
-
#terminated = false;
|
|
127
|
-
|
|
66
|
+
export class CMI5Adapter extends BaseXAPILaunchAdapter {
|
|
128
67
|
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
129
68
|
// course's manifest passingScore for this launch — the LMS is the authority.
|
|
130
69
|
#masteryScore: number | null = null;
|
|
131
70
|
|
|
132
|
-
// cmi5 §10 LMS.LaunchData
|
|
133
|
-
//
|
|
134
|
-
// LRSes validate every Defined Statement against it.
|
|
71
|
+
// cmi5 §10 LMS.LaunchData; `contextTemplate` (§9.6.2) is the base context
|
|
72
|
+
// strict LRSes validate every Defined Statement against.
|
|
135
73
|
#launchData: CMI5LaunchData | null = null;
|
|
136
74
|
/** cmi5 §10.2.2 — Browse/Review forbid every Defined Statement except Initialized/Terminated. */
|
|
137
75
|
#launchMode: CMI5LaunchMode = 'Normal';
|
|
138
|
-
/** cmi5 §10.2.6 — AU redirects here on `exit()`. */
|
|
139
|
-
#returnURL: string | undefined;
|
|
140
76
|
|
|
141
77
|
async init(): Promise<void> {
|
|
78
|
+
this.version = '1.0.3';
|
|
79
|
+
this.logName = 'cmi5';
|
|
142
80
|
const params = new URLSearchParams(window.location.search);
|
|
143
81
|
const fetchUrl = params.get('fetch');
|
|
144
|
-
|
|
145
|
-
this.#endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
|
|
82
|
+
this.endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
|
|
146
83
|
const reg = params.get('registration') || '';
|
|
147
84
|
// xAPI requires `context.registration` to be a UUID; sending an empty
|
|
148
85
|
// string makes LRSes 400. Omit when the LMS didn't provide one.
|
|
149
|
-
this
|
|
150
|
-
this
|
|
86
|
+
this.registration = reg ? reg : undefined;
|
|
87
|
+
this.activityId = params.get('activityId') || '';
|
|
151
88
|
|
|
152
89
|
const rawMastery = params.get('masteryScore');
|
|
153
90
|
if (rawMastery !== null && rawMastery !== '') {
|
|
@@ -161,22 +98,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
161
98
|
}
|
|
162
99
|
}
|
|
163
100
|
|
|
164
|
-
|
|
165
|
-
// would fail every Identified-Agent check downstream and produce
|
|
166
|
-
// confusing 400s on every send. Fail loud here instead.
|
|
167
|
-
const rawActor = params.get('actor') || '';
|
|
168
|
-
try {
|
|
169
|
-
const parsed = JSON.parse(rawActor);
|
|
170
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
171
|
-
throw new Error('actor must be an object');
|
|
172
|
-
}
|
|
173
|
-
this.#actor = parsed as XAPIAgent;
|
|
174
|
-
} catch (err) {
|
|
175
|
-
throw new Error(
|
|
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 },
|
|
178
|
-
);
|
|
179
|
-
}
|
|
101
|
+
this.parseActorParam(params.get('actor') || '');
|
|
180
102
|
|
|
181
103
|
// The cmi5 fetch URL is single-use (§6.2): if it fails we can't retry,
|
|
182
104
|
// and continuing with no token will 401-loop until auth is marked dead.
|
|
@@ -240,18 +162,16 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
240
162
|
if (!token) {
|
|
241
163
|
token = text.replace(/^auth-token=/, '').trim();
|
|
242
164
|
}
|
|
243
|
-
this
|
|
244
|
-
if (!this
|
|
165
|
+
this.authToken = token;
|
|
166
|
+
if (!this.authToken) {
|
|
245
167
|
throw new Error(
|
|
246
168
|
'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.',
|
|
247
169
|
);
|
|
248
170
|
}
|
|
249
171
|
|
|
250
|
-
// cmi5 §10 — LaunchData
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// LaunchData values override the URL masteryScore parsed earlier
|
|
254
|
-
// (§10.2.4 makes it authoritative).
|
|
172
|
+
// cmi5 §10 — LaunchData carries the session id (§9.6.3.1), Publisher
|
|
173
|
+
// Activity (§9.6.2.3), and launchMode/returnURL/masteryScore (§10.2); its
|
|
174
|
+
// masteryScore overrides the URL value parsed earlier (§10.2.4).
|
|
255
175
|
this.#launchData = await this.#fetchLaunchData();
|
|
256
176
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
257
177
|
let sessionId: string | undefined;
|
|
@@ -270,7 +190,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
270
190
|
typeof this.#launchData.returnURL === 'string' &&
|
|
271
191
|
this.#launchData.returnURL
|
|
272
192
|
) {
|
|
273
|
-
this
|
|
193
|
+
this.returnURL = this.#launchData.returnURL;
|
|
274
194
|
}
|
|
275
195
|
const launchMastery = parseScaled01(this.#launchData.masteryScore);
|
|
276
196
|
if (launchMastery !== null) {
|
|
@@ -283,58 +203,14 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
283
203
|
// is legitimate (no prefs set); the GET itself is what's required.
|
|
284
204
|
await this.#fetchLearnerPreferences();
|
|
285
205
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
auth: this.#authToken,
|
|
289
|
-
actor: this.#actor,
|
|
290
|
-
activityId: this.#activityId,
|
|
291
|
-
registration: this.#registration,
|
|
292
|
-
sessionId,
|
|
293
|
-
cmi5Mode: true,
|
|
294
|
-
});
|
|
295
|
-
await this.#publisher.init();
|
|
206
|
+
const publisher = this.createPublisher({ sessionId, cmi5Mode: true });
|
|
207
|
+
await publisher.init();
|
|
296
208
|
|
|
297
|
-
// cmi5 §9.3.2 — queue Initialized before the resume State GET so a
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
this.#publisher
|
|
301
|
-
.sendStatement({
|
|
302
|
-
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
303
|
-
context: this.#cmi5Context(),
|
|
304
|
-
})
|
|
305
|
-
.then(warnOnLRSReject('Initialized'))
|
|
306
|
-
.catch((err) => {
|
|
307
|
-
console.warn('Tessera cmi5: failed to send Initialized statement', err);
|
|
308
|
-
});
|
|
209
|
+
// cmi5 §9.3.2 — queue Initialized before the resume State GET so a slow
|
|
210
|
+
// LRS can't push it past the spec's "reasonable period".
|
|
211
|
+
this.sendInitialized();
|
|
309
212
|
|
|
310
|
-
|
|
311
|
-
// URL than statements/, so it doesn't go through the publisher's send
|
|
312
|
-
// path — but we still use the same auth/headers.
|
|
313
|
-
try {
|
|
314
|
-
const stateUrl = this.#buildStateUrl();
|
|
315
|
-
const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
|
|
316
|
-
if (resp.ok) {
|
|
317
|
-
this.#state = await resp.json();
|
|
318
|
-
} else if (resp.status !== 404) {
|
|
319
|
-
console.warn(
|
|
320
|
-
`Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`,
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
} catch (err) {
|
|
324
|
-
console.warn(
|
|
325
|
-
`Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
|
|
326
|
-
);
|
|
327
|
-
this.#state = null;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Returns the underlying publisher so the xAPI client can fan author-
|
|
333
|
-
* issued statements to the LMS-launched LRS via `endpoint: 'lms'`. Null
|
|
334
|
-
* when init() hasn't run yet.
|
|
335
|
-
*/
|
|
336
|
-
getPublisher(): XAPIPublisher | null {
|
|
337
|
-
return this.#publisher;
|
|
213
|
+
await this.loadResumeState();
|
|
338
214
|
}
|
|
339
215
|
|
|
340
216
|
/**
|
|
@@ -352,222 +228,37 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
352
228
|
return this.#launchMode;
|
|
353
229
|
}
|
|
354
230
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
saveState(state: SavedState): void {
|
|
360
|
-
this.#state = state;
|
|
361
|
-
if (!this.#publisher) return;
|
|
362
|
-
// Chain the State PUT onto the publisher's queue so it lands before
|
|
363
|
-
// Terminated. We can't use sendStatement here (different URL/verb).
|
|
364
|
-
void this.#publisher.chainTask(async () => {
|
|
365
|
-
try {
|
|
366
|
-
const resp = await this.#xapiFetch(this.#buildStateUrl(), {
|
|
367
|
-
method: 'PUT',
|
|
368
|
-
headers: { 'Content-Type': 'application/json' },
|
|
369
|
-
body: JSON.stringify(state),
|
|
370
|
-
});
|
|
371
|
-
if (!resp.ok) {
|
|
372
|
-
console.warn(
|
|
373
|
-
`Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`,
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
} catch (err) {
|
|
377
|
-
console.warn('Tessera: Failed to save CMI5 state', err);
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
setScore(score: number): void {
|
|
383
|
-
// Clamped to [0, 100] so the /100 division yields a spec-legal
|
|
384
|
-
// scaled value in [0, 1] (xAPI).
|
|
385
|
-
if (!Number.isFinite(score)) {
|
|
386
|
-
this.#score = null;
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
this.#score = Math.max(0, Math.min(100, score));
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
seedLifecycle(
|
|
393
|
-
completion: 'incomplete' | 'complete',
|
|
394
|
-
success: 'unknown' | 'passed' | 'failed',
|
|
395
|
-
): void {
|
|
396
|
-
if (completion === 'complete') this.#completedEmitted = true;
|
|
397
|
-
if (success === 'passed' || success === 'failed') {
|
|
398
|
-
this.#lastSuccessEmitted = success;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
403
|
-
if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
|
|
404
|
-
return;
|
|
405
|
-
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
|
|
406
|
-
if (this.#launchMode !== 'Normal') return;
|
|
407
|
-
this.#completedEmitted = true;
|
|
408
|
-
// cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
|
|
409
|
-
const result: Record<string, unknown> = {
|
|
410
|
-
completion: true,
|
|
411
|
-
duration: formatISO8601Duration(this.#durationSeconds),
|
|
412
|
-
};
|
|
413
|
-
this.#publisher
|
|
414
|
-
.sendStatement({
|
|
415
|
-
verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
|
|
416
|
-
result,
|
|
417
|
-
context: this.#cmi5Context({ moveOn: true }),
|
|
418
|
-
})
|
|
419
|
-
.then(warnOnLRSReject('Completed'))
|
|
420
|
-
.catch((err) => {
|
|
421
|
-
console.warn('Tessera cmi5: failed to send Completed statement', err);
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
426
|
-
if (status === 'unknown' || !this.#publisher) return;
|
|
427
|
-
if (status === this.#lastSuccessEmitted) return;
|
|
428
|
-
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
|
|
429
|
-
if (this.#launchMode !== 'Normal') return;
|
|
430
|
-
this.#lastSuccessEmitted = status;
|
|
431
|
-
|
|
432
|
-
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
433
|
-
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
434
|
-
const result: Record<string, unknown> = {
|
|
435
|
-
success: status === 'passed',
|
|
436
|
-
duration: formatISO8601Duration(this.#durationSeconds),
|
|
437
|
-
};
|
|
438
|
-
if (this.#score !== null) {
|
|
439
|
-
const scaled = this.#score / 100;
|
|
440
|
-
// cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
|
|
441
|
-
// masteryScore; Failed-with-score requires scaled < masteryScore.
|
|
442
|
-
// The author asserted the verb, so on contradiction we keep the
|
|
443
|
-
// verb and drop the score (and warn).
|
|
444
|
-
if (this.#masteryScore !== null) {
|
|
445
|
-
const violatesPassed =
|
|
446
|
-
status === 'passed' && scaled < this.#masteryScore;
|
|
447
|
-
const violatesFailed =
|
|
448
|
-
status === 'failed' && scaled >= this.#masteryScore;
|
|
449
|
-
if (violatesPassed || violatesFailed) {
|
|
450
|
-
console.warn(
|
|
451
|
-
`Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
|
|
452
|
-
`${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
|
|
453
|
-
`per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
|
|
454
|
-
`Statement will be sent without a score.`,
|
|
455
|
-
);
|
|
456
|
-
} else {
|
|
457
|
-
result.score = { scaled };
|
|
458
|
-
}
|
|
459
|
-
} else {
|
|
460
|
-
result.score = { scaled };
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
this.#publisher
|
|
464
|
-
.sendStatement({
|
|
465
|
-
verb: { id: verb, display: { 'en-US': verbName } },
|
|
466
|
-
result,
|
|
467
|
-
context: this.#cmi5Context({ moveOn: true, mastery: true }),
|
|
468
|
-
})
|
|
469
|
-
.then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
|
|
470
|
-
.catch((err) => {
|
|
471
|
-
console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
setDuration(seconds: number): void {
|
|
476
|
-
this.#durationSeconds = seconds;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
setExit(_mode: 'suspend' | 'normal'): void {
|
|
480
|
-
// cmi5 has no analogue to SCORM cmi.exit; suspend semantics are carried
|
|
481
|
-
// by *not* sending Completed/Terminated yet. No-op.
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
reportInteraction(
|
|
485
|
-
questionId: string,
|
|
486
|
-
interaction: Interaction,
|
|
487
|
-
correct: boolean | null,
|
|
488
|
-
): void {
|
|
489
|
-
if (!this.#publisher) return;
|
|
490
|
-
const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
|
|
491
|
-
const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
|
|
492
|
-
const definition: Record<string, unknown> = {
|
|
493
|
-
type: CMI_INTERACTION_TYPE,
|
|
494
|
-
interactionType: interaction.type,
|
|
495
|
-
};
|
|
496
|
-
if (pattern !== null) {
|
|
497
|
-
definition.correctResponsesPattern = [pattern];
|
|
498
|
-
}
|
|
499
|
-
const result: Record<string, unknown> = { response };
|
|
500
|
-
if (correct !== null) {
|
|
501
|
-
result.success = correct;
|
|
502
|
-
}
|
|
503
|
-
this.#publisher
|
|
504
|
-
.sendStatement({
|
|
505
|
-
verb: { id: VERBS.answered, display: { 'en-US': 'answered' } },
|
|
506
|
-
object: {
|
|
507
|
-
id: `${this.#activityId}#${questionId}`,
|
|
508
|
-
objectType: 'Activity',
|
|
509
|
-
definition,
|
|
510
|
-
},
|
|
511
|
-
result,
|
|
512
|
-
})
|
|
513
|
-
.then(warnOnLRSReject('Answered'))
|
|
514
|
-
.catch((err) => {
|
|
515
|
-
console.warn('Tessera cmi5: failed to send Answered statement', err);
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
commit(): void {
|
|
520
|
-
// No-op — xAPI calls are sent individually per statement.
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
terminate(): void {
|
|
524
|
-
if (this.#terminated) return;
|
|
525
|
-
this.#terminated = true;
|
|
526
|
-
if (!this.#publisher) return;
|
|
527
|
-
// Mark unloading so all subsequent (queued) requests use keepalive,
|
|
528
|
-
// and so the XAPIClient stops accepting new author sends — Terminated
|
|
529
|
-
// must be the last statement of the cmi5 session (§9.3.6).
|
|
530
|
-
this.#publisher.markUnloading();
|
|
531
|
-
const duration = formatISO8601Duration(this.#durationSeconds);
|
|
532
|
-
// No Suspended — cmi5 doesn't define that verb (§9.3); the LMS
|
|
533
|
-
// handles resume vs Abandoned itself when a new session opens
|
|
534
|
-
// against an active registration (§9.3.6).
|
|
535
|
-
// cmi5 §9.5.4.1 — Terminated MUST include result.duration.
|
|
536
|
-
this.#publisher
|
|
537
|
-
.sendStatement({
|
|
538
|
-
verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
|
|
539
|
-
result: { duration },
|
|
540
|
-
context: this.#cmi5Context(),
|
|
541
|
-
})
|
|
542
|
-
.then(warnOnLRSReject('Terminated'))
|
|
543
|
-
.catch((err) => {
|
|
544
|
-
console.warn('Tessera cmi5: failed to send Terminated statement', err);
|
|
545
|
-
});
|
|
231
|
+
/** cmi5 §10.2.2 — Browse/Review forbid Completed/Passed/Failed. */
|
|
232
|
+
protected isDefinedStatementAllowed(): boolean {
|
|
233
|
+
return this.#launchMode === 'Normal';
|
|
546
234
|
}
|
|
547
235
|
|
|
548
236
|
/**
|
|
549
|
-
* cmi5 §
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
237
|
+
* cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
|
|
238
|
+
* masteryScore; Failed-with-score requires scaled < masteryScore. The
|
|
239
|
+
* author asserted the verb, so on contradiction keep the verb and drop
|
|
240
|
+
* the score (and warn).
|
|
553
241
|
*/
|
|
554
|
-
|
|
555
|
-
this.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
242
|
+
protected scoreForSuccess(status: 'passed' | 'failed'): number | null {
|
|
243
|
+
if (this.score === null) return null;
|
|
244
|
+
const scaled = this.score / 100;
|
|
245
|
+
if (this.#masteryScore !== null) {
|
|
246
|
+
const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
|
|
247
|
+
const violatesFailed =
|
|
248
|
+
status === 'failed' && scaled >= this.#masteryScore;
|
|
249
|
+
if (violatesPassed || violatesFailed) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
|
|
252
|
+
`${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
|
|
253
|
+
`per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
|
|
254
|
+
`Statement will be sent without a score.`,
|
|
255
|
+
);
|
|
256
|
+
return null;
|
|
562
257
|
}
|
|
563
258
|
}
|
|
564
|
-
|
|
565
|
-
window.location.assign(this.#returnURL);
|
|
566
|
-
}
|
|
259
|
+
return scaled;
|
|
567
260
|
}
|
|
568
261
|
|
|
569
|
-
// ---- Private helpers ----
|
|
570
|
-
|
|
571
262
|
/**
|
|
572
263
|
* Build the cmi5 context for a Defined Statement, starting from the
|
|
573
264
|
* LMS contextTemplate (§9.6.2 — AU MUST NOT overwrite). Adds the
|
|
@@ -575,7 +266,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
575
266
|
* Completed/Passed/Failed (§9.6.2.2), and the masteryScore extension
|
|
576
267
|
* for Passed/Failed (§9.6.3.2).
|
|
577
268
|
*/
|
|
578
|
-
|
|
269
|
+
protected buildContext(
|
|
579
270
|
opts: { moveOn?: boolean; mastery?: boolean } = {},
|
|
580
271
|
): Record<string, unknown> {
|
|
581
272
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
@@ -619,35 +310,21 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
619
310
|
return ctx;
|
|
620
311
|
}
|
|
621
312
|
|
|
622
|
-
#buildStateUrl(stateId: string = 'tessera-state'): string {
|
|
623
|
-
const agentJson = JSON.stringify(this.#actor);
|
|
624
|
-
const params = new URLSearchParams({
|
|
625
|
-
activityId: this.#activityId,
|
|
626
|
-
agent: agentJson,
|
|
627
|
-
stateId,
|
|
628
|
-
});
|
|
629
|
-
// registration is optional per CMI5 spec — omit it when not provided
|
|
630
|
-
if (this.#registration) {
|
|
631
|
-
params.set('registration', this.#registration);
|
|
632
|
-
}
|
|
633
|
-
return `${this.#endpoint}activities/state?${params.toString()}`;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
313
|
/** cmi5 §11 — Agent Profile URL. Scoped to agent only (no activity/registration). */
|
|
637
314
|
#buildAgentProfileUrl(profileId: string): string {
|
|
638
|
-
const agentJson = JSON.stringify(this
|
|
315
|
+
const agentJson = JSON.stringify(this.actor);
|
|
639
316
|
const params = new URLSearchParams({
|
|
640
317
|
agent: agentJson,
|
|
641
318
|
profileId,
|
|
642
319
|
});
|
|
643
|
-
return `${this
|
|
320
|
+
return `${this.endpoint}agents/profile?${params.toString()}`;
|
|
644
321
|
}
|
|
645
322
|
|
|
646
323
|
/** GET the cmi5 §10 `LMS.LaunchData` document. Null if absent — strict LRSes will then reject Defined Statements. */
|
|
647
324
|
async #fetchLaunchData(): Promise<CMI5LaunchData | null> {
|
|
648
325
|
try {
|
|
649
|
-
const url = this
|
|
650
|
-
const resp = await this
|
|
326
|
+
const url = this.buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
|
|
327
|
+
const resp = await this.xapiFetch(url, { method: 'GET' });
|
|
651
328
|
if (resp.ok) {
|
|
652
329
|
return (await resp.json()) as CMI5LaunchData;
|
|
653
330
|
}
|
|
@@ -672,7 +349,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
672
349
|
async #fetchLearnerPreferences(): Promise<void> {
|
|
673
350
|
try {
|
|
674
351
|
const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
|
|
675
|
-
const resp = await this
|
|
352
|
+
const resp = await this.xapiFetch(url, { method: 'GET' });
|
|
676
353
|
if (!resp.ok && resp.status !== 404) {
|
|
677
354
|
console.warn(
|
|
678
355
|
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`,
|
|
@@ -684,24 +361,4 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
684
361
|
);
|
|
685
362
|
}
|
|
686
363
|
}
|
|
687
|
-
|
|
688
|
-
async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
689
|
-
const headers = new Headers(options.headers);
|
|
690
|
-
if (this.#authToken) {
|
|
691
|
-
// Basic, not Bearer — cmi5 §6.2 specifies the LMS-issued token is
|
|
692
|
-
// used as a Basic Authorization credential.
|
|
693
|
-
headers.set('Authorization', `Basic ${this.#authToken}`);
|
|
694
|
-
}
|
|
695
|
-
headers.set('X-Experience-API-Version', X_API_VERSION);
|
|
696
|
-
|
|
697
|
-
// Mirror the publisher: once the page is unloading, every State API
|
|
698
|
-
// write needs keepalive or the browser will cancel it during teardown.
|
|
699
|
-
// saveState is the suspend payload — losing it costs the resume.
|
|
700
|
-
const keepalive = this.#publisher?.isUnloading() ?? false;
|
|
701
|
-
return fetch(url, {
|
|
702
|
-
...options,
|
|
703
|
-
headers,
|
|
704
|
-
...(keepalive ? { keepalive: true } : {}),
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
364
|
}
|