Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions js/scorm_tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/js/scorm_tracker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="idevice_node" id="ide-tf"><button id="tf">Check</button></div>'
+ '<div class="idevice_node" id="ide-guess"><button id="guess">Check</button></div>';
});

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 = '<div class="idevice_node" id="ide-p2"><button id="p2">Check</button></div>';
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']);
});
});
33 changes: 33 additions & 0 deletions tests/track_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading