tessera-learn 0.0.9 → 0.0.11
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/dist/plugin/cli.js +5 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +215 -124
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
- package/dist/validation-D9DXlqNP.js.map +1 -0
- package/package.json +9 -6
- package/src/components/Audio.svelte +5 -2
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +60 -98
- package/src/components/Image.svelte +3 -8
- package/src/components/LockedBanner.svelte +3 -4
- package/src/components/MultipleChoice.svelte +53 -94
- package/src/components/Quiz.svelte +2 -1
- package/src/components/Video.svelte +4 -2
- package/src/components/util.ts +1 -0
- package/src/plugin/cli.ts +2 -7
- package/src/plugin/export.ts +23 -41
- package/src/plugin/index.ts +197 -56
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +31 -5
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +38 -67
- package/src/runtime/App.svelte +48 -36
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +13 -83
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +28 -29
- package/src/runtime/adapters/retry.ts +0 -64
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +11 -16
- package/src/runtime/hooks.svelte.ts +14 -16
- package/src/runtime/navigation.svelte.ts +51 -45
- package/src/runtime/progress.svelte.ts +25 -10
- package/src/runtime/quiz-policy.ts +21 -178
- package/src/runtime/xapi/agent-rules.ts +7 -2
- package/src/runtime/xapi/publisher.ts +1 -11
- package/src/runtime/xapi/validation.ts +1 -1
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-BxWAMMnJ.js.map +0 -1
- package/src/runtime/LoadingSkeleton.svelte +0 -26
|
@@ -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';
|
|
@@ -41,21 +41,6 @@ const CMI5_CATEGORY_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,21 +72,12 @@ 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
83
|
verbName: string
|
|
@@ -148,10 +124,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
148
124
|
#terminated = false;
|
|
149
125
|
|
|
150
126
|
// 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).
|
|
127
|
+
// course's manifest passingScore for this launch — the LMS is the authority.
|
|
153
128
|
#masteryScore: number | null = null;
|
|
154
|
-
#moveOn: CMI5MoveOn = 'NotApplicable';
|
|
155
129
|
|
|
156
130
|
// cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
|
|
157
131
|
// (§9.6.2) — Publisher Activity and session id live there, and strict
|
|
@@ -161,10 +135,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
161
135
|
#launchMode: CMI5LaunchMode = 'Normal';
|
|
162
136
|
/** cmi5 §10.2.6 — AU redirects here on `exit()`. */
|
|
163
137
|
#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
138
|
|
|
169
139
|
async init(): Promise<void> {
|
|
170
140
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -189,17 +159,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
189
159
|
}
|
|
190
160
|
}
|
|
191
161
|
|
|
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.`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
162
|
// Malformed actor JSON is a launch-time failure: an empty {} actor
|
|
204
163
|
// would fail every Identified-Agent check downstream and produce
|
|
205
164
|
// confusing 400s on every send. Fail loud here instead.
|
|
@@ -280,9 +239,9 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
280
239
|
|
|
281
240
|
// cmi5 §10 — LaunchData is the only spec-defined channel for the
|
|
282
241
|
// session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
|
|
283
|
-
// validates against, plus launchMode/returnURL/
|
|
284
|
-
//
|
|
285
|
-
//
|
|
242
|
+
// validates against, plus launchMode/returnURL/masteryScore (§10.2).
|
|
243
|
+
// LaunchData values override the URL masteryScore parsed earlier
|
|
244
|
+
// (§10.2.4 makes it authoritative).
|
|
286
245
|
this.#launchData = await this.#fetchLaunchData();
|
|
287
246
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
288
247
|
let sessionId: string | undefined;
|
|
@@ -303,9 +262,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
303
262
|
) {
|
|
304
263
|
this.#returnURL = this.#launchData.returnURL;
|
|
305
264
|
}
|
|
306
|
-
if (typeof this.#launchData.launchParameters === 'string') {
|
|
307
|
-
this.#launchParameters = this.#launchData.launchParameters;
|
|
308
|
-
}
|
|
309
265
|
if (
|
|
310
266
|
typeof this.#launchData.masteryScore === 'number' &&
|
|
311
267
|
Number.isFinite(this.#launchData.masteryScore) &&
|
|
@@ -314,18 +270,12 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
314
270
|
) {
|
|
315
271
|
this.#masteryScore = this.#launchData.masteryScore;
|
|
316
272
|
}
|
|
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
273
|
}
|
|
324
274
|
|
|
325
275
|
// cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
|
|
326
276
|
// LRSes track the GET and reject Initialized otherwise. A 404 here
|
|
327
277
|
// is legitimate (no prefs set); the GET itself is what's required.
|
|
328
|
-
|
|
278
|
+
await this.#fetchLearnerPreferences();
|
|
329
279
|
|
|
330
280
|
this.#publisher = new XAPIPublisher({
|
|
331
281
|
endpoint: this.#endpoint,
|
|
@@ -391,31 +341,11 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
391
341
|
return this.#masteryScore;
|
|
392
342
|
}
|
|
393
343
|
|
|
394
|
-
/** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
|
|
395
|
-
getMoveOn(): CMI5MoveOn {
|
|
396
|
-
return this.#moveOn;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
344
|
/** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
|
|
400
345
|
getLaunchMode(): CMI5LaunchMode {
|
|
401
346
|
return this.#launchMode;
|
|
402
347
|
}
|
|
403
348
|
|
|
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
349
|
getState(): SavedState | null {
|
|
420
350
|
return this.#state;
|
|
421
351
|
}
|
|
@@ -725,15 +655,16 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
725
655
|
return null;
|
|
726
656
|
}
|
|
727
657
|
|
|
728
|
-
/**
|
|
729
|
-
|
|
658
|
+
/**
|
|
659
|
+
* GET cmi5 §11.1 Learner Preferences. The GET itself is the §11
|
|
660
|
+
* obligation (it must precede Initialized); the response body is not
|
|
661
|
+
* consumed. 404 is normal (no prefs set).
|
|
662
|
+
*/
|
|
663
|
+
async #fetchLearnerPreferences(): Promise<void> {
|
|
730
664
|
try {
|
|
731
665
|
const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
|
|
732
666
|
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) {
|
|
667
|
+
if (!resp.ok && resp.status !== 404) {
|
|
737
668
|
console.warn(
|
|
738
669
|
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
|
|
739
670
|
);
|
|
@@ -743,7 +674,6 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
743
674
|
`Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
|
|
744
675
|
);
|
|
745
676
|
}
|
|
746
|
-
return null;
|
|
747
677
|
}
|
|
748
678
|
|
|
749
679
|
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
|
+
}
|
|
@@ -60,40 +60,39 @@ 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<LMSStandard, { detect: () => PersistenceAdapter | null; label: string }> = {
|
|
67
|
+
scorm12: {
|
|
68
|
+
detect: () => { const api = findSCORM12API(); return api ? new SCORM12Adapter(api) : null; },
|
|
69
|
+
label: 'SCORM 1.2 API',
|
|
70
|
+
},
|
|
71
|
+
scorm2004: {
|
|
72
|
+
detect: () => { const api = findSCORM2004API(); return api ? new SCORM2004Adapter(api) : null; },
|
|
73
|
+
label: 'SCORM 2004 API',
|
|
74
|
+
},
|
|
75
|
+
cmi5: {
|
|
76
|
+
detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
|
|
77
|
+
label: 'cmi5 launch parameters',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
63
81
|
export function createAdapter(
|
|
64
82
|
config: CourseConfig,
|
|
65
83
|
options: CreateAdapterOptions = {}
|
|
66
84
|
): PersistenceAdapter {
|
|
67
85
|
const allowFallback =
|
|
68
86
|
options.allowFallback ?? import.meta.env?.DEV === true;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
case 'scorm2004': {
|
|
80
|
-
const api = findSCORM2004API();
|
|
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);
|
|
87
|
+
const standard = config.export?.standard;
|
|
88
|
+
if (standard === 'scorm12' || standard === 'scorm2004' || standard === 'cmi5') {
|
|
89
|
+
const entry = LMS_ADAPTERS[standard];
|
|
90
|
+
const adapter = entry.detect();
|
|
91
|
+
if (adapter) return adapter;
|
|
92
|
+
if (!allowFallback) throw missingApiError(standard);
|
|
93
|
+
console.warn(
|
|
94
|
+
`Tessera (dev): ${entry.label} not found — falling back to localStorage`
|
|
95
|
+
);
|
|
98
96
|
}
|
|
97
|
+
return new WebAdapter(config);
|
|
99
98
|
}
|
|
@@ -296,67 +296,3 @@ export function findLMSAPI(propName: string): unknown {
|
|
|
296
296
|
// Check window.parent chain (iframe launch pattern)
|
|
297
297
|
return scan(window);
|
|
298
298
|
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
|
|
302
|
-
*
|
|
303
|
-
* `DurationTracker.sessionSeconds` always feeds integer seconds via
|
|
304
|
-
* `Math.floor`, so the centisecond field is always `.00`. The format
|
|
305
|
-
* still includes it because `CMITimespan` is defined that way and some
|
|
306
|
-
* older LMS importers reject the bare HHHH:MM:SS form.
|
|
307
|
-
*/
|
|
308
|
-
export function formatHHMMSS(totalSeconds: number): string {
|
|
309
|
-
const whole = Math.floor(totalSeconds);
|
|
310
|
-
const hours = Math.floor(whole / 3600);
|
|
311
|
-
const minutes = Math.floor((whole % 3600) / 60);
|
|
312
|
-
const seconds = whole % 60;
|
|
313
|
-
const hh = String(hours).padStart(4, '0');
|
|
314
|
-
const mm = String(minutes).padStart(2, '0');
|
|
315
|
-
const ss = String(seconds).padStart(2, '0');
|
|
316
|
-
return `${hh}:${mm}:${ss}.00`;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
|
|
321
|
-
* `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
|
|
322
|
-
* then trims trailing zeros (no padded "0.8500000" forms).
|
|
323
|
-
*/
|
|
324
|
-
export function formatReal107(value: number): string {
|
|
325
|
-
if (!Number.isFinite(value)) return '0';
|
|
326
|
-
const rounded = Math.round(value * 1e7) / 1e7;
|
|
327
|
-
return rounded
|
|
328
|
-
.toFixed(7)
|
|
329
|
-
.replace(/(\.\d*?)0+$/, '$1')
|
|
330
|
-
.replace(/\.$/, '');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
|
|
335
|
-
* zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
|
|
336
|
-
* seconds with error 406. UTC components are used so writes don't drift
|
|
337
|
-
* across local-TZ flips even though the format is zone-free.
|
|
338
|
-
*/
|
|
339
|
-
export function formatISO8601Timestamp(date: Date): string {
|
|
340
|
-
const yyyy = date.getUTCFullYear();
|
|
341
|
-
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
342
|
-
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
343
|
-
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
344
|
-
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
345
|
-
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
|
346
|
-
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Format seconds as ISO 8601 duration: PT1H30M45S
|
|
351
|
-
*/
|
|
352
|
-
export function formatISO8601Duration(totalSeconds: number): string {
|
|
353
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
354
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
355
|
-
const seconds = totalSeconds % 60;
|
|
356
|
-
|
|
357
|
-
let result = 'PT';
|
|
358
|
-
if (hours > 0) result += `${hours}H`;
|
|
359
|
-
if (minutes > 0) result += `${minutes}M`;
|
|
360
|
-
if (seconds > 0 || result === 'PT') result += `${seconds}S`;
|
|
361
|
-
return result;
|
|
362
|
-
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from '../interaction-format.js';
|
|
5
5
|
import type { SavedState } from '../persistence.js';
|
|
6
6
|
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
7
|
-
import { formatHHMMSS, formatReal107 } from './
|
|
7
|
+
import { formatHHMMSS, formatReal107 } from './format.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* SCORM 1.2 API interface.
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
formatISO8601Duration,
|
|
6
6
|
formatISO8601Timestamp,
|
|
7
7
|
formatReal107,
|
|
8
|
-
} from './
|
|
8
|
+
} from './format.js';
|
|
9
9
|
|
|
10
10
|
export interface SCORM2004API {
|
|
11
11
|
Initialize(param: string): string;
|
|
@@ -48,13 +48,11 @@ export type SCORM2004Mode = 'browse' | 'normal' | 'review';
|
|
|
48
48
|
/**
|
|
49
49
|
* Per §4.2.1.5, the SCO MUST NOT alter the learner record in `browse` or
|
|
50
50
|
* `review` mode — every write below is gated on `#mode === 'normal'`.
|
|
51
|
-
* `#masteryScore` (§4.2.4.3)
|
|
52
|
-
* LMS-supplied thresholds in [0,1].
|
|
51
|
+
* `#masteryScore` (§4.2.4.3) is the LMS-supplied pass threshold in [0,1].
|
|
53
52
|
*/
|
|
54
53
|
export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
55
54
|
#mode: SCORM2004Mode = 'normal';
|
|
56
55
|
#masteryScore: number | null = null;
|
|
57
|
-
#completionThreshold: number | null = null;
|
|
58
56
|
|
|
59
57
|
constructor(api: SCORM2004API) {
|
|
60
58
|
super(api, SCORM2004_DIALECT);
|
|
@@ -64,9 +62,6 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
64
62
|
await super.init();
|
|
65
63
|
this.#mode = this.#readMode();
|
|
66
64
|
this.#masteryScore = this.#readScaledThreshold('cmi.scaled_passing_score');
|
|
67
|
-
this.#completionThreshold = this.#readScaledThreshold(
|
|
68
|
-
'cmi.completion_threshold'
|
|
69
|
-
);
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
getLaunchMode(): SCORM2004Mode {
|
|
@@ -78,8 +73,8 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
78
73
|
return this.#masteryScore;
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
|
|
82
|
-
return this.#
|
|
76
|
+
get #canWrite(): boolean {
|
|
77
|
+
return this.#mode === 'normal';
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
#readMode(): SCORM2004Mode {
|
|
@@ -104,7 +99,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
104
99
|
}
|
|
105
100
|
|
|
106
101
|
saveState(state: SavedState): void {
|
|
107
|
-
if (this.#
|
|
102
|
+
if (!this.#canWrite) return;
|
|
108
103
|
super.saveState(state);
|
|
109
104
|
// §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
|
|
110
105
|
this.queue.enqueue(
|
|
@@ -114,7 +109,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
114
109
|
}
|
|
115
110
|
|
|
116
111
|
setDuration(seconds: number): void {
|
|
117
|
-
if (this.#
|
|
112
|
+
if (!this.#canWrite) return;
|
|
118
113
|
super.setDuration(seconds);
|
|
119
114
|
}
|
|
120
115
|
|
|
@@ -123,12 +118,12 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
123
118
|
interaction: import('../interaction.js').Interaction,
|
|
124
119
|
correct: boolean | null
|
|
125
120
|
): void {
|
|
126
|
-
if (this.#
|
|
121
|
+
if (!this.#canWrite) return;
|
|
127
122
|
super.reportInteraction(questionId, interaction, correct);
|
|
128
123
|
}
|
|
129
124
|
|
|
130
125
|
setScore(score: number): void {
|
|
131
|
-
if (this.#
|
|
126
|
+
if (!this.#canWrite) return;
|
|
132
127
|
const raw = formatReal107(score);
|
|
133
128
|
// §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
|
|
134
129
|
const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
|
|
@@ -151,7 +146,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
154
|
-
if (this.#
|
|
149
|
+
if (!this.#canWrite) return;
|
|
155
150
|
const value = status === 'complete' ? 'completed' : 'incomplete';
|
|
156
151
|
this.queue.enqueue(
|
|
157
152
|
() => this.api.SetValue('cmi.completion_status', value),
|
|
@@ -167,7 +162,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
167
162
|
}
|
|
168
163
|
|
|
169
164
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
170
|
-
if (this.#
|
|
165
|
+
if (!this.#canWrite) return;
|
|
171
166
|
// Setting "unknown" explicitly prevents SCORM Cloud from rolling up
|
|
172
167
|
// a null status to "passed".
|
|
173
168
|
this.queue.enqueue(
|
|
@@ -177,7 +172,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
177
172
|
}
|
|
178
173
|
|
|
179
174
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
180
|
-
if (this.#
|
|
175
|
+
if (!this.#canWrite) return;
|
|
181
176
|
this.queue.enqueue(
|
|
182
177
|
() => this.api.SetValue('cmi.exit', mode),
|
|
183
178
|
'cmi.exit'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
3
|
import type { Interaction } from './interaction.js';
|
|
3
4
|
import { isCorrect as isCorrectInteraction } from './interaction.js';
|
|
4
5
|
import {
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
import {
|
|
12
13
|
resolveFeedbackMode,
|
|
13
14
|
resolveRetryStrategy,
|
|
14
|
-
type QuizPolicyConfig,
|
|
15
15
|
type QuizQuestionResult,
|
|
16
16
|
} from './quiz-policy.js';
|
|
17
17
|
|
|
@@ -185,8 +185,8 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
185
185
|
if (opts.graded && navCtx) {
|
|
186
186
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
187
187
|
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
|
|
188
|
-
navCtx.progress.recalculateCompletion(navCtx.manifest, navCtx.config);
|
|
189
|
-
navCtx.progress.recalculateSuccess(navCtx.
|
|
188
|
+
navCtx.progress.recalculateCompletion(navCtx.manifest.totalPages, navCtx.config);
|
|
189
|
+
navCtx.progress.recalculateSuccess(navCtx.config);
|
|
190
190
|
} else if (navCtx) {
|
|
191
191
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
192
192
|
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, false);
|
|
@@ -291,7 +291,7 @@ export function useCompletion(): {
|
|
|
291
291
|
return;
|
|
292
292
|
}
|
|
293
293
|
progress.markCompleteManually();
|
|
294
|
-
progress.recalculateSuccess(
|
|
294
|
+
progress.recalculateSuccess(config);
|
|
295
295
|
},
|
|
296
296
|
get completionStatus() {
|
|
297
297
|
return progress.completionStatus;
|
|
@@ -410,9 +410,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
const maxAttempts = quizConfig.maxAttempts ?? Infinity;
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const retryPredicate = resolveRetryStrategy(policyCfg);
|
|
413
|
+
const feedbackPredicate = resolveFeedbackMode(quizConfig);
|
|
414
|
+
const retryPredicate = resolveRetryStrategy(quizConfig);
|
|
416
415
|
|
|
417
416
|
let internalQuestions = $state<InternalQuestion[]>([]);
|
|
418
417
|
const answers = new Map<number, unknown>();
|
|
@@ -422,8 +421,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
422
421
|
let reviewing = $state(false);
|
|
423
422
|
let score = $state(0);
|
|
424
423
|
let attemptCount = $state(0);
|
|
425
|
-
|
|
426
|
-
|
|
424
|
+
const feedbackShown = new SvelteSet<number>();
|
|
425
|
+
const lockedCorrect = new SvelteSet<number>();
|
|
427
426
|
let submitCalled = false;
|
|
428
427
|
|
|
429
428
|
const seenIds = new Set<string>();
|
|
@@ -477,7 +476,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
477
476
|
}
|
|
478
477
|
|
|
479
478
|
function feedbackVisibleInternal(index: number): boolean {
|
|
480
|
-
if (
|
|
479
|
+
if (quizConfig.feedbackMode === 'never') return false;
|
|
481
480
|
return feedbackPredicate({
|
|
482
481
|
questionIndex: index,
|
|
483
482
|
submitted,
|
|
@@ -489,10 +488,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
489
488
|
}
|
|
490
489
|
|
|
491
490
|
function revealFeedbackInternal(index: number): void {
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
next.add(index);
|
|
495
|
-
feedbackShown = next;
|
|
491
|
+
if (quizConfig.feedbackMode === 'never') return;
|
|
492
|
+
feedbackShown.add(index);
|
|
496
493
|
}
|
|
497
494
|
|
|
498
495
|
function isLockedCorrectInternal(index: number): boolean {
|
|
@@ -618,7 +615,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
618
615
|
for (const i of newLocked) {
|
|
619
616
|
if (answers.has(i)) preserved.set(i, answers.get(i));
|
|
620
617
|
}
|
|
621
|
-
lockedCorrect
|
|
618
|
+
lockedCorrect.clear();
|
|
619
|
+
for (const i of newLocked) lockedCorrect.add(i);
|
|
622
620
|
answers.clear();
|
|
623
621
|
reportedAnswers.clear();
|
|
624
622
|
for (const [i, a] of preserved) answers.set(i, a);
|
|
@@ -626,7 +624,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
626
624
|
if (!newLocked.has(i) && internalQuestions[i].reset) internalQuestions[i].reset!();
|
|
627
625
|
}
|
|
628
626
|
answersVersion++;
|
|
629
|
-
feedbackShown
|
|
627
|
+
feedbackShown.clear();
|
|
630
628
|
submitted = false;
|
|
631
629
|
reviewing = false;
|
|
632
630
|
score = 0;
|