diff --git a/docs/TRACKING.md b/docs/TRACKING.md index 88c7f0a..76ea15d 100644 --- a/docs/TRACKING.md +++ b/docs/TRACKING.md @@ -79,6 +79,12 @@ persists**: `ingest()` returns before any gradebook write same page (`js/scorm_tracker.js`, exelearning issue 2458). Opening or reviewing the activity creates no attempt, consumes no allowed attempt and changes no grade; a submitted 0 is still recorded like any other score. +- **Only the iDevices the learner touched are sent.** Once the attempt has started, + the seed of every other gradable iDevice on the page is still in `cmi.suspend_data`. + The tracker records which `.idevice_node` each interaction landed in and posts + `itemscores` for those iDevices only (exelearning issue 2481). An untouched iDevice + gets no row in that attempt, so under "Last attempt" it keeps its previous grade, + or stays empty if it was never answered. - **Flat table.** `exelearning_attempt` holds one row per `(exelearningid, userid, attempt, itemnumber)`; `itemnumber=0` is the overall, `>0` is an iDevice (`db/install.xml:71-82`). `record_item()` upserts so repeated diff --git a/js/scorm_tracker.js b/js/scorm_tracker.js index e507065..f860eb9 100644 --- a/js/scorm_tracker.js +++ b/js/scorm_tracker.js @@ -438,19 +438,39 @@ // score is being written can start the attempt. var interactedDoc = null; var watchedDocs = []; + // The iDevices (by objectid) the learner interacted with during this visit. + // The runtime seeds every gradable iDevice on the page with 0, and that seed + // stays in suspend_data, so only touched iDevices are sent (exelearning + // issue 2481): an untouched one keeps its previous grade, or stays empty. + var touched = {}; // Record a learner interaction when it happened inside an iDevice. Navigation // and clicks elsewhere in the package do not answer anything. function noteInteraction(target) { - if (target && typeof target.closest === 'function' && target.closest('.idevice_node')) { + var node = target && typeof target.closest === 'function' && target.closest('.idevice_node'); + if (node) { interactedDoc = target.ownerDocument; + if (node.id) { touched[node.id] = true; } } } + // The item scores to send: every captured score when interaction gating is + // off, otherwise only those of the iDevices the learner touched. + function touchedItemScores() { + if (!awaitInteraction) { return itemScores; } + var out = {}; + for (var oid in itemScores) { + if (itemScores.hasOwnProperty(oid) && touched[oid]) { out[oid] = itemScores[oid]; } + } + return out; + } + // Listen for the learner's own input on a package page. The iframe loads a new // document per package page, so this runs whenever the SCO talks to the API. function watchDocument(doc) { - if (started || !doc || typeof doc.addEventListener !== 'function' + // Keep watching after the attempt starts: later pages still need to know + // which of their iDevices the learner touched. + if (!awaitInteraction || !doc || typeof doc.addEventListener !== 'function' || watchedDocs.indexOf(doc) !== -1) { return; } @@ -477,7 +497,7 @@ // keep the values buffered for the first real commit. if (!dirty || !started) { return true; } var snapshot = JSON.stringify(cmi); - var payload = buildPayload(cmid, session, cmi, itemScores, sesskey); + var payload = buildPayload(cmid, session, cmi, touchedItemScores(), sesskey); try { var xhr = xhrFactory(); // Synchronous in LMSFinish (student closes the tab); async otherwise. diff --git a/tests/js/scorm_tracker.test.js b/tests/js/scorm_tracker.test.js index 3279b94..f0b8935 100644 --- a/tests/js/scorm_tracker.test.js +++ b/tests/js/scorm_tracker.test.js @@ -771,3 +771,90 @@ describe('parseSuspend (versioned exe12 payload, core PR #2209)', () => { expect(parseSuspend('1. "Quiz"; score: 60%; weighted: 30%.')[1].title).toBe('Quiz'); }); }); + +describe('createScormApi: only the iDevices the learner touched are graded', () => { + // Two gradable iDevices on one page, as in the demo activity (True/False + Guess). + // On load the runtime seeds both with 0 in a single suspend_data write. + const SEED = '1. "Verdadero o falso"; Puntuación: 0%; Peso: 50%.\t' + + '2. "Adivina"; Puntuación: 0%; Peso: 50%'; + let scheduled; + function config(xhr) { + scheduled = null; + return { + cmid: 42, + trackurl: 'https://example.test/track.php', + session: 'tok', + bindUnload: false, + getScoringDocument: () => document, + xhrFactory: () => xhr, + setTimeout: (fn) => { scheduled = fn; return 1; }, + clearTimeout: () => { scheduled = null; }, + }; + } + function seedOnLoad(api) { + api.LMSInitialize(''); + api.LMSSetValue('cmi.suspend_data', SEED); + api.LMSSetValue('cmi.core.score.raw', '0'); + } + beforeEach(() => { + document.body.innerHTML = '
' + + ''; + }); + + it('does not send the seeded 0 of an iDevice the learner never touched (issue 2481)', () => { + const xhr = makeXhr(200); + const tracker = createScormApi(config(xhr)); + seedOnLoad(tracker.api); + // The learner plays only the Guess iDevice and gets it right. + tracker.noteInteraction(document.getElementById('guess')); + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Verdadero o falso"; Puntuación: 0%; Peso: 50%.\t' + + '2. "Adivina"; Puntuación: 100%; Peso: 50%'); + tracker.api.LMSSetValue('cmi.core.score.raw', '50'); + scheduled(); + expect(xhr.calls).toHaveLength(1); + expect(JSON.parse(xhr.lastPayload).itemscores).toEqual({ + 'ide-guess': { scorepct: 100, weighted: 50, title: 'Adivina' }, + }); + }); + + it('sends a legitimate 0 for the second iDevice once the learner answers it too', () => { + const xhr = makeXhr(200); + const tracker = createScormApi(config(xhr)); + seedOnLoad(tracker.api); + tracker.noteInteraction(document.getElementById('guess')); + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Verdadero o falso"; Puntuación: 0%; Peso: 50%.\t' + + '2. "Adivina"; Puntuación: 100%; Peso: 50%'); + scheduled(); + // Then answers the True/False wrongly: its value stays 0, identical to the seed. + tracker.noteInteraction(document.getElementById('tf')); + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Verdadero o falso"; Puntuación: 0%; Peso: 50%.\t' + + '2. "Adivina"; Puntuación: 100%; Peso: 50%'); + scheduled(); + expect(JSON.parse(xhr.lastPayload).itemscores).toEqual({ + 'ide-tf': { scorepct: 0, weighted: 50, title: 'Verdadero o falso' }, + 'ide-guess': { scorepct: 100, weighted: 50, title: 'Adivina' }, + }); + }); + + it('keeps tracking touched iDevices on later pages after the attempt has started', () => { + const xhr = makeXhr(200); + let current = document; + const tracker = createScormApi({ ...config(xhr), getScoringDocument: () => current }); + seedOnLoad(tracker.api); + tracker.noteInteraction(document.getElementById('guess')); + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Verdadero o falso"; Puntuación: 0%; Peso: 50%.\t' + + '2. "Adivina"; Puntuación: 100%; Peso: 50%'); + scheduled(); + // Page 2 carries one gradable iDevice; the learner answers it. + current = document.implementation.createHTMLDocument('page 2'); + current.body.innerHTML = ''; + const listeners = {}; + current.addEventListener = (type, fn) => { listeners[type] = fn; }; + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Quiz"; Puntuación: 0%; Peso: 100%'); + // Real input on the new page must still be watched after the attempt started. + listeners.pointerdown({ isTrusted: true, target: current.getElementById('p2') }); + tracker.api.LMSSetValue('cmi.suspend_data', '1. "Quiz"; Puntuación: 80%; Peso: 100%'); + scheduled(); + expect(Object.keys(JSON.parse(xhr.lastPayload).itemscores).sort()).toEqual(['ide-guess', 'ide-p2']); + }); +}); diff --git a/tests/track_test.php b/tests/track_test.php index e2c1ab4..ff9729b 100644 --- a/tests/track_test.php +++ b/tests/track_test.php @@ -614,6 +614,39 @@ public function test_ingest_records_a_submitted_zero_under_last_attempt(): void $this->assertEqualsWithDelta(0.0, $this->published_grade($instance, $student->id, 1), 0.0001); } + /** + * An iDevice missing from itemscores keeps its grade under "Last attempt". + * + * Contract relied on by the tracker (exelearning issue 2481): it only sends the + * iDevices the learner touched during a visit, so an attempt that scores one + * iDevice must leave every other iDevice's last grade alone. + */ + public function test_ingest_leaves_items_missing_from_itemscores_untouched(): void { + [$instance, $student] = $this->create_activity_with_student([ + 'grademodel' => EXELEARNING_GRADEMODEL_PERITEM, + 'grademethod' => \mod_exelearning\local\attempts::GRADE_LAST, + ]); + [$course, $cm] = $this->course_and_cm($instance); + $tf = $this->objectid_for($instance, 1); + $guess = $this->objectid_for($instance, 2); + $submit = function (string $session, array $itemscores) use ($instance, $course, $cm, $student): void { + $result = track::ingest($instance, $course, $cm, $student->id, [ + 'session' => $session, + // Raw matches the overall recomputed from the one scored iDevice, so + // ingest() does not report a divergence (DEC-6-01). + 'cmi' => ['cmi.core.score.raw' => '100', 'cmi.core.score.max' => '100'], + 'itemscores' => $itemscores, + ], false); + $this->assertTrue($result['ok']); + }; + + $submit('visitTf', [$tf => ['scorepct' => 100.0, 'weighted' => 50.0, 'title' => 'TF']]); + $submit('visitGuess', [$guess => ['scorepct' => 100.0, 'weighted' => 50.0, 'title' => 'Guess']]); + + $this->assertEqualsWithDelta(100.0, $this->published_grade($instance, $student->id, 1), 0.0001); + $this->assertEqualsWithDelta(100.0, $this->published_grade($instance, $student->id, 2), 0.0001); + } + /** * With the master grading switch off (DEC-13-07), ingest() records NOTHING * (DEC-126-01). diff --git a/version.php b/version.php index 2794860..26c6202 100644 --- a/version.php +++ b/version.php @@ -34,7 +34,7 @@ // in $plugin->release ('dev'); a release-preparation PR commits the final // version + semver release BEFORE the tag is created (see DEVELOPMENT.md, // "Versioning and releases"). -$plugin->version = 2026092611; +$plugin->version = 2026092612; $plugin->release = 'dev'; $plugin->requires = 2024100700; // Moodle 4.5 LTS+. $plugin->supported = [405, 502]; // Moodle 4.5 LTS through Moodle 5.2.