scorm-kit 0.2.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.
@@ -0,0 +1,328 @@
1
+ /*
2
+ * Mock SCORM 1.2 LMS — runs in the shell window, exposes window.API so the
3
+ * course (in the iframe) finds it via parent-walking. Stores CMI state in
4
+ * an in-memory tree, logs every call, persists to localStorage by package.
5
+ */
6
+ (function () {
7
+ "use strict";
8
+
9
+ // SCORM 1.2 error codes (subset that matters in practice).
10
+ var ERR = {
11
+ OK: "0",
12
+ GENERAL: "101",
13
+ INVALID_ARG: "201",
14
+ ELEMENT_CANNOT_HAVE_CHILDREN: "202",
15
+ ELEMENT_NOT_ARRAY: "203",
16
+ NOT_INITIALIZED: "301",
17
+ NOT_IMPLEMENTED: "401",
18
+ READ_ONLY: "403",
19
+ WRITE_ONLY: "404",
20
+ INCORRECT_DATA_TYPE: "405",
21
+ };
22
+ var ERR_STRINGS = {
23
+ "0": "No error", "101": "General exception", "201": "Invalid argument error",
24
+ "202": "Element cannot have children", "203": "Element not an array",
25
+ "301": "Not initialized", "401": "Not implemented error",
26
+ "403": "Element is read-only", "404": "Element is write-only",
27
+ "405": "Incorrect data type",
28
+ };
29
+
30
+ // Default CMI 1.2 model (only the elements courses actually use).
31
+ function defaultCmi() {
32
+ return {
33
+ "cmi.core._children": "student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,exit,session_time",
34
+ "cmi.core.student_id": "mock-student",
35
+ "cmi.core.student_name": "Mock, Student",
36
+ "cmi.core.lesson_location": "",
37
+ "cmi.core.credit": "credit",
38
+ "cmi.core.lesson_status": "not attempted",
39
+ "cmi.core.entry": "",
40
+ "cmi.core.score._children": "raw,min,max",
41
+ "cmi.core.score.raw": "",
42
+ "cmi.core.score.min": "",
43
+ "cmi.core.score.max": "",
44
+ "cmi.core.total_time": "0000:00:00.00",
45
+ "cmi.core.lesson_mode": "normal",
46
+ "cmi.core.exit": "",
47
+ "cmi.core.session_time": "0000:00:00.00",
48
+ "cmi.suspend_data": "",
49
+ "cmi.launch_data": "",
50
+ "cmi.comments": "",
51
+ "cmi.comments_from_lms": "",
52
+ "cmi.objectives._count": "0",
53
+ "cmi.objectives._children": "id,score,status",
54
+ "cmi.student_data._children": "mastery_score,max_time_allowed,time_limit_action",
55
+ "cmi.student_preference._children": "audio,language,speed,text",
56
+ "cmi.interactions._count": "0",
57
+ "cmi.interactions._children": "id,objectives,time,type,correct_responses,weighting,student_response,result,latency",
58
+ };
59
+ }
60
+
61
+ // Write-permission map. Anything not in here = read-only.
62
+ var WRITABLE = new Set([
63
+ "cmi.core.lesson_location", "cmi.core.lesson_status", "cmi.core.exit",
64
+ "cmi.core.session_time", "cmi.core.score.raw", "cmi.core.score.min",
65
+ "cmi.core.score.max", "cmi.suspend_data", "cmi.comments",
66
+ ]);
67
+ // Dynamic-prefix writables (arrays): cmi.objectives.N.*, cmi.interactions.N.*
68
+ function isDynamicWritable(key) {
69
+ return /^cmi\.objectives\.\d+\.(id|score\.(raw|min|max)|status)$/.test(key) ||
70
+ /^cmi\.interactions\.\d+\.(id|objectives\.\d+\.id|time|type|correct_responses\.\d+\.pattern|weighting|student_response|result|latency)$/.test(key);
71
+ }
72
+
73
+ // ---------- state ------------------------------------------------------
74
+
75
+ var state = {
76
+ initialized: false,
77
+ terminated: false,
78
+ cmi: defaultCmi(),
79
+ lastError: ERR.OK,
80
+ log: [],
81
+ failMode: "none",
82
+ packageKey: location.search.slice(1) || "default",
83
+ };
84
+ var STORAGE_KEY = "mockLMS:" + state.packageKey;
85
+
86
+ function persist() {
87
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.cmi)); } catch (e) {}
88
+ }
89
+ function restore() {
90
+ try {
91
+ var v = localStorage.getItem(STORAGE_KEY);
92
+ if (v) {
93
+ Object.assign(state.cmi, JSON.parse(v));
94
+ // SCORM 1.2 resume semantics: tell the course this is a resume so it
95
+ // knows to read suspend_data / lesson_location instead of starting fresh.
96
+ state.cmi["cmi.core.entry"] = "resume";
97
+ }
98
+ } catch (e) {}
99
+ }
100
+
101
+ // ---------- log --------------------------------------------------------
102
+
103
+ var logEl, cmiEl, countEl, filterEl, stateEl;
104
+ var t0 = performance.now();
105
+
106
+ function logCall(method, args, ret, err) {
107
+ var entry = {
108
+ t: ((performance.now() - t0) / 1000).toFixed(2),
109
+ method: method,
110
+ args: args,
111
+ ret: ret,
112
+ err: err,
113
+ };
114
+ state.log.push(entry);
115
+ appendLogRow(entry);
116
+ updateCount();
117
+ }
118
+
119
+ function appendLogRow(e) {
120
+ if (!logEl) return;
121
+ var li = document.createElement("li");
122
+ if (e.err && e.err !== ERR.OK) li.className = "err";
123
+ var argText = e.args.map(function (a) { return JSON.stringify(a); }).join(", ");
124
+ li.innerHTML =
125
+ '<span class="t">' + e.t + 's</span>' +
126
+ '<span class="m"></span>' +
127
+ '<span class="a"></span>' +
128
+ '<span class="r"></span>';
129
+ li.children[1].textContent = e.method;
130
+ li.children[2].textContent = argText;
131
+ li.children[3].textContent = JSON.stringify(e.ret);
132
+ li.dataset.method = e.method.toLowerCase();
133
+ li.dataset.args = argText.toLowerCase();
134
+ applyFilter(li);
135
+ logEl.appendChild(li);
136
+ logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight;
137
+ }
138
+
139
+ function updateCount() {
140
+ if (countEl) countEl.textContent = state.log.length + " call" + (state.log.length === 1 ? "" : "s");
141
+ }
142
+
143
+ function applyFilter(li) {
144
+ if (!filterEl) return;
145
+ var q = filterEl.value.trim().toLowerCase();
146
+ if (!q) { li.classList.remove("hidden"); return; }
147
+ var hit = li.dataset.method.indexOf(q) >= 0 || li.dataset.args.indexOf(q) >= 0;
148
+ li.classList.toggle("hidden", !hit);
149
+ }
150
+
151
+ function renderCmi() {
152
+ if (!cmiEl) return;
153
+ // Skip the _children noise — show actual values only.
154
+ var lines = Object.keys(state.cmi).sort().filter(function (k) {
155
+ return !/_children$|_count$/.test(k);
156
+ }).map(function (k) {
157
+ return k + " = " + JSON.stringify(state.cmi[k]);
158
+ });
159
+ cmiEl.textContent = lines.join("\n");
160
+ }
161
+
162
+ function setStateBadge(text, cls) {
163
+ if (!stateEl) return;
164
+ stateEl.textContent = text;
165
+ stateEl.className = "state " + (cls || "");
166
+ }
167
+
168
+ // ---------- the SCORM 1.2 API surface ---------------------------------
169
+
170
+ function setLastError(code) { state.lastError = code; }
171
+
172
+ function lmsInit(s) {
173
+ if (state.failMode === "init") { setLastError(ERR.GENERAL); logCall("LMSInitialize", [s], "false", ERR.GENERAL); return "false"; }
174
+ if (state.initialized) { setLastError(ERR.GENERAL); logCall("LMSInitialize", [s], "false", ERR.GENERAL); return "false"; }
175
+ state.initialized = true;
176
+ state.terminated = false;
177
+ setStateBadge("connected", "connected");
178
+ setLastError(ERR.OK);
179
+ logCall("LMSInitialize", [s], "true", ERR.OK);
180
+ return "true";
181
+ }
182
+
183
+ function lmsFinish(s) {
184
+ if (state.failMode === "finish") { setLastError(ERR.GENERAL); logCall("LMSFinish", [s], "false", ERR.GENERAL); return "false"; }
185
+ if (!state.initialized) { setLastError(ERR.NOT_INITIALIZED); logCall("LMSFinish", [s], "false", ERR.NOT_INITIALIZED); return "false"; }
186
+ state.initialized = false; state.terminated = true;
187
+ persist();
188
+ setStateBadge("terminated", "terminated");
189
+ setLastError(ERR.OK);
190
+ logCall("LMSFinish", [s], "true", ERR.OK);
191
+ return "true";
192
+ }
193
+
194
+ function lmsCommit(s) {
195
+ if (state.failMode === "commit") { setLastError(ERR.GENERAL); logCall("LMSCommit", [s], "false", ERR.GENERAL); return "false"; }
196
+ if (!state.initialized) { setLastError(ERR.NOT_INITIALIZED); logCall("LMSCommit", [s], "false", ERR.NOT_INITIALIZED); return "false"; }
197
+ persist();
198
+ setLastError(ERR.OK);
199
+ logCall("LMSCommit", [s], "true", ERR.OK);
200
+ return "true";
201
+ }
202
+
203
+ function lmsGetValue(key) {
204
+ if (!state.initialized) { setLastError(ERR.NOT_INITIALIZED); logCall("LMSGetValue", [key], "", ERR.NOT_INITIALIZED); return ""; }
205
+ var v = state.cmi[key];
206
+ if (v == null) {
207
+ setLastError(ERR.NOT_IMPLEMENTED);
208
+ logCall("LMSGetValue", [key], "", ERR.NOT_IMPLEMENTED);
209
+ return "";
210
+ }
211
+ setLastError(ERR.OK);
212
+ logCall("LMSGetValue", [key], v, ERR.OK);
213
+ return String(v);
214
+ }
215
+
216
+ function lmsSetValue(key, value) {
217
+ if (state.failMode === "set") {
218
+ setLastError(ERR.GENERAL); logCall("LMSSetValue", [key, value], "false", ERR.GENERAL); return "false";
219
+ }
220
+ if (!state.initialized) {
221
+ setLastError(ERR.NOT_INITIALIZED); logCall("LMSSetValue", [key, value], "false", ERR.NOT_INITIALIZED); return "false";
222
+ }
223
+ // Auto-expand array _count on first interactions/objectives.N write.
224
+ var arr = /^cmi\.(interactions|objectives)\.(\d+)\./.exec(key);
225
+ if (arr) {
226
+ var countKey = "cmi." + arr[1] + "._count";
227
+ var idx = +arr[2];
228
+ var current = +(state.cmi[countKey] || "0");
229
+ if (idx >= current) state.cmi[countKey] = String(idx + 1);
230
+ }
231
+ if (!WRITABLE.has(key) && !isDynamicWritable(key)) {
232
+ // Known CMI elements that exist in defaultCmi but aren't writable are
233
+ // read-only (per SCORM 1.2 RTE) — surface that with err 403. Unknown
234
+ // keys still get NOT_IMPLEMENTED (401). Either way, we record the
235
+ // attempted value so the call shows up in the panel for debugging.
236
+ var code = (state.cmi[key] != null && !/_children$|_count$/.test(key))
237
+ ? ERR.READ_ONLY
238
+ : ERR.NOT_IMPLEMENTED;
239
+ state.cmi[key] = String(value);
240
+ setLastError(code);
241
+ logCall("LMSSetValue", [key, value], "false", code);
242
+ return "false";
243
+ }
244
+ state.cmi[key] = String(value);
245
+ setLastError(ERR.OK);
246
+ logCall("LMSSetValue", [key, value], "true", ERR.OK);
247
+ return "true";
248
+ }
249
+
250
+ function lmsGetLastError() { return state.lastError; }
251
+ function lmsGetErrorString(code) { return ERR_STRINGS[String(code)] || ""; }
252
+ function lmsGetDiagnostic(code) { return lmsGetErrorString(code); }
253
+
254
+ window.API = {
255
+ LMSInitialize: lmsInit,
256
+ LMSFinish: lmsFinish,
257
+ LMSCommit: lmsCommit,
258
+ LMSGetValue: lmsGetValue,
259
+ LMSSetValue: lmsSetValue,
260
+ LMSGetLastError: lmsGetLastError,
261
+ LMSGetErrorString: lmsGetErrorString,
262
+ LMSGetDiagnostic: lmsGetDiagnostic,
263
+ };
264
+
265
+ // ---------- UI wiring -------------------------------------------------
266
+
267
+ function el(id) { return document.getElementById(id); }
268
+ function boot() {
269
+ logEl = el("log");
270
+ cmiEl = el("cmi");
271
+ countEl = el("log-count");
272
+ filterEl = el("filter");
273
+ stateEl = el("state");
274
+
275
+ el("restart").addEventListener("click", function () {
276
+ state.cmi = defaultCmi();
277
+ state.initialized = false; state.terminated = false;
278
+ state.log = []; state.lastError = ERR.OK;
279
+ logEl.innerHTML = ""; updateCount();
280
+ persist(); renderCmi();
281
+ setStateBadge("disconnected", "");
282
+ try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
283
+ el("course").src = el("course").src;
284
+ });
285
+ el("clear").addEventListener("click", function () {
286
+ state.log = []; logEl.innerHTML = ""; updateCount();
287
+ });
288
+ el("export").addEventListener("click", function () {
289
+ var dump = { exportedAt: new Date().toISOString(), cmi: state.cmi, log: state.log };
290
+ var blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
291
+ var a = document.createElement("a");
292
+ a.href = URL.createObjectURL(blob);
293
+ a.download = "scorm-session-" + Date.now() + ".json";
294
+ a.click();
295
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
296
+ });
297
+ el("fail-mode").addEventListener("change", function (e) {
298
+ state.failMode = e.target.value;
299
+ });
300
+ filterEl.addEventListener("input", function () {
301
+ [].forEach.call(logEl.children, applyFilter);
302
+ });
303
+ [].forEach.call(document.querySelectorAll(".tabs button"), function (b) {
304
+ b.addEventListener("click", function () {
305
+ document.querySelectorAll(".tabs button").forEach(function (x) { x.classList.remove("active"); });
306
+ document.querySelectorAll(".tab-pane").forEach(function (x) { x.classList.remove("active"); });
307
+ b.classList.add("active");
308
+ el("tab-" + b.dataset.tab).classList.add("active");
309
+ if (b.dataset.tab === "cmi") renderCmi();
310
+ });
311
+ });
312
+
313
+ // Load config + boot the iframe.
314
+ fetch("/config.json").then(function (r) { return r.json(); }).then(function (cfg) {
315
+ // Apply CMI presets BEFORE the course runs.
316
+ restore();
317
+ Object.keys(cfg.cmiPresets || {}).forEach(function (k) {
318
+ var fullKey = k.indexOf("cmi.") === 0 ? k : "cmi.core." + k;
319
+ state.cmi[fullKey] = cfg.cmiPresets[k];
320
+ });
321
+ state.failMode = cfg.fail || "none";
322
+ el("fail-mode").value = state.failMode;
323
+ renderCmi();
324
+ el("course").src = cfg.launchUrl;
325
+ });
326
+ }
327
+ document.addEventListener("DOMContentLoaded", boot);
328
+ })();