Skip to content

Grade only the iDevices the learner touched - #157

Open
erseco wants to merge 2 commits into
hotfix/2458-empty-attempt-on-page-loadfrom
hotfix/2481-untouched-idevice-zero
Open

erseco wants to merge 2 commits into
hotfix/2458-empty-attempt-on-page-loadfrom
hotfix/2481-untouched-idevice-zero

Conversation

@erseco

@erseco erseco commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a page has several gradable iDevices and the learner answers only one of them, the plugin also recorded the seeded 0 of every untouched iDevice. Under "Last attempt" that 0 replaced a grade the learner had already earned.

After this change, each visit records only the iDevices the learner actually interacted with. An untouched iDevice keeps its previous grade, or stays empty if it was never answered.

Original issue

Related to exelearning/exelearning#2481, reported by @jjdeharo. Thank you for the precise steps and the track.php payload: they pointed straight at the cause.

This PR is stacked on PR 155 (base branch hotfix/2458-empty-attempt-on-page-load). It picks up exactly the case PR 155 left out of scope. GitHub retargets it to main when PR 155 merges.

Root cause

On page load the package runtime writes every gradable iDevice into cmi.suspend_data with score: 0. The tracker (js/scorm_tracker.js) captures those entries into itemScores, and once the attempt starts (PR 155) it sends the whole map. The server stores every entry it receives as a real result:

"itemscores": {
  "idevice-…-sevb8qqdy": {"scorepct": 0,   "title": "Verdadero o falso"},
  "idevice-…-upsl0qps2": {"scorepct": 100, "title": "Adivina"}
}

The seed and a real 0 are byte-identical, so the server cannot tell them apart. Only the browser knows which iDevice the learner touched.

Fix

  • noteInteraction() already finds the .idevice_node a trusted pointer/touch/key event landed in. It now also remembers that node's id.
  • send() posts itemscores only for those touched iDevices.
  • The tracker keeps watching new package pages after the attempt starts, so an iDevice answered on page 2 is still recorded.
  • A legitimate 0 still goes through: the entry is captured from the seed, and it is sent as soon as the learner touches that iDevice.
  • Server unchanged. A new PHPUnit contract test pins that an iDevice missing from itemscores keeps its grade under "Last attempt".

TDD

RED

New Vitest suite only the iDevices the learner touched are graded, run on the PR 155 tracker:

× does not send the seeded 0 of an iDevice the learner never touched (issue 2481)
  AssertionError: expected { 'ide-tf': {…}, 'ide-guess': {…} } to deeply equal { 'ide-guess': {…} }
× keeps tracking touched iDevices on later pages after the attempt has started
  TypeError: listeners.pointerdown is not a function
Tests  2 failed | 63 passed (65)

GREEN

make test-js                                          → 5 files, 83 tests passed
make test ARGS=mod/exelearning/tests/track_test.php   → 39 tests passed (Moodle 5.0.7)
vendor/bin/phpcs --standard=moodle tests/track_test.php version.php → 0 errors, 0 warnings
make check-version                                    → OK

How to test

  1. Create an activity with research/fixtures/elpx/actividad-evaluable.elpx (True/False + Guess on one page), grading per iDevice, method Last attempt.
  2. As a student, open it and play only the Guess iDevice, then save its score. The True/False column must stay empty.
  3. Leave, reopen and answer the True/False correctly: its column shows 100.
  4. Leave, reopen and play only the Guess again. The True/False column must stay at 100.
  5. Optional: in DevTools, the track.php request body carries only the iDevice you played.

Screenshots

Same steps on localhost (Moodle 5.0.7), attempts report as teacher.

Before

The untouched True/False gets a 0 in attempts 1 and 3, and its "Last attempt" grade falls from 100 to 0.

2481-before.png

After

Each attempt records only the iDevice that was played. True/False keeps its 100.

2481-after.png

Notes

  • cmi.core.score.raw (the package's own overall score) is not filtered: it is the runtime's aggregate, not a per-iDevice value.
  • version.php: 2026092612 (JavaScript source change), above PR 155's 2026092611.

Moodle Playground Preview

The changes in this pull request can be previewed and tested using a Moodle Playground instance.

Preview in Moodle Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELPX upload, viewer and preview work normally.

The package runtime seeds every gradable iDevice on a page with a score of 0.
Once an attempt started, the tracker sent that seed for untouched iDevices too,
so under "Last attempt" an iDevice the learner never answered dropped to 0.

The tracker now remembers which .idevice_node each trusted interaction landed
in and posts itemscores for those iDevices only. It keeps watching later pages
after the attempt starts. Related to exelearning/exelearning#2481.
@codecov-commenter

codecov-commenter commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.97%. Comparing base (ace9409) to head (948c3a3).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                             Coverage Diff                              @@
##             hotfix/2458-empty-attempt-on-page-load     #157      +/-   ##
============================================================================
+ Coverage                                     93.95%   93.97%   +0.01%     
  Complexity                                      810      810              
============================================================================
  Files                                            46       46              
  Lines                                          3576     3584       +8     
============================================================================
+ Hits                                           3360     3368       +8     
  Misses                                          216      216              
Flag Coverage Δ
javascript 96.66% <100.00%> (+0.15%) ⬆️
php 93.83% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
PHP (server-side) 93.83% <ø> (ø)
JavaScript (SCORM tracker) 96.66% <100.00%> (+0.15%) ⬆️
Files with missing lines Coverage Δ
js/scorm_tracker.js 96.66% <100.00%> (+0.15%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ingest() reports a debugging() divergence when cmi.core.score.raw differs
from the overall recomputed from itemscores; CI treats it as an error.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants