Test audit of #39: lollipop/self-crossing snapping, fix a real self-crossing bug #116
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!116
Loading…
Reference in a new issue
No description provided.
Delete branch "area/navigation-snapping-test-audit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Audits issue #39's acceptance criteria against #31's
RouteSnapper(merged tonight as PR #114) and adds the real, missing test scope. Does not close #39 — two criteria are genuinely out of this issue's honest scope (see below) and get dependency edges instead.Criteria already covered by
RouteSnapperTest(#31)Verified non-superficial (tight tolerances, not "some value came back"):
"an out-and-back route does not jump from the outbound leg to the geometrically coincident return leg"assertsoffsetMeters < 1.0and correctsegmentStartIndex/monotonic distance across the turnaround."the search window widens after a GPS gap..."asserts the post-gap distance to withinplusOrMinus 5.0of the true 400 m value, which is what actually proves the widened window fired rather than merely "some progress was made"."the search window widens to re-acquire the route after an off-route excursion"asserts the post-rejoin distance to withinplusOrMinus 5.0of the true 450 m value.No new tests added for these three; writing near-duplicates would not have added real coverage.
Criterion genuinely missing: lollipop and self-crossing routes
Not covered by #31 (only a plain out-and-back was tested). Auditing it surfaced a real, previously-undiscovered bug, not just an untested case:
On a route that crosses itself,
RouteSnapper's forward-only windowed search can contain both the rider's true, nearby position on the polyline and a much later revisit of the same physical point (the crossing itself). Because the underlyinglocateAlongPolylinesimply picks whichever segment in the window has the globally smallest offset, a few meters of completely ordinary GPS noise can make the far, chronologically-wrong segment look marginally closer than the true one — and the search cursor jumps onto it, irrecoverably, since forward-only search never looks back.Reproduced concretely: an 8 m half-size figure-eight (~226 m total route length) with only 3 m of perpendicular noise near the crossing snapped the cursor from ~113 m into the ride straight to ~223 m — permanently — with the reported offset then growing on every subsequent fix even though the rider was genuinely still on-route, riding the second loop.
Fix (
RouteSnapper.kt): a two-phase search insnap(). On the plain, un-widened path (no GPS gap, previous fix looked on-route), try a tight, plausibility-gated window first (IMMEDIATE_SEARCH_HORIZON_METERS= 60 m,maxOffsetMeters= the existingREACQUISITION_OFFSET_THRESHOLD_METERS= 50 m). Only when that tight search finds nothing plausible does it fall back to the exact original full search, completely unchanged (same horizon logic, samemaxOffsetMeters = Double.MAX_VALUE, same honest-large-offset reporting).This guarantees zero behavioural change on every already-covered path:
RouteSnapperTestsuite, including the real-komoot GPX regression fixture, passes unchanged with the fix applied.Also added a lollipop fixture (stem out, loop via a different, physically distinct path, stem back) confirming that shape already tracks correctly without needing the fix, since its coincident points are well separated in cumulative route distance at a realistic loop size — an honest "this part already works" finding, not assumed.
Both new tests and the fix live in:
companion/core/src/main/kotlin/de/butzei/pedalpebble/core/route/nav/RouteSnapper.ktcompanion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/RouteSnapperTest.ktCriteria explicitly NOT closed here
Off-route enter/exit hysteresis at the thresholds belongs to #32 ("Off-route detection with hysteresis and auto-recovery"), a separate open issue.
RouteSnapperdeliberately does not implement hysteresis — it only reports a rawoffsetMetersfor #32 to build on (seeRouteSnap's own KDoc). There is noOffRouteDetector.ktanywhere in the repo yet (confirmed by search). Fabricating a test against a class that doesn't exist would be worse than leaving this honestly untested. Dependency edge added: #39 depends on #32.Distance-to-turn and ETA against hand-computed values belongs to #33 ("Next turn, then-turn, remaining distance and ETA"), a separate open issue targeting a
NavEngine.ktthat does not exist yet (confirmed by search — thenav/package currently contains onlyRouteSnapper.kt).Cue.distanceAlongRouteMeters(from #26/#63's cue-sheet enrichment) andRouteSnap.distanceAlongRouteMeters(from #31) both already exist independently and a distance-to-turn figure is a straightforward subtraction of the two, but no code anywhere actually does that combination yet, and no rolling-speed-based ETA exists at all. Dependency edge added: #39 depends on #33.Issue #39 is left open — the hysteresis/ETA scope is real and honestly untested by design, not overlooked. Leaving the closing decision to Robert per standing instructions.
Verification
Real
./gradlew :companion:core:test --rerun-tasksrun (all core tests, not justRouteSnapperTest) — green../gradlew :companion:core:koverVerify— green, coverage floor intact.https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt