coursecode 0.1.56 → 0.1.58

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.
Files changed (86) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +49 -5
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +28 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +6 -2
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/package.json +1 -1
  86. package/template/vite.config.js +50 -22
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file standalone-driver.js
3
+ * @description Local persistence driver for portable, single-file HTML courses.
4
+ */
5
+
6
+ import { HttpDriverBase } from './http-driver-base.js';
7
+ import { logger } from '../utilities/logger.js';
8
+
9
+ function slug(value) {
10
+ return String(value || 'course')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-|-$/g, '')
14
+ .slice(0, 80) || 'course';
15
+ }
16
+
17
+ export class StandaloneDriver extends HttpDriverBase {
18
+ constructor() {
19
+ super();
20
+ const courseId = typeof document !== 'undefined'
21
+ ? (document.querySelector('meta[name="coursecode-id"]')?.content || document.title)
22
+ : 'course';
23
+ this._storageKey = `coursecode-portable:${slug(courseId)}`;
24
+ this._storageAvailable = true;
25
+ }
26
+
27
+ getFormat() {
28
+ return 'standalone';
29
+ }
30
+
31
+ getCapabilities() {
32
+ return {
33
+ supportsObjectives: true,
34
+ supportsInteractions: true,
35
+ supportsComments: true,
36
+ supportsEmergencySave: true,
37
+ maxSuspendDataBytes: 0,
38
+ asyncCommit: false
39
+ };
40
+ }
41
+
42
+ async initialize() {
43
+ if (this._isConnected) return true;
44
+ this._mock = true;
45
+ this._loadMockState();
46
+ this._isConnected = true;
47
+ this._isTerminated = false;
48
+ logger.debug('[StandaloneDriver] Portable course initialized', {
49
+ restoreAvailable: this._storageAvailable,
50
+ resumed: Boolean(this._bookmarkCache)
51
+ });
52
+ return true;
53
+ }
54
+
55
+ async terminate() {
56
+ if (!this._isConnected || this._isTerminated) return true;
57
+ this._saveMockState();
58
+ this._isTerminated = true;
59
+ return true;
60
+ }
61
+
62
+ emergencySave() {
63
+ this._saveMockState();
64
+ }
65
+
66
+ getLearnerInfo() {
67
+ return { id: 'local-learner', name: 'Learner' };
68
+ }
69
+
70
+ getLaunchData() {
71
+ return null;
72
+ }
73
+
74
+ async _persistState() {
75
+ this._saveMockState();
76
+ }
77
+
78
+ _loadMockState() {
79
+ let stored;
80
+ try {
81
+ stored = localStorage.getItem(this._storageKey);
82
+ } catch (error) {
83
+ this._storageAvailable = false;
84
+ logger.warn('[StandaloneDriver] Browser restore storage is unavailable; progress will last for this session only', {
85
+ domain: 'standalone',
86
+ operation: 'loadState',
87
+ error: error.message
88
+ });
89
+ return;
90
+ }
91
+
92
+ if (!stored) return;
93
+ try {
94
+ const state = JSON.parse(stored);
95
+ if (!state || typeof state !== 'object') return;
96
+
97
+ this._mockState = state;
98
+ this._bookmarkCache = state.bookmark || null;
99
+ this._completionStatus = state.completionStatus || 'unknown';
100
+ this._successStatus = state.successStatus || 'unknown';
101
+ this._score = state.score ?? null;
102
+ } catch (error) {
103
+ this._mockState = {};
104
+ logger.warn('[StandaloneDriver] Ignoring invalid saved progress and starting a new local session', {
105
+ domain: 'standalone',
106
+ operation: 'parseState',
107
+ error: error.message
108
+ });
109
+ try {
110
+ localStorage.removeItem(this._storageKey);
111
+ } catch {
112
+ this._storageAvailable = false;
113
+ }
114
+ }
115
+ }
116
+
117
+ _saveMockState() {
118
+ const state = {
119
+ ...this._mockState,
120
+ bookmark: this._bookmarkCache,
121
+ completionStatus: this._completionStatus,
122
+ successStatus: this._successStatus,
123
+ score: this._score
124
+ };
125
+ this._mockState = state;
126
+
127
+ if (!this._storageAvailable) return;
128
+ try {
129
+ localStorage.setItem(this._storageKey, JSON.stringify(state));
130
+ } catch (error) {
131
+ this._storageAvailable = false;
132
+ logger.warn('[StandaloneDriver] Could not persist portable course progress', {
133
+ domain: 'standalone',
134
+ operation: 'saveState',
135
+ error: error.message
136
+ });
137
+ }
138
+ }
139
+ }
@@ -16,34 +16,49 @@ import { logger } from '../utilities/logger.js';
16
16
  /**
17
17
  * Creates a batch registration function that sets tracked[totalField] = ids.length.
18
18
  */
19
- function makeRegister(totalField, label) {
19
+ function makeRegister(totalField, label, registeredField = null) {
20
20
  // Derive the viewed field name from the total field (e.g., 'tabsTotal' → 'tabsViewed')
21
21
  const viewedField = totalField.replace('Total', 'Viewed');
22
22
  return function (slideId, ids) {
23
23
  if (!slideId || !Array.isArray(ids)) return;
24
24
  const state = this._getState();
25
25
  if (!state[slideId]) return;
26
- state[slideId].tracked[totalField] = ids.length;
27
- // Reset viewed array on re-registration to prevent viewed > total inconsistency
28
- state[slideId].tracked[viewedField] = [];
26
+ const tracked = state[slideId].tracked;
27
+ const registeredIds = [...new Set(ids.filter(Boolean))];
28
+ const registeredIdSet = new Set(registeredIds);
29
+
30
+ tracked[totalField] = registeredIds.length;
31
+ if (registeredField) {
32
+ tracked[registeredField] = registeredIds;
33
+ }
34
+ // Views render fresh during navigation. Preserve valid persisted progress
35
+ // while pruning IDs that no longer exist in the authored content.
36
+ tracked[viewedField] = [...new Set(tracked[viewedField] || [])]
37
+ .filter(id => registeredIdSet.has(id));
29
38
  this._setState(state);
30
- logger.debug(`[EngagementManager] Registered ${ids.length} ${label}: ${slideId}`);
39
+ logger.debug(`[EngagementManager] Registered ${registeredIds.length} ${label}: ${slideId}`);
31
40
  this._checkAndEmitProgress(slideId);
32
41
  };
33
42
  }
34
43
 
35
44
  /**
36
- * Creates an incremental registration function that adds to total and inits viewed array.
45
+ * Creates a deduplicating incremental registration function for components
46
+ * that can register independently within the same rendered view.
37
47
  */
38
- function makeRegisterIncremental(totalField, viewedField, label) {
48
+ function makeRegisterIncremental(totalField, registeredField, label) {
49
+ const viewedField = totalField.replace('Total', 'Viewed');
39
50
  return function (slideId, ids) {
40
51
  if (!slideId || !Array.isArray(ids)) return;
41
52
  const state = this._getState();
42
53
  if (!state[slideId]) return;
43
- state[slideId].tracked[totalField] = (state[slideId].tracked[totalField] || 0) + ids.length;
44
- if (!state[slideId].tracked[viewedField]) {
45
- state[slideId].tracked[viewedField] = [];
54
+ const tracked = state[slideId].tracked;
55
+ if (!Array.isArray(tracked[viewedField])) {
56
+ tracked[viewedField] = [];
46
57
  }
58
+ const registeredIds = new Set(tracked[registeredField] || []);
59
+ ids.filter(Boolean).forEach(id => registeredIds.add(id));
60
+ tracked[registeredField] = [...registeredIds];
61
+ tracked[totalField] = registeredIds.size;
47
62
  this._setState(state);
48
63
  logger.debug(`[EngagementManager] Registered ${ids.length} ${label}: ${slideId}`);
49
64
  this._checkAndEmitProgress(slideId);
@@ -101,10 +116,16 @@ export const registerTimeline = makeRegister('timelineEventsTotal', 'timeline ev
101
116
  export const registerModals = makeRegister('modalsTotal', 'modals');
102
117
 
103
118
  export const registerInteractiveImage = makeRegisterIncremental(
104
- 'interactiveImageHotspotsTotal', 'interactiveImageHotspotsViewed', 'hotspots'
119
+ 'interactiveImageHotspotsTotal', 'interactiveImageHotspotsRegistered', 'hotspots'
105
120
  );
106
121
  export const registerLightbox = makeRegisterIncremental(
107
- 'lightboxesTotal', 'lightboxesViewed', 'lightboxes'
122
+ 'lightboxesTotal', 'lightboxesRegistered', 'lightboxes'
123
+ );
124
+ export const registerInteractiveImages = makeRegister(
125
+ 'interactiveImageHotspotsTotal', 'hotspots', 'interactiveImageHotspotsRegistered'
126
+ );
127
+ export const registerLightboxes = makeRegister(
128
+ 'lightboxesTotal', 'lightboxes', 'lightboxesRegistered'
108
129
  );
109
130
 
110
131
  // =========================================================================
@@ -153,6 +174,10 @@ export const trackSlideVideoComplete = makeBoolTracker('videoComplete', 'Slide v
153
174
  /** Interaction tracking — uses object map keyed by interactionId, not array. */
154
175
  export function trackInteraction(slideId, interactionId, completed, correct) {
155
176
  if (!slideId || !interactionId) return;
177
+ if (typeof completed !== 'boolean' || typeof correct !== 'boolean') {
178
+ logger.warn(`[EngagementManager] Ignoring invalid interaction state for ${interactionId}; completed and correct must be booleans`);
179
+ return;
180
+ }
156
181
  const state = this._getState();
157
182
  if (!state[slideId]) return;
158
183
  state[slideId].tracked.interactionsCompleted[interactionId] = { completed, correct };
@@ -163,7 +188,7 @@ export function trackInteraction(slideId, interactionId, completed, correct) {
163
188
 
164
189
  /** Scroll depth — numeric high-water mark, not array/boolean. */
165
190
  export function trackScrollDepth(slideId, percentage) {
166
- if (!slideId || typeof percentage !== 'number') return;
191
+ if (!slideId || !Number.isFinite(percentage)) return;
167
192
  const state = this._getState();
168
193
  if (!state[slideId]) return;
169
194
  const currentDepth = state[slideId].tracked.scrollDepth;
@@ -250,7 +250,11 @@ export default strategies;
250
250
  export const validTypes = Object.keys(strategies);
251
251
 
252
252
  // ── Core fields not owned by any single strategy ────────────────────
253
- const CORE_FIELDS = { flipCardsRegistered: [] };
253
+ const CORE_FIELDS = {
254
+ flipCardsRegistered: [],
255
+ interactiveImageHotspotsRegistered: [],
256
+ lightboxesRegistered: []
257
+ };
254
258
 
255
259
  /**
256
260
  * Aggregate all tracked field defaults from strategy declarations.
@@ -51,6 +51,12 @@ import { initErrorReporter } from './utilities/error-reporter.js';
51
51
  import { initDataReporter, reportData } from './utilities/data-reporter.js';
52
52
  import { initCourseChannel } from './utilities/course-channel.js';
53
53
  import { canvasSlide } from './utilities/canvas-slide.js';
54
+ import { hydratePortableAssetObject } from './utilities/portable-assets.js';
55
+
56
+ // Portable exports inject their asset map before this module runs. Hydrating
57
+ // config here covers branding and programmatic media sources before managers
58
+ // cache any paths. This is a no-op in LMS and preview builds.
59
+ hydratePortableAssetObject(courseConfig);
54
60
 
55
61
  // Expose framework modules globally IMMEDIATELY for bundled course slides
56
62
  // This MUST happen before any slide code executes (which happens during glob import)
@@ -615,6 +621,7 @@ async function initializeCourseApplication() {
615
621
  await audioManager.load(slide.audio, toSlideId, 'slide');
616
622
  logger.debug(`[AudioManager] Loaded audio for slide: ${toSlideId}`);
617
623
  } catch (error) {
624
+ if (error.name === 'AbortError') return;
618
625
  logger.error(`[AudioManager] Failed to load audio for slide ${toSlideId}:`, error);
619
626
  // Continue - don't let audio errors break navigation
620
627
  }
@@ -17,6 +17,21 @@
17
17
  import { deepMerge } from '../utilities/utilities.js';
18
18
  import stateManager from '../state/index.js';
19
19
  import { createAssessmentInstance } from '../assessment/AssessmentFactory.js';
20
+
21
+ /**
22
+ * Resolves the runtime assessment ID from a derived slide entry or a raw
23
+ * course-structure entry. CourseHelpers normally supplies assessmentId from
24
+ * the assessment module; slide.id is the documented authoring fallback.
25
+ * @param {object} slide
26
+ * @returns {string}
27
+ */
28
+ export function resolveAssessmentId(slide) {
29
+ const assessmentId = slide?.assessmentId || slide?.id;
30
+ if (!assessmentId || typeof assessmentId !== 'string') {
31
+ throw new Error('AssessmentManager: assessment slide requires assessmentId or id');
32
+ }
33
+ return assessmentId;
34
+ }
20
35
  import { logger } from '../utilities/logger.js';
21
36
 
22
37
  /**
@@ -190,7 +205,7 @@ export function allAssessmentsMeetRequirements(assessmentSlides = []) {
190
205
  }
191
206
 
192
207
  return assessmentsWithRequirements.every(slide =>
193
- meetsCompletionRequirements(slide.assessmentId, slide.assessment.completionRequirements)
208
+ meetsCompletionRequirements(resolveAssessmentId(slide), slide.assessment.completionRequirements)
194
209
  );
195
210
  }
196
211
 
@@ -18,6 +18,11 @@
18
18
  import { eventBus } from '../core/event-bus.js';
19
19
  import stateManager from '../state/index.js';
20
20
  import { logger } from '../utilities/logger.js';
21
+ import { resolvePortableAssetUrl } from '../utilities/portable-assets.js';
22
+ import {
23
+ DEFAULT_MEDIA_COMPLETION_THRESHOLD,
24
+ normalizeCompletionThreshold
25
+ } from '../utilities/media-utils.js';
21
26
 
22
27
  /**
23
28
  * @typedef {Object} AudioState
@@ -42,9 +47,6 @@ import { logger } from '../utilities/logger.js';
42
47
  * @property {number} [completionThreshold=0.95] - Percentage (0-1) required for completion
43
48
  */
44
49
 
45
- /** Default completion threshold (95%) */
46
- const DEFAULT_COMPLETION_THRESHOLD = 0.95;
47
-
48
50
  class AudioManager {
49
51
  constructor() {
50
52
  /** @type {HTMLAudioElement|null} */
@@ -61,7 +63,7 @@ class AudioManager {
61
63
  duration: 0,
62
64
  volume: 1,
63
65
  required: false,
64
- completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
66
+ completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
65
67
  isCompleted: false
66
68
  };
67
69
 
@@ -85,6 +87,9 @@ class AudioManager {
85
87
 
86
88
  /** @type {Function|null} - Cleanup function for pending load operation */
87
89
  this._pendingLoadCleanup = null;
90
+
91
+ /** @type {Function|null} - Rejects a pending load when its source is replaced */
92
+ this._pendingLoadCancel = null;
88
93
  }
89
94
 
90
95
  /**
@@ -262,7 +267,7 @@ class AudioManager {
262
267
  this.state.isPlaying = false; // Reset - new audio isn't playing
263
268
  this.state.position = 0; // Reset position for new audio
264
269
  this.state.required = config.required || false;
265
- this.state.completionThreshold = config.completionThreshold ?? DEFAULT_COMPLETION_THRESHOLD;
270
+ this.state.completionThreshold = normalizeCompletionThreshold(config.completionThreshold);
266
271
  this.maxPositionReached = 0;
267
272
 
268
273
  // Check if already completed (from previous session)
@@ -279,10 +284,7 @@ class AudioManager {
279
284
  });
280
285
 
281
286
  // Clean up any pending load operation before starting new one
282
- if (this._pendingLoadCleanup) {
283
- this._pendingLoadCleanup();
284
- this._pendingLoadCleanup = null;
285
- }
287
+ this._cancelPendingLoad();
286
288
 
287
289
  // Set flag to ignore MEDIA_ERR_ABORTED during source switch
288
290
  this._isSwitchingSource = true;
@@ -353,11 +355,25 @@ class AudioManager {
353
355
  const cleanup = () => {
354
356
  this.audio.removeEventListener('canplaythrough', onLoaded);
355
357
  this.audio.removeEventListener('error', onError);
356
- this._pendingLoadCleanup = null;
358
+ if (this._pendingLoadCleanup === cleanup) {
359
+ this._pendingLoadCleanup = null;
360
+ this._pendingLoadCancel = null;
361
+ }
362
+ };
363
+
364
+ const cancel = () => {
365
+ if (resolved) return;
366
+ resolved = true;
367
+ this._isSwitchingSource = false;
368
+ cleanup();
369
+ const error = new Error(`Audio load cancelled: ${audioSrc}`);
370
+ error.name = 'AbortError';
371
+ reject(error);
357
372
  };
358
373
 
359
374
  // Store cleanup function so it can be called if load is cancelled
360
375
  this._pendingLoadCleanup = cleanup;
376
+ this._pendingLoadCancel = cancel;
361
377
 
362
378
  // Add event listeners BEFORE setting src
363
379
  this.audio.addEventListener('canplaythrough', onLoaded);
@@ -455,6 +471,10 @@ class AudioManager {
455
471
  */
456
472
  seek(position) {
457
473
  this._requireInitialized();
474
+
475
+ if (!Number.isFinite(position)) {
476
+ throw new Error(`AudioManager.seek: position must be a finite number, got ${position}`);
477
+ }
458
478
 
459
479
  if (!this.state.currentSrc || !this.audio.duration) {
460
480
  return;
@@ -533,10 +553,7 @@ class AudioManager {
533
553
  this._savePosition();
534
554
 
535
555
  // Clean up any pending load operation
536
- if (this._pendingLoadCleanup) {
537
- this._pendingLoadCleanup();
538
- this._pendingLoadCleanup = null;
539
- }
556
+ this._cancelPendingLoad();
540
557
 
541
558
  // Save contextType before clearing state (needed for event)
542
559
  const contextType = this.state.contextType;
@@ -569,7 +586,7 @@ class AudioManager {
569
586
  duration: 0,
570
587
  volume: this.state.volume,
571
588
  required: false,
572
- completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
589
+ completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
573
590
  isCompleted: false
574
591
  };
575
592
  this.maxPositionReached = 0;
@@ -578,6 +595,20 @@ class AudioManager {
578
595
  eventBus.emit('audio:unloaded', { contextType });
579
596
  }
580
597
 
598
+ /**
599
+ * Settles and cleans up an in-progress load before replacing its source.
600
+ * @private
601
+ */
602
+ _cancelPendingLoad() {
603
+ if (this._pendingLoadCancel) {
604
+ this._pendingLoadCancel();
605
+ } else if (this._pendingLoadCleanup) {
606
+ this._pendingLoadCleanup();
607
+ this._pendingLoadCleanup = null;
608
+ }
609
+ this._pendingLoadCancel = null;
610
+ }
611
+
581
612
  /**
582
613
  * Gets the current audio state.
583
614
  * @returns {AudioState}
@@ -679,6 +710,9 @@ class AudioManager {
679
710
  }
680
711
  }
681
712
  }
713
+
714
+ const portableSrc = resolvePortableAssetUrl(resolvedSrc);
715
+ if (portableSrc !== resolvedSrc) return portableSrc;
682
716
 
683
717
  // If already a full path or URL, return as-is
684
718
  if (resolvedSrc.startsWith('http') || resolvedSrc.startsWith('/') || resolvedSrc.startsWith('./')) {
@@ -760,7 +794,7 @@ class AudioManager {
760
794
  * @private
761
795
  */
762
796
  _savePosition() {
763
- if (!this.state.contextId || !this.state.position) return;
797
+ if (!this.state.contextId || !Number.isFinite(this.state.position)) return;
764
798
 
765
799
  this.positionCache.set(this.state.contextId, this.state.position);
766
800
  this._persistPositions();
@@ -48,6 +48,7 @@ class FlagManager {
48
48
  * @returns {any} The value of the flag, or undefined if not found.
49
49
  */
50
50
  getFlag(key) {
51
+ if (!Object.hasOwn(this.flags, key)) return undefined;
51
52
  const val = this.flags[key];
52
53
  return (val && typeof val === 'object') ? deepClone(val) : val;
53
54
  }
@@ -62,6 +63,9 @@ class FlagManager {
62
63
  if (typeof key !== 'string' || key.trim() === '') {
63
64
  throw new Error('FlagManager: setFlag requires a non-empty string key.');
64
65
  }
66
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
67
+ throw new Error(`FlagManager: reserved flag key "${key}" is not allowed.`);
68
+ }
65
69
 
66
70
  this.flags[key] = value;
67
71
 
@@ -74,7 +78,7 @@ class FlagManager {
74
78
  * @param {string} key - The key for the flag to remove.
75
79
  */
76
80
  removeFlag(key) {
77
- if (this.flags.hasOwnProperty(key)) {
81
+ if (Object.hasOwn(this.flags, key)) {
78
82
  delete this.flags[key];
79
83
  stateManager.setDomainState(this.DOMAIN_KEY, this.flags, { source: this.SOURCE });
80
84
  eventBus.emit('flag:removed', { key });
@@ -147,6 +147,15 @@ class ObjectiveManager {
147
147
  }
148
148
  }
149
149
 
150
+ if (objectiveData.success_status !== undefined &&
151
+ !ObjectiveManager.VALID_SUCCESS_STATUSES.includes(objectiveData.success_status)) {
152
+ throw new Error(`ObjectiveManager: Invalid success_status "${objectiveData.success_status}". Must be one of: ${ObjectiveManager.VALID_SUCCESS_STATUSES.join(', ')}`);
153
+ }
154
+ if (objectiveData.completion_status !== undefined &&
155
+ !ObjectiveManager.VALID_COMPLETION_STATUSES.includes(objectiveData.completion_status)) {
156
+ throw new Error(`ObjectiveManager: Invalid completion_status "${objectiveData.completion_status}". Must be one of: ${ObjectiveManager.VALID_COMPLETION_STATUSES.join(', ')}`);
157
+ }
158
+
150
159
  const existing = this.objectives[id] || { id };
151
160
  const updated = { ...existing, ...objectiveData };
152
161
 
@@ -178,7 +187,7 @@ class ObjectiveManager {
178
187
  * Valid completion status values per SCORM spec.
179
188
  * @private
180
189
  */
181
- static VALID_COMPLETION_STATUSES = ['completed', 'incomplete'];
190
+ static VALID_COMPLETION_STATUSES = ['completed', 'incomplete', 'not attempted', 'unknown'];
182
191
 
183
192
  /**
184
193
  * A helper method to specifically update the success status of an objective.
@@ -200,12 +209,11 @@ class ObjectiveManager {
200
209
  if (!objective) {
201
210
  throw new Error(`ObjectiveManager: Objective with id "${id}" not found.`);
202
211
  }
203
- objective.success_status = success_status;
204
- this.setObjective(objective);
205
- if (score !== null) {
206
- // Validate score through setScore's guard (which will persist separately)
207
- this.setScore(id, score);
208
- }
212
+ this.setObjective({
213
+ ...objective,
214
+ success_status,
215
+ ...(score !== null ? { score } : {})
216
+ });
209
217
  }
210
218
 
211
219
  /**
@@ -100,10 +100,24 @@ class ScoreManager {
100
100
  throw new Error('[ScoreManager] Configuration must include non-empty "sources" array');
101
101
  }
102
102
 
103
+ const sourceIds = config.sources.map(source =>
104
+ typeof source === 'string' ? source : source?.id
105
+ );
106
+ const invalidSourceId = sourceIds.find(sourceId =>
107
+ typeof sourceId !== 'string' || !/^(assessment|objective):.+/.test(sourceId)
108
+ );
109
+ if (invalidSourceId !== undefined) {
110
+ throw new Error(`[ScoreManager] Invalid source ID "${invalidSourceId}". Use "assessment:<id>" or "objective:<id>".`);
111
+ }
112
+ if (new Set(sourceIds).size !== sourceIds.length) {
113
+ throw new Error('[ScoreManager] Scoring source IDs must be unique');
114
+ }
115
+
103
116
  // Validate weighted sources
104
117
  if (config.type === 'weighted') {
105
118
  const hasInvalidSources = config.sources.some(source =>
106
- typeof source !== 'object' || !source.id || typeof source.weight !== 'number'
119
+ !source || typeof source !== 'object' || !source.id ||
120
+ !Number.isFinite(source.weight) || source.weight < 0 || source.weight > 1
107
121
  );
108
122
  if (hasInvalidSources) {
109
123
  throw new Error('[ScoreManager] Weighted scoring requires sources with {id, weight} format');
@@ -167,7 +181,7 @@ class ScoreManager {
167
181
  // Load objective score from ObjectiveManager
168
182
  try {
169
183
  const objective = objectiveManager.getObjective(id);
170
- if (objective && typeof objective.score === 'number') {
184
+ if (objective && Number.isFinite(objective.score) && objective.score >= 0 && objective.score <= 100) {
171
185
  this.cachedScores[sourceId] = objective.score;
172
186
  }
173
187
  } catch (_error) {
@@ -179,7 +193,10 @@ class ScoreManager {
179
193
  const domainKey = `assessment_${id}`;
180
194
  const assessmentState = stateManager.getDomainState(domainKey);
181
195
  const summary = assessmentState?.summary;
182
- if (summary && summary.lastResults && typeof summary.lastResults.scorePercentage === 'number') {
196
+ if (summary && summary.lastResults &&
197
+ Number.isFinite(summary.lastResults.scorePercentage) &&
198
+ summary.lastResults.scorePercentage >= 0 &&
199
+ summary.lastResults.scorePercentage <= 100) {
183
200
  this.cachedScores[sourceId] = summary.lastResults.scorePercentage;
184
201
  }
185
202
  } catch (_error) {
@@ -200,6 +217,10 @@ class ScoreManager {
200
217
  _updateSourceScore(sourceId, score) {
201
218
  if (!this.isInitialized) return;
202
219
 
220
+ if (!Number.isFinite(score) || score < 0 || score > 100) {
221
+ throw new Error(`[ScoreManager] Source score ${score} is out of range [0-100].`);
222
+ }
223
+
203
224
  // Check if this source is configured
204
225
  const sourceIds = this.config.sources.map(source =>
205
226
  typeof source === 'string' ? source : source.id
@@ -213,7 +234,7 @@ class ScoreManager {
213
234
  const oldScore = this.cachedScores[sourceId];
214
235
  this.cachedScores[sourceId] = score;
215
236
 
216
- logger.debug(`[ScoreManager] Score updated: ${sourceId} = ${score} (was ${oldScore || 'none'})`);
237
+ logger.debug(`[ScoreManager] Score updated: ${sourceId} = ${score} (was ${oldScore ?? 'none'})`);
217
238
 
218
239
  // Recalculate and report course score
219
240
  this._calculateAndReportScore();
@@ -234,7 +255,7 @@ class ScoreManager {
234
255
  }
235
256
 
236
257
  // Validate score range
237
- if (calculatedScore < 0 || calculatedScore > 100 || isNaN(calculatedScore)) {
258
+ if (!Number.isFinite(calculatedScore) || calculatedScore < 0 || calculatedScore > 100) {
238
259
  throw new Error(`[ScoreManager] Calculated score ${calculatedScore} is out of range [0-100]. This indicates a bug in the scoring calculation.`);
239
260
  }
240
261
 
@@ -381,7 +402,7 @@ class ScoreManager {
381
402
  return null;
382
403
  }
383
404
 
384
- if (typeof result !== 'number' || isNaN(result)) {
405
+ if (!Number.isFinite(result)) {
385
406
  throw new Error(`[ScoreManager] Custom calculate function must return a number or null. Got: ${typeof result}`);
386
407
  }
387
408