diff --git a/docs/COURSE_VERIFICATION.md b/docs/COURSE_VERIFICATION.md index 6eb39db..636879d 100644 --- a/docs/COURSE_VERIFICATION.md +++ b/docs/COURSE_VERIFICATION.md @@ -145,3 +145,40 @@ confirm `play.php` returns 200, export a SCORM package, and grep the exported Welcome page and the question count / `trackingWeight` / `trackingPassed` / empty-option count are unchanged. Then re-test on LMS that a completed attempt reports `lesson_status=passed` and a non-zero grade. + +## SCORM autosave on progress (root `script` attribute) + +**Problem**: results (`cmi.core.score.raw`, `cmi.core.lesson_status`, +interactions, `suspend_data`) were only reported to the LMS when the learner +clicked **Save Session** (or the window unloaded). The engine's +`exitInteraction()` calls `LMSSetValue` but never `LMSCommit`, and +`finishTracking()` (which commits and sets score/status) runs only at +`XTTerminate()` (Save / `onbeforeunload`). So nothing flushed mid-session. + +**Fix applied** (content-level, no engine change): a `script="..."` attribute +on the `` root in `source/data.xml` + `source/preview.xml` — +an official Nottingham root property (wizard `data.xwd` line 261) that xenith +injects once globally before pages load. The script wraps the global +`XTExitPage`/`XTExitInteraction` to call a `persist()` helper after every page +leave / quiz answer: it sets `cmi.core.lesson_status`, `cmi.core.score.raw/min/max`, +`cmi.core.exit`, `cmi.suspend_data` from the engine's own global `state`, then +calls `doLMSCommit()`. A 30 s heartbeat and `pagehide` listener are safety +nets. Guards make it a no-op outside a live SCORM `normal` session. + +**Verified**: +- `play.php` returns 200; editor opens (200) and a **Publish round-trip + preserved the `script` attribute** in `data.xml` byte-for-byte (editor loads + all root attrs via `build_lo_data`; `upload.php` `process()` re-adds every + attr — no filtering). +- Fresh SCORM export's `template.xml` carries the script (`xPersistProgress` + count = 1); all content/tracking attrs unchanged (45 questions, 80% pass, + `trackingMode=full`, `trackingWeight` 1×7 + 21, `unmarkForCompletion`×1, 0 + empty options, 27 `delaySecs=0`). +- Mock SCORM 1.2 API harness loading the **original unpatched engine JS** + + this script: **5 commits fire during progress** (score 0→100 as a quiz is + answered) vs **0** without the script (only 1 commit on terminate). See + `PROJECT_CONTEXT.md` § "SCORM autosave on progress" for full detail. + +**Pending (convention 9 — re-test on LMS)**: confirm a learner attempt now +shows the gradebook updating incrementally (not only on Save), and that a +completed attempt still reports `lesson_status=passed` with a non-zero grade. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 976ff97..0a5642d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -69,6 +69,9 @@ with agents and building agents. - **No Trial MCQ** (an early trial page was removed). - **Welcome page carries `unmarkForCompletion="true"`** — see "SCORM completion bug fix" below for why; it is the fix for LMS completion tracking. +- **Root `` carries a `script="..."` attribute** (SCORM + autosave) — see "SCORM autosave on progress" below; it makes results report + to the LMS as the learner progresses instead of only on Save Session. - `play.php` returns 200; the export is `Secure_code_development_scorm.zip`. ## Conventions (MANDATORY for any content changes) @@ -191,6 +194,69 @@ persist across XOT rebuilds. a replacement fix — re-introducing the off-by-one will silently break LMS completion reporting again. +## SCORM autosave on progress (root `script` attribute) + +**Symptom**: on LMS, results (`cmi.core.score.raw`, `cmi.core.lesson_status`, +interactions, `suspend_data`) were only reported to the LMS when the learner +clicked **Save Session** (or the window unloaded). If the LMS viewer unloaded +the SCO without reliably firing `onbeforeunload`, nothing was reported and the +gradebook stayed empty mid-attempt. + +**Root cause** (Xerte Nottingham engine behaviour, **not** a bug we can fix in +content via the engine files — the release workflow builds the engine from the +`docker-container` branch of `RichardoC/xerteonlinetoolkits`, so engine JS +edits do not persist): in `xttracking_scorm1.2.js`, `exitInteraction()` (called +on every page leave / quiz answer) issues many `LMSSetValue` calls but **never** +calls `LMSCommit`. `doLMSCommit()` is called **only** inside `finishTracking()`, +which is called **only** from `XTTerminate()` (Save Session / `onbeforeunload`). +SCORM 1.2 only persists `LMSSetValue` to the LMS backend on `LMSCommit`, so all +progress stayed in the API adapter's in-memory model until terminate. Worse, +`cmi.core.score.raw` and `cmi.core.lesson_status` are *set* only inside +`finishTracking()`, so even a mid-session commit would not update the grade. + +**Fix** (content-level, no engine/vendoring change): a `script="..."` attribute +on the `` root in `source/data.xml` + `source/preview.xml`. +`script` is an **official, optional Nottingham root property** (wizard +`data.xwd` line 261: *"Add JavaScript to this project. The code will run after +the project interface has been set up but before any pages have loaded"*); +xenith.js injects it once globally via `$x_head.append('')` (line 2269). The script: + +1. Defines `persist()` — replays the score/status half of `finishTracking()` + (`cmi.core.lesson_status`, `cmi.core.score.raw/min/max`, `cmi.core.exit`, + `cmi.suspend_data`) then calls `doLMSCommit()`, using the engine's own global + `state` object (`state.getSuccessStatus()`, `state.getRawScore()`, + `state.getVars()`). Guards on `state.initialised`, `state.scormmode === + 'normal'`, `state.trackingmode !== 'none'`, `state.finished`, and the + presence of `doLMSCommit`/`state.getSuccessStatus` so it is a no-op outside a + live SCORM `normal` session (e.g. `play.php`, xAPI, noop tracking). +2. Wraps the global `XTExitPage` and `XTExitInteraction` so every page leave and + quiz answer flushes immediately — the gradebook updates as the learner + progresses. +3. A 30 s `setInterval` heartbeat as a safety net (idle learners, or LMS + viewers that unload without events), and a `pagehide` listener as a backup + flush if `onbeforeunload` does not fire. + +**Why this is safe and durable**: it is content, so the release workflow +exports it as part of `data.xml`/`template.xml` with no engine change. The +editor round-trips it: `build_lo_data` (toolbox.js) loads **all** root +attributes including `script`, and `editor/upload.php` `process()` re-adds +**every** attribute via `addAttribute` (no filtering by wizard-declared names). +Verified by an editor open → Publish round-trip: `script` survived in +`data.xml` byte-for-byte (timestamp updated, attribute intact). `makeAbsolute` +only rewrites `FileLocation + '...'` media refs (absent from the script) and +`addLineBreaks` only applies to textinput/textarea types, so the JS is not +mangled. The script attribute value contains no XML-special characters (`&`, +`<`, `>`, `"` — single quotes only, `!== -1` instead of `>= 0`, nested `if`s +instead of `&&`), so it sits raw in the XML with no escaping concerns. + +**Do not remove or empty the root `script` attribute** without a replacement — +re-introducing the terminate-only commit will silently revert to +"results only on Save". If the engine is ever patched upstream to commit on +`exitInteraction`, this `script` becomes redundant (harmless — its `persist()` +would just call `doLMSCommit` a second time, which is idempotent) and can be +dropped. + ## SCORM scoring (how the LMS grade is computed) SCORM 1.2 reports **one** `cmi.core.score.raw` (0–100) and **one** @@ -294,6 +360,8 @@ grep -oE 'judge="true"' /tmp/c.xml | wc -l # 8 grep -oE 'trackingWeight="[0-9]+"' /tmp/c.xml # 7x "1" + 1x "21" grep -oE 'trackingMode="[a-z_]+"' /tmp/c.xml # "full" grep -oE 'delaySecs="0"' /tmp/c.xml | wc -l # 27 (all bullets pages) +grep -c 'xPersistProgress' /tmp/c.xml # 1 (SCORM autosave script present on root) +grep -oE 'unmarkForCompletion="true"' /tmp/c.xml | wc -l # 1 (Welcome page) # empty options (must be 0): python3 -c "import re,html as H;x=open('/tmp/c.xml').read();print(sum(1 for m in re.finditer(r'