Skip to content

fix(tracking): key the change baseline by objectid, not by the page-local slot - #125

Merged
erseco merged 3 commits into
mainfrom
fix/scorm-tracker-cross-page-slot-collision
Aug 21, 2026
Merged

fix(tracking): key the change baseline by objectid, not by the page-local slot#125
erseco merged 3 commits into
mainfrom
fix/scorm-tracker-cross-page-slot-collision

Conversation

@erseco

@erseco erseco commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Extracted from #105, which had bolted it onto the SCORM library replacement. It is a bug in main, independent of that swap and of the xAPI retirement in #124, so it lands on its own.

The defect

captureItemScores() decides which per-iDevice scores to POST by comparing the parsed cmi.suspend_data against a baseline. That baseline was keyed by N — the page-local DOM index of the iDevice.

N is not an identity. The legacy suspend_data format restarts it on every page, so slot 2 on page 1 and slot 2 on page 2 are different iDevices wearing the same key. When a page-2 iDevice landed on a slot whose page-1 occupant happened to have the same score and the same weight, it compared equal to the baseline, was treated as unchanged, and was dropped.

The learner answered it. The server never heard about it. Its gradebook column was simply never written — silently, with no error, and only for the learners who hit that combination.

The fix

The baseline is keyed by objectid, the stable identity the server already routes on (DEC-5-01). Two different iDevices are never "unchanged" relative to each other.

That change forces a second one. An objectid-keyed baseline can no longer be replaced wholesale by the newly parsed page — that would forget every other page and re-emit its scores as soon as the learner navigated back. It now carries forward the entries for pages that are not loaded, so returning to a page emits nothing until a score actually changes. The write-avoidance optimisation survives.

Entries that do not resolve against the current page's DOM are now skipped before the comparison instead of after it, so a stale cross-page entry can no longer be attributed to whatever iDevice happens to occupy that index on the loaded page.

Tests

Three, all red against the tracker on main:

× routes a newly scored entry by stable objectid
× emits a page-2 iDevice landing on a slot whose page-1 occupant scored identically
× does not re-emit unchanged scores when the learner returns to a page

The first two are mirror images, and together they are the attribution rule: a collided slot with an identical score must still be graded, and a stale entry must not be attributed to an iDevice the learner never answered. A tracker can pass either one alone by being naive in opposite directions; only the pair pins the behaviour.

npx vitest run  →  2 files, 39 tests passed, exit 0

Scope

js/scorm_tracker.js, its Vitest spec, and the decision record DEC-125-01. No PHP, no schema, no upgrade step, no user-visible strings.


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.

erseco added 2 commits August 21, 2026 12:00
…ocal slot

captureItemScores() compared each parsed suspend_data entry against a
baseline keyed by N, the page-local DOM index of the iDevice. The legacy
suspend_data format reuses N across pages, so N is not an identity: a
page-2 iDevice landing on a slot whose page-1 occupant happened to have
the same score and weight compared equal to it, was treated as
"unchanged", and was dropped. Its gradebook column was simply never
written, silently, and only for the learner who hit that combination.

The baseline is now keyed by objectid, which is the stable identity the
server already routes on (DEC-5-01). Two different iDevices are never
"unchanged" relative to each other.

Keying by objectid means the baseline can no longer be replaced wholesale
by the newly parsed page, since that would forget every other page and
re-emit its scores on the way back. It now carries entries forward for
pages that are not loaded, so returning to a page emits nothing until a
score actually changes — the write-avoidance optimisation survives.

Entries that do not resolve against the current page's DOM are skipped
before the comparison rather than after it, so a stale cross-page entry
can no longer be attributed to whatever iDevice happens to sit at that
index on the loaded page.

Three tests, all red against the tracker on main:

  × routes a newly scored entry by stable objectid
  × emits a page-2 iDevice landing on a slot whose page-1 occupant scored identically
  × does not re-emit unchanged scores when the learner returns to a page

The first two are mirror images and together specify the attribution
rule: a collided slot with an identical score must still be graded, and a
stale entry must not be attributed to an iDevice the learner never
answered. vitest 39/39 with the fix.
@codecov-commenter

codecov-commenter commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.32%. Comparing base (4f3831a) to head (a9e7800).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #125      +/-   ##
============================================
+ Coverage     92.31%   92.32%   +0.01%     
  Complexity      824      824              
============================================
  Files            51       51              
  Lines          3734     3740       +6     
============================================
+ Hits           3447     3453       +6     
  Misses          287      287              
Flag Coverage Δ
javascript 94.33% <100.00%> (+0.22%) ⬆️
php 92.23% <ø> (ø)

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

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

@erseco
erseco merged commit 85f5b99 into main Aug 21, 2026
25 checks passed
@erseco
erseco deleted the fix/scorm-tracker-cross-page-slot-collision branch August 21, 2026 11:46
erseco added a commit that referenced this pull request Aug 21, 2026
…time writes

The vendored runtime this PR replaces is only half the story. The plugin
supplies its copy ONLY to packages that do not carry one —
package_manager.php:254, `if ($present) { continue; }` — and eXeLearning's
SCORM exporter DOES carry one: Scorm12Exporter.spec.ts asserts the
assembled file ships all five layers, client, activities, policy,
lifecycle, adapter. The activities layer is the one that writes
cmi.suspend_data as `exe12/1|{record}|{record}…`.

So the moment core#2209 ships, every SCORM export uploaded here arrives
with a runtime writing a payload neither of this plugin's parsers can
read — `grep -c exe12` was 0 in both classes/local/track.php and
js/scorm_tracker.js — and every per-iDevice grade in those packages is
lost silently. That is caused by this change, not independent of it, so
it belongs in this PR rather than in one of its own.

Both parsers now dispatch on the header and nothing downstream re-sniffs
the payload. One representation serves both formats: every entry carries
title, scorepct and weighted, plus an `objectid` key on — and only on —
an entry that knows its own identity. A versioned record needs no DOM
resolution and cannot collide, so captureItemScores() and the server's
legacy itemnumber fallback both step aside for it; everything else there
exists only because the legacy format cannot name its owner and reuses
the page-local slot N across pages (DEC-5-01).

Version handling is deliberately strict. An unreadable version tag, or
one newer than the parser understands, yields an EMPTY map rather than a
best-effort parse: a future revision may reorder fields, and a silently
misparsed one would publish a WRONG grade — worse than publishing none,
which merely leaves the item visibly ungraded.

Three record shapes are skipped on purpose, each documented where it is
skipped: three-field records (migrated-but-unclaimed legacy entries that
name a page position, not an iDevice), records without the evaluable flag
(the producer excludes them from cmi.core.score.raw, so they must not
reach a gradebook column either), and records whose score field is empty
(no result yet, which is not the same as scoring 0).

Ported from the trace-replay branch rather than copied: that branch
predates #124 and #125, so the exe12 half was lifted onto the current
files and the legacy attribution work it also carries was left there.
filter_registered_scores() is extracted from the inline filter in
ingest(), because the objectid map recovered from suspend_data needs the
same protection against injected objectids.

Verified red without the parser: disabling the dispatch fails all four
new PHP tests and nothing else.

phpunit 344/1287 exit 0 · vitest 33/33 · phpcs 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants