coursecode 0.1.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.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,615 @@
1
+ /**
2
+ * AssessmentActions - Handles all user interactions and business logic.
3
+ *
4
+ * This module orchestrates the assessment flow by responding to user actions
5
+ * (e.g., 'next', 'submit'). It uses the AssessmentState module to manage state
6
+ * and triggers UI updates via the ViewManager.
7
+ *
8
+ * This layer is also responsible for managing global UI state (footer visibility).
9
+ */
10
+
11
+ import { eventBus } from '../core/event-bus.js';
12
+ import * as AppUI from '../app/AppUI.js';
13
+ import { goToSlide } from '../navigation/NavigationActions.js';
14
+ import { getCurrentSlideId, getVisitedSlides } from '../navigation/NavigationState.js';
15
+ import { getSlideById } from '../utilities/course-helpers.js';
16
+ import objectiveManager from '../managers/objective-manager.js';
17
+ import interactionManager from '../managers/interaction-manager.js';
18
+
19
+ import globalStateManager from '../state/index.js';
20
+ import { formatLearnerResponseForScorm } from '../validation/scorm-validators.js';
21
+ import { logger } from '../utilities/logger.js';
22
+
23
+ /**
24
+ * Creates an object containing all action handlers for an assessment.
25
+ * @param {object} stateManager - The assessment-specific state manager.
26
+ * @param {object} uiManager - The assessment-specific UI manager (a view manager instance).
27
+ * @param {Array} questionInstances - An array of all question instances for the assessment.
28
+ * @param {object} config - The assessment's configuration object.
29
+ * @param {object} assessmentUI - The full assessment UI object (for accessing modal methods).
30
+ * @returns {object} An object with an `initialize` method to attach event listeners.
31
+ */
32
+ export function createAssessmentActions(stateManager, uiManager, questionInstances, config, assessmentUI) {
33
+ // FAIL FAST validation of critical parameters
34
+ if (!config || !config.id) {
35
+ throw new Error('[AssessmentActions] config with id is required');
36
+ }
37
+ if (!assessmentUI) {
38
+ throw new Error(`[AssessmentActions:${config.id}] assessmentUI parameter is required`);
39
+ }
40
+
41
+ const { settings } = config;
42
+ // All parameters are immutable for this action instance's lifetime
43
+
44
+ async function _forceSaveCurrentResponse() {
45
+ const currentIndex = stateManager.getCurrentQuestionIndex();
46
+ const questionInstance = questionInstances[currentIndex];
47
+ if (questionInstance && typeof questionInstance.persistToSCORM === 'function') {
48
+ await questionInstance.persistToSCORM();
49
+ }
50
+ }
51
+
52
+ function _manageFooterVisibility(viewName) {
53
+ if (viewName === 'question' || viewName === 'review') {
54
+ AppUI.hideFooter();
55
+ } else {
56
+ AppUI.showFooter();
57
+ }
58
+ }
59
+
60
+ async function handleStart() {
61
+ const summary = stateManager.getSummary();
62
+ // Summary initialized by Factory on first render
63
+ const attemptNumber = (summary?.attempts || 0) + 1;
64
+
65
+ await stateManager.clearSession();
66
+ await stateManager.setCurrentView('question');
67
+ await stateManager.setCurrentQuestionIndex(0);
68
+ await stateManager.setStartTime(Date.now());
69
+ await stateManager.setSubmitted(false);
70
+ await stateManager.setAttemptNumber(attemptNumber);
71
+
72
+ _manageFooterVisibility('question');
73
+ uiManager.showView('question');
74
+ }
75
+
76
+ async function handlePrev() {
77
+ await _forceSaveCurrentResponse();
78
+
79
+ const currentIndex = stateManager.getCurrentQuestionIndex();
80
+ if (currentIndex > 0) {
81
+ await stateManager.setCurrentQuestionIndex(currentIndex - 1);
82
+ uiManager.showView('question');
83
+ }
84
+ }
85
+
86
+ async function handleNext() {
87
+ await _forceSaveCurrentResponse();
88
+
89
+ const currentIndex = stateManager.getCurrentQuestionIndex();
90
+ const isLastQuestion = currentIndex === config.questions.length - 1;
91
+
92
+ if (isLastQuestion) {
93
+ if (settings.allowReview) {
94
+ await stateManager.updateSession({ reviewReached: true });
95
+ await stateManager.setCurrentView('review');
96
+ _manageFooterVisibility('review');
97
+ uiManager.showView('review');
98
+ } else {
99
+ // FIX: Route through handleSubmit to ensure unanswered checks are performed
100
+ // previously called submitAssessment() directly, bypassing validation
101
+ await handleSubmit();
102
+ }
103
+ } else {
104
+ await stateManager.setCurrentQuestionIndex(currentIndex + 1);
105
+ uiManager.showView('question');
106
+ }
107
+ }
108
+
109
+ async function handleReviewQuestion(event) {
110
+ await _forceSaveCurrentResponse();
111
+
112
+ const index = parseInt(event.target.closest('[data-question-index]').dataset.questionIndex, 10);
113
+ if (isNaN(index)) {
114
+ const errorMessage = `[AssessmentActions:${config.id}] Invalid question index from review screen`;
115
+ logger.error(errorMessage, { domain: 'assessment', operation: 'handleReviewQuestion', assessmentId: config.id });
116
+ throw new Error(errorMessage);
117
+ }
118
+
119
+ await stateManager.setCurrentView('question');
120
+ await stateManager.setCurrentQuestionIndex(index);
121
+ _manageFooterVisibility('question');
122
+ uiManager.showView('question');
123
+ }
124
+
125
+ async function handleBackToQuestions() {
126
+ await _forceSaveCurrentResponse();
127
+ await stateManager.setCurrentView('question');
128
+ _manageFooterVisibility('question');
129
+ uiManager.showView('question');
130
+ }
131
+
132
+ async function handleJumpToReview() {
133
+ await _forceSaveCurrentResponse();
134
+ await stateManager.setCurrentView('review');
135
+ _manageFooterVisibility('review');
136
+ uiManager.showView('review');
137
+ }
138
+
139
+ async function handleSubmit() {
140
+ await _forceSaveCurrentResponse();
141
+
142
+ // Check for unanswered questions before submission
143
+ // Use the same logic as review screen - delegate to interaction metadata
144
+ const session = stateManager.getSession();
145
+ const responses = session?.responses || {};
146
+ const unansweredIndices = [];
147
+
148
+ for (let i = 0; i < config.questions.length; i++) {
149
+ const _question = config.questions[i];
150
+ const response = responses[i];
151
+
152
+ // Get metadata from question instance
153
+ const metadata = questionInstances[i].metadata;
154
+
155
+ // Use interaction's isAnswered method - it knows best for its type
156
+ if (!metadata || !metadata.isAnswered(response)) {
157
+ unansweredIndices.push(i);
158
+ }
159
+ }
160
+
161
+ // Check if unanswered questions exist and how to handle them
162
+ const allowUnanswered = settings.allowUnansweredSubmission === true; // Default false (strict mode)
163
+
164
+ if (unansweredIndices.length > 0) {
165
+ // Show modal: informational if blocked, confirmation if allowed
166
+ assessmentUI.showUnansweredModal(
167
+ unansweredIndices,
168
+ allowUnanswered, // Pass whether submission is allowed
169
+ async () => {
170
+ // User confirmed submission (only called if allowUnanswered is true)
171
+ await submitAssessment();
172
+ }
173
+ );
174
+ return;
175
+ }
176
+
177
+ // Either no unanswered questions, or allowUnansweredSubmission is true - proceed
178
+ await submitAssessment();
179
+ }
180
+
181
+ function handleCheck() {
182
+ const currentIndex = stateManager.getCurrentQuestionIndex();
183
+ const questionInstance = questionInstances[currentIndex];
184
+ if (questionInstance && typeof questionInstance.checkAnswer === 'function') {
185
+ questionInstance.checkAnswer();
186
+ }
187
+ }
188
+
189
+ function handleReset() {
190
+ const currentIndex = stateManager.getCurrentQuestionIndex();
191
+ const questionInstance = questionInstances[currentIndex];
192
+ if (questionInstance && typeof questionInstance.reset === 'function') {
193
+ questionInstance.reset();
194
+ }
195
+ }
196
+
197
+ function handleHint() {
198
+ const currentIndex = stateManager.getCurrentQuestionIndex();
199
+ const questionInstance = questionInstances[currentIndex];
200
+ if (questionInstance && typeof questionInstance.showHint === 'function') {
201
+ questionInstance.showHint();
202
+ }
203
+ }
204
+
205
+ async function handleRetake() {
206
+ // Archive old responses BEFORE clearing session
207
+ const oldSession = stateManager.getSession();
208
+ if (oldSession && oldSession.responses && Object.keys(oldSession.responses).length > 0) {
209
+ await stateManager.archiveDiscardedResponses(config.questions, oldSession.responses);
210
+ }
211
+
212
+ // Reset all question instances to clear their internal state
213
+ questionInstances.forEach(questionInstance => {
214
+ if (questionInstance && typeof questionInstance.reset === 'function') {
215
+ try {
216
+ questionInstance.reset();
217
+ } catch (_e) {
218
+ // Ignore error if container is null (not rendered yet)
219
+ // This happens when retaking from results screen without viewing questions
220
+ }
221
+ }
222
+ });
223
+
224
+ // Clear session state
225
+ await stateManager.clearSession();
226
+ await stateManager.setCurrentView('intro');
227
+ await stateManager.setCurrentQuestionIndex(0);
228
+ await stateManager.setStartTime(null);
229
+ await stateManager.setSubmitted(false);
230
+ await stateManager.updateSession({ reviewReached: false });
231
+
232
+ // Check if we need to re-randomize questions
233
+ const shouldRandomizeOnRetake = settings.randomizeOnRetake !== false;
234
+ // FIX: Also check randomizeQuestions setting for direct mode (no banks)
235
+ const isRandomized = config.questionBanks || settings.randomizeQuestions;
236
+
237
+ if (shouldRandomizeOnRetake && isRandomized) {
238
+ // Clear selection to force new randomization
239
+ await stateManager.setSelectedQuestions(null);
240
+
241
+ // Navigate back to this slide with refresh flag
242
+ // This will cause slide to create NEW assessment instance
243
+ const currentSlideId = getCurrentSlideId();
244
+ if (currentSlideId) {
245
+ goToSlide(currentSlideId, { refreshAssessment: true });
246
+ return; // Exit - new instance will be created
247
+ }
248
+ }
249
+
250
+ // Simple reset - same questions
251
+ _manageFooterVisibility('intro');
252
+ uiManager.showView('intro');
253
+ }
254
+
255
+ function handleRestartCourse() {
256
+ // Show a confirmation modal before restarting.
257
+ // The actual restart logic is handled by a global listener for 'confirm-restart'.
258
+ eventBus.emit('ui:showModal', 'restart');
259
+ }
260
+
261
+ function _prepareResultsDisplayData(results) {
262
+ const summary = stateManager.getSummary();
263
+ // Summary always exists after submit
264
+ const currentAttempts = summary?.attempts || 0;
265
+ const { attemptsBeforeRemedial, attemptsBeforeRestart, allowRetake } = settings;
266
+
267
+ // Determine action button configuration
268
+ let actionButton = null;
269
+
270
+ if (!results.passed && allowRetake) {
271
+ // Check if remedial content has already been viewed
272
+ // Use NavigationState directly to get visited slides
273
+ const visitedSlides = getVisitedSlides() || [];
274
+
275
+ const hasRemedialSlides = settings.remedialSlideIds && settings.remedialSlideIds.length > 0;
276
+ const remedialViewed = hasRemedialSlides && settings.remedialSlideIds.every(id => visitedSlides.includes(id));
277
+
278
+ if (attemptsBeforeRestart && currentAttempts >= attemptsBeforeRestart) {
279
+ actionButton = {
280
+ type: 'restart',
281
+ action: 'restart-course',
282
+ label: 'Restart Course',
283
+ message: `You have completed ${currentAttempts} attempts. To try again, you must restart the entire course. This will erase all progress.`,
284
+ messageType: 'warning'
285
+ };
286
+ } else if (attemptsBeforeRemedial && currentAttempts >= attemptsBeforeRemedial && !remedialViewed) {
287
+ actionButton = {
288
+ type: 'remedial',
289
+ action: hasRemedialSlides ? 'go-to-remedial' : 'retake',
290
+ label: hasRemedialSlides ? 'Review Content' : 'Retake Assessment',
291
+ message: hasRemedialSlides
292
+ ? 'Please review the recommended content before attempting the assessment again. This will help strengthen your understanding of key concepts.'
293
+ : 'Please take time to review the course material before attempting the assessment again.',
294
+ messageType: 'info'
295
+ };
296
+ } else {
297
+ // Standard retake
298
+ let attemptsMessage = null;
299
+
300
+ // Check remedial warning first (if applicable and not passed)
301
+ if (attemptsBeforeRemedial) {
302
+ const remaining = attemptsBeforeRemedial - currentAttempts;
303
+ if (remaining > 0) {
304
+ attemptsMessage = remaining > 1
305
+ ? `${remaining} more attempts before review is recommended.`
306
+ : 'Content review will be recommended after your next attempt.';
307
+ }
308
+ }
309
+
310
+ // If no remedial warning (either not configured, or we passed it and viewed content), check restart warning
311
+ if (!attemptsMessage && attemptsBeforeRestart) {
312
+ const remaining = attemptsBeforeRestart - currentAttempts;
313
+ if (remaining > 0) {
314
+ attemptsMessage = remaining > 1
315
+ ? `${remaining} attempts remaining before course restart is required.`
316
+ : 'This is your final attempt before course restart is required.';
317
+ }
318
+ }
319
+
320
+ actionButton = {
321
+ type: 'retake',
322
+ action: 'retake',
323
+ label: 'Retake Assessment',
324
+ attemptsMessage
325
+ };
326
+ }
327
+ }
328
+
329
+ return {
330
+ ...results,
331
+ actionButton,
332
+ currentAttempts
333
+ };
334
+ }
335
+
336
+ async function handleGoToRemedial() {
337
+ const remedialSlideIds = settings.remedialSlideIds || [];
338
+ if (remedialSlideIds.length === 0) {
339
+ const error = new Error('No remedial slides configured');
340
+ logger.error(error.message, { domain: 'assessment', operation: 'handleGoToRemedial', stack: error.stack, assessmentId: config.id });
341
+ throw error;
342
+ }
343
+
344
+ // Navigate to first remedial slide
345
+ const firstRemedialSlide = remedialSlideIds[0];
346
+
347
+ // Validate slide exists before navigating
348
+ const slide = await getSlideById(firstRemedialSlide);
349
+ if (!slide) {
350
+ const errorMessage = `[AssessmentActions:${config.id}] Remedial slide '${firstRemedialSlide}' not found in course structure`;
351
+ logger.error(errorMessage, { domain: 'assessment', operation: 'handleGoToRemedial', assessmentId: config.id, remedialSlideIds });
352
+ AppUI.showNotification(`Error: Slide '${firstRemedialSlide}' not found`, 'error');
353
+ throw new Error(errorMessage);
354
+ }
355
+
356
+ goToSlide(firstRemedialSlide, {
357
+ fromAssessment: config.id,
358
+ remedialReview: true
359
+ });
360
+ }
361
+
362
+ async function submitAssessment() {
363
+ const session = stateManager.getSession();
364
+ if (!session) {
365
+ const errorMessage = `[AssessmentActions:${config.id}] No session data found, cannot submit`;
366
+ logger.error(errorMessage, { domain: 'assessment', operation: 'submitAssessment', assessmentId: config.id });
367
+ throw new Error(errorMessage);
368
+ }
369
+
370
+ const attemptNumber = stateManager.getAttemptNumber();
371
+ const finalResults = calculateFinalResults(session.responses || {}, attemptNumber);
372
+
373
+ // Calculate time spent
374
+ const startTime = stateManager.getStartTime();
375
+ if (startTime) {
376
+ const endTime = Date.now();
377
+ const durationMs = endTime - startTime;
378
+ const minutes = Math.floor(durationMs / 60000);
379
+ const seconds = Math.floor((durationMs % 60000) / 1000);
380
+ finalResults.timeSpent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
381
+ }
382
+
383
+ // OPTIMIZATION: Store only summary stats in suspend_data, not full details array
384
+ // The details array can be 2-4KB for large assessments, causing SCORM 409 errors
385
+ // Details are only needed for immediate display; on resume we show summary only
386
+ const resultsSummary = {
387
+ attemptNumber: finalResults.attemptNumber,
388
+ totalQuestions: finalResults.totalQuestions,
389
+ correctCount: finalResults.correctCount,
390
+ scorePercentage: finalResults.scorePercentage,
391
+ passed: finalResults.passed,
392
+ // Store only question IDs and correctness, not full details
393
+ questionResults: finalResults.details.map(d => ({
394
+ id: d.questionId,
395
+ correct: d.correct
396
+ }))
397
+ };
398
+
399
+ await stateManager.updateSummary({
400
+ lastResults: resultsSummary,
401
+ submitted: true,
402
+ attempts: attemptNumber,
403
+ [`attempt_${attemptNumber}`]: resultsSummary,
404
+ });
405
+
406
+ await stateManager.updateSession({
407
+ submitted: true
408
+ // Don't duplicate results in session - summary has it
409
+ });
410
+
411
+ // Prepare display data with action buttons and business logic
412
+ // Pass FULL results (with details) for immediate display only
413
+ const displayData = _prepareResultsDisplayData(finalResults);
414
+
415
+ await stateManager.setCurrentView('results');
416
+ _manageFooterVisibility('results');
417
+ uiManager.showView('results', displayData);
418
+
419
+ // Keep full results in memory for this session only (not persisted)
420
+ // This allows detailed review immediately after submission
421
+
422
+ // Automatically set linked objective if configured
423
+ if (config.assessmentObjective) {
424
+ try {
425
+ objectiveManager.setCompletionStatus(config.assessmentObjective, 'completed');
426
+ objectiveManager.setSuccessStatus(config.assessmentObjective, finalResults.passed ? 'passed' : 'failed');
427
+ } catch (error) {
428
+ const errorMessage = `Failed to update objective '${config.assessmentObjective}' after assessment submission`;
429
+ logger.error(errorMessage, { domain: 'assessment', operation: 'submitAssessment', stack: error.stack, assessmentId: config.id, objective: config.assessmentObjective });
430
+ throw new Error(errorMessage);
431
+ }
432
+ }
433
+
434
+ // Record each question response to cmi.interactions for LMS reporting
435
+ // This is separate from suspend_data persistence - purely for analytics/audit
436
+ _recordAssessmentInteractionsToCMI(finalResults, attemptNumber);
437
+
438
+ // Emit event for ScoreManager and other systems BEFORE flush
439
+ // ScoreManager listens here and calls reportScore() synchronously
440
+ eventBus.emit('assessment:submitted', {
441
+ assessmentId: config.id,
442
+ results: finalResults
443
+ });
444
+
445
+ // Flush critical assessment data + score to LMS immediately
446
+ // Assessment submission is a critical action - don't rely on debounce
447
+ await globalStateManager.flush();
448
+
449
+ if (typeof config.onComplete === 'function') {
450
+ config.onComplete(finalResults);
451
+ }
452
+ }
453
+
454
+ function calculateFinalResults(responses, attemptNumber) {
455
+ const totalQuestions = config.questions.length;
456
+ let achievedScore = 0;
457
+ let totalPossibleScore = 0;
458
+
459
+ const details = config.questions.map((q, i) => {
460
+ const questionInstance = questionInstances[i];
461
+ const response = responses[i];
462
+ const weight = q.weight;
463
+ totalPossibleScore += weight;
464
+
465
+ // Handle missing responses - treat as incorrect
466
+ let evaluation;
467
+ if (response === undefined || response === null) {
468
+ // Unanswered question - mark as incorrect
469
+ evaluation = {
470
+ correct: false,
471
+ score: 0,
472
+ feedback: 'Question was not answered'
473
+ };
474
+ } else {
475
+ evaluation = questionInstance.evaluate(response);
476
+ }
477
+
478
+ if (!evaluation || typeof evaluation.correct !== 'boolean') {
479
+ const error = new Error(`Question ${i + 1} (${q.id}) evaluate() returned invalid result`);
480
+ logger.error(error.message, { domain: 'assessment', operation: 'calculateFinalResults', stack: error.stack, assessmentId: config.id, questionIndex: i, questionId: q.id });
481
+ throw error;
482
+ }
483
+
484
+ const isCorrect = evaluation.correct;
485
+
486
+ if (isCorrect) {
487
+ achievedScore += weight;
488
+ }
489
+
490
+ // Include bank metadata if present
491
+ const detail = {
492
+ questionIndex: i,
493
+ questionId: q.id,
494
+ correct: isCorrect,
495
+ response: response,
496
+ weight: weight,
497
+ };
498
+
499
+ if (q._meta) {
500
+ detail.bankId = q._meta.bankId;
501
+ detail.originalIndex = q._meta.originalIndex;
502
+ }
503
+
504
+ return detail;
505
+ });
506
+
507
+ const scorePercentage = (totalPossibleScore > 0) ? (achievedScore / totalPossibleScore) * 100 : 0;
508
+
509
+ // Check for LMS-provided masteryScore override (cmi5 launch data)
510
+ // masteryScore is 0-1 scaled in cmi5 spec, convert to percentage
511
+ const launchData = globalStateManager.getLaunchData();
512
+ const effectivePassingScore = (launchData?.masteryScore !== null && launchData?.masteryScore !== undefined)
513
+ ? launchData.masteryScore * 100
514
+ : (settings.passingScore || 0);
515
+
516
+ const passed = scorePercentage >= effectivePassingScore;
517
+
518
+ const correctCount = details.filter(d => d.correct).length;
519
+
520
+ return {
521
+ attemptNumber,
522
+ totalQuestions,
523
+ correctCount,
524
+ scorePercentage,
525
+ passed,
526
+ details,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Records assessment question interactions to CMI for LMS reporting.
532
+ * Called after assessment submission to append each question as a CMI interaction.
533
+ * Formats learner_response according to SCORM 2004 4th Edition requirements.
534
+ * @param {Object} finalResults - The calculated results from calculateFinalResults
535
+ * @param {number} attemptNumber - Current attempt number for ID uniqueness
536
+ */
537
+ function _recordAssessmentInteractionsToCMI(finalResults, attemptNumber) {
538
+ if (!interactionManager || typeof interactionManager.record !== 'function') {
539
+ return;
540
+ }
541
+
542
+ const questions = config.questions || [];
543
+
544
+ finalResults.details.forEach((detail, i) => {
545
+ const question = questions[i];
546
+ if (!question) return;
547
+
548
+ const questionInstance = questionInstances[i];
549
+ const scormType = questionInstance?.metadata?.scormType || 'other';
550
+
551
+ // Format response according to SCORM 2004 requirements
552
+ const formattedResponse = formatLearnerResponseForScorm(scormType, detail.response);
553
+
554
+ // Build unique ID: assessmentId_questionId_attempt-N
555
+ const interactionId = `${config.id}_${detail.questionId}_attempt-${attemptNumber}`;
556
+
557
+ try {
558
+ interactionManager.record({
559
+ id: interactionId,
560
+ type: scormType,
561
+ learner_response: formattedResponse,
562
+ result: detail.correct ? 'correct' : 'incorrect',
563
+ objectives: config.assessmentObjective ? [config.assessmentObjective] : undefined,
564
+ });
565
+ } catch (err) {
566
+ logger.error(`Failed to record assessment interaction to CMI: ${err.message}`, {
567
+ domain: 'assessment', operation: 'recordInteractionsToCMI',
568
+ assessmentId: config.id, questionId: detail.questionId
569
+ });
570
+ }
571
+ });
572
+ }
573
+
574
+ function initialize(container) {
575
+ container.addEventListener('click', async (event) => {
576
+ const target = event.target.closest('[data-action]');
577
+ if (!target) return;
578
+
579
+ const action = target.dataset.action;
580
+ const actions = {
581
+ 'start': handleStart,
582
+ 'prev': handlePrev,
583
+ 'next': handleNext,
584
+ 'review-question': handleReviewQuestion,
585
+ 'back-to-questions': handleBackToQuestions,
586
+ 'jump-to-review': handleJumpToReview,
587
+ 'submit': handleSubmit,
588
+ 'check': handleCheck,
589
+ 'reset': handleReset,
590
+ 'hint': handleHint,
591
+ 'retake': handleRetake,
592
+ 'restart-course': handleRestartCourse,
593
+ 'go-to-remedial': handleGoToRemedial,
594
+ };
595
+
596
+ if (actions[action]) {
597
+ event.preventDefault();
598
+ target.disabled = true;
599
+ try {
600
+ await actions[action](event);
601
+ } catch (error) {
602
+ const errorMessage = `[AssessmentActions:${config.id}] Error in action '${action}': ${error.message}`;
603
+ logger.error(errorMessage, { domain: 'assessment', operation: action, assessmentId: config.id, stack: error.stack });
604
+ throw error;
605
+ } finally {
606
+ target.disabled = false;
607
+ }
608
+ }
609
+ });
610
+ }
611
+
612
+ return {
613
+ initialize,
614
+ };
615
+ }