Skip to content

fix: make device pack reordering reachable, and testable without a Lunii - #54

Open
lgnap wants to merge 4 commits into
antoinevalentinHA:masterfrom
lgnap:diagnose/device-reorder
Open

lgnap wants to merge 4 commits into
antoinevalentinHA:masterfrom
lgnap:diagnose/device-reorder

Conversation

@lgnap

@lgnap lgnap commented Sep 9, 2026

Copy link
Copy Markdown

Reordering packs on the device is the only way to change the order the Lunii plays them in. Three things were in the way of it: the list could not be scrolled during a drag, nothing tested the path at either end, and dev mode refused the operation outright.

The bug

The device list is a scrolling box showing a few tiles at a time — at common window widths its grid is a single column, so a dozen packs stack well past the fold. A drag raises dragenter only for tiles the pointer actually reaches, and the browser's own drag scrolling does not drive an inner scroll container. A pack could therefore only ever be dropped beside a tile that was already on screen: reordering into anything below the fold was simply not expressible.

The fix drives the scroll from a frame loop rather than from dragover alone, because dragover stops firing while the pointer is held still — which is exactly what someone does when waiting for a list to scroll. The loop is cancelled on drop, on dragend and on unmount, so it cannot outlive the drag that started it.

Tests

PackIndexReorderValidationTest pins what FsStoryTellerAsyncDriver.reorderPacks accepts. Its guard checks that every submitted UUID is on the device but not the converse, so a partial list is honoured and every pack the caller left out sorts by indexOf == -1 and lands at the front. That is reachable from the UI rather than hypothetical: getPacksList() drops index entries whose .content folder has no ni file, so the list the browser sends back can legitimately be shorter than the index it reorders. The assertions record the defect, they do not bless it.

packLibraryReorder.test.js drives the drag and drop handlers through React's own event system. packLibraryAutoScroll.test.js specifies the new scrolling: the frame loop is driven by hand, since jsdom has no layout and a real requestAnimationFrame would only make the test wait.

Dev mode

MockStoryTellerService.reorderPacks was a // Not supported stub returning false, which DeviceController turns into a 500. The whole drag and drop could be driven in the browser and was then told, every time, that the reorganisation had failed — so the one screen that cannot be reasoned about from tests alone was the one screen dev mode could not show working. The mock now stores the submitted order in a file beside the packs, which is where a real device keeps its own pack index too, and copies the real driver's two surprises rather than improving on them: a partial list is honoured, and a list naming an absent pack is refused.

Verification

  • Java suite: 87 tests, 0 failures (mvn test)
  • JavaScript suite: 66 tests across 6 suites
  • Driven by hand against a real Lunii with 11 packs, and against the mocked device in dev mode

🤖 Generated with Claude Code

Claude Tuxedo added 4 commits September 9, 2026 20:47
Reordering packs on the device is the only way to change the order the Lunii
plays them in. Nothing exercised that path above the pack index writer, at
either end of it.

Two levels, both characterization:

PackIndexReorderValidationTest pins what FsStoryTellerAsyncDriver.reorderPacks
accepts. Its guard checks that every submitted UUID is on the device but not
the converse, so a partial list is honoured and every pack the caller left out
sorts by indexOf == -1 and lands at the front of the device. Duplicates pass,
and an empty list still rewrites the index because allMatch over an empty
stream is vacuously true. That is reachable from the UI rather than
hypothetical: getPacksList() drops index entries whose .content folder has no
ni file, so the list the browser sends back can legitimately be shorter than
the index it reorders. The assertions record the defect, they do not bless it.

packLibraryReorder.test.js drives the drag & drop handlers through React's own
event system, which needed the component exported. No rendering library is
added: react-dom ships test-utils, and Simulate propagates through the tree, so
a dragleave simulated on a tile reaches the dropzone handler as a bubbling one
would.
The device list is a scrolling box showing a few tiles at a time -- at common
window widths its grid is a single column, so a dozen packs stack well past the
fold. A drag raises dragenter only for tiles the pointer actually reaches, and
the browser's own drag scrolling does not drive an inner scroll container, so a
pack could only ever be dropped beside a tile that was already on screen.
Reordering into anything below the fold was simply not expressible.

Driven by a frame loop rather than by dragover alone: dragover stops firing
while the pointer is held still, which is exactly what someone does when
waiting for a list to scroll. The loop is cancelled on drop, on dragend and on
unmount, so it cannot outlive the drag that started it.
The scrolling itself shipped in the previous commit with nothing exercising it.
These are specifications rather than characterization: the behaviour is new, and
what it promises -- a pack below the fold becomes a reachable drop target -- is
only true if the loop keeps running while the pointer is held still, and only
safe if it stops on drop, on dragend and on unmount.

The frame loop is driven by hand. jsdom has no layout, so a real
requestAnimationFrame would only make the test wait; and scrollTop stays 0
whatever the component assigns to it, so the zone is backed by a plain property.
What is asserted is what the component writes on each frame, which is the
contract that matters here.
Dev mode is the only way to exercise the web UI without a Lunii plugged in, and
reorderPacks on the mocked device was a `// Not supported` stub returning false.
DeviceController turns that false into a 500, so a drag and drop could be driven
all the way through the browser and was then told, every time, that the
reorganisation had failed. The one screen that cannot be reasoned about from
tests alone -- drag, drop, scroll -- was the one dev mode could not show working.

The mock keeps one file per pack and a folder listing carries no order, so the
order has to be stored. It goes in a file beside the packs, which is where a real
device keeps its own pack index too. Not a pack file, so the listing skips it
without being taught to.

Two behaviours are copied from FsStoryTellerAsyncDriver rather than improved on:
a partial list is honoured, with every pack the caller left out sorting by
indexOf == -1 and landing at the front, and a list naming a pack the device does
not hold is refused outright. That is what PackIndexReorderValidationTest pins at
the other end. A mock that quietly behaved better would hide the real device's
surprises in the only mode that can be run without hardware.
@lgnap
lgnap force-pushed the diagnose/device-reorder branch from 54d001f to 8d6298d Compare September 9, 2026 18:47
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.

1 participant