Skip to content

Expand unit test coverage and fix the issues found - #1041

Merged
davecraig merged 12 commits into
Scottish-Tech-Army:mainfrom
davecraig:main
Aug 27, 2026
Merged

Expand unit test coverage and fix the issues found#1041
davecraig merged 12 commits into
Scottish-Tech-Army:mainfrom
davecraig:main

Conversation

@davecraig

Copy link
Copy Markdown
Contributor

Lots of new unit tests and quite a lot of issues found and fixed.

davecraig and others added 6 commits August 26, 2026 10:55
…d ViewModels

Fills the largest gaps in unit test coverage across shared/ and app/: compass
and routing utils, CalloutController and the manual-callout builders, the
head-tracking calibration/provider layer, and all previously-untested
ViewModels. Adds Mockito to app/build.gradle.kts to make the Context-bound
onboarding/settings ViewModels testable.

Also fixes two bugs the new tests surfaced:
- dijkstraOnWaysWithLoops returned on first edge relaxation reaching `end`
  instead of once `end` is popped off the priority queue with a guaranteed-
  minimal distance, so it could return a non-optimal (too-long) shortest path.
- CalloutController.startCallout let an exception from the callout body (e.g.
  a button pressed before GeoEngine.start() has finished) escape the launched
  coroutine, cancelling SoundscapeService's root Job and silently disabling
  every future callout button press for the rest of the service's lifetime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iew, and geocoders

Continues the coverage sweep: road-naming confection, the Street Preview
state machine, route/beacon playback, the beacon style preview controller,
route-share JSON, and the Photon/Fused/Multi geocoder orchestration classes.

Also fixes three bugs the new tests surfaced:
- routeToShareJson() built JSON via raw string templates with no escaping,
  so a `"` or `\` in a route/marker name, description, or address produced
  invalid JSON. Now built via kotlinx.serialization's JSON DSL.
- StreetPreview.go() computed the heading of a newly-reached junction from
  the first way chosen at the previous junction rather than the way actually
  last followed to get there, so a route that bends through a pass-through
  node reported a physically wrong facing direction - throwing off which
  choice reads as "straight ahead" at the next junction.
- RoutePlayer.moveToNext() always returned true, even at the last waypoint,
  unlike moveToPrevious() which correctly returns false at the start
  boundary. RoutePlayer.startBeacon() also hardcoded currentRouteFlow's
  beaconOnly to true instead of using the already-computed distance-based
  value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overwrite

PhotonGeocoder.getAddressFromLocationName used `feature.properties?.get("name").toString()`
for name/featureType/featureClass - the `?.` only guarded the map lookup, so a
genuinely absent property became the literal string "null" instead of staying
null, which MvtFeature.getText() then treated as a real name (surfacing "Null"
to the user instead of falling back to a class-based description). Switched to
`?.toString()` throughout, and extracted the now-shared MvtFeature-building
logic into one place also used by getAddressFromLngLat, which previously never
computed featureName at all.

FusedGeocoder.getAddressFromLocationName merges a nearby Photon street-number
match's name into the platform result unconditionally. PhotonGeocoder itself
never populates `.name` (only `.featureName`) unless a caller's processor has
already run LocationDescription.process() on the result, so a PhotonGeocoder
used without that specific wiring would silently blank out a good platform
name during the merge. Guarded the merge to only overwrite when the photon
name is non-blank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r, and small utils

Continues the coverage sweep: offline map download/extract orchestration,
the shared speakCallout rendering logic, tile source resolution, a
ResourceMapper spot-check suite, and a bundle of small string/byte-format/
share-text/route-name utilities. Added ktor-client-mock to shared's
commonTest to make OfflineMapManager's HTTP-backed refresh() testable.

Also fixes five bugs the new tests surfaced:
- ResourceMapper's OSM-tag lookup table had a duplicate "fuel" entry; the
  later put() silently overwrote the earlier, intended "Gas Station" mapping
  with the generic "Fuel Station" one. Removed the duplicate.
- TileSourceResolver.resolveTileSourceUrl(location = null) returned
  offlineExtractPaths[0] - whatever order the filesystem happened to list
  extracts in - contradicting its own doc comment's "best (largest) local
  extract" guarantee. Now picks the largest validated extract, matching the
  location != null case.
- OfflineMapManager.startDownload()'s retry loop had no terminal state after
  exhausting all 10 attempts on repeated HTTP 503s: downloadState was left
  stuck on Caching forever with no indication the download had failed. Now
  sets an Error state and cleans up the temp file.
- FormatBytes.formatBytes()'s unit-selection loop compared the raw
  (pre-rounding) value, so e.g. 999_500 bytes (999.5 kB, which rounds to
  "1000" for display) never advanced to MB, showing "1000 kB" instead of
  "1.0 MB". Added a post-rounding recheck.
- ShareLocationText.buildShareLocationText() chained four .replace() calls
  over the same growing string, so a location name containing literal
  placeholder text (e.g. named "%2$s") could get swept up and rewritten by a
  later substitution meant only for template placeholders. Switched to a
  single regex pass against the original template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…classes

Continues the coverage sweep: street-address description/house-number logic,
named-way search disambiguation, search-string Unicode normalization, GPX
recording, and the Ktor-backed Photon/vector-tile network clients.

Also fixes seven bugs the new tests surfaced:
- GpxRecorder.storeLocation()'s buffer eviction called List.drop(1), which
  returns a new list rather than mutating the MutableList - the result was
  discarded, so the buffer grew unbounded instead of being capped. Now uses
  removeAt(0).
- GpxRecorder.generateGpx() stamped every <trkpt> with a single timestamp
  computed once at generation time, instead of each location's own recorded
  timestampMilliseconds - exported GPX tracks had meaningless timing data.
- TileSearch.generateWithoutSettlement() and addLastWords() both had a
  post-decrement loop-break check (`--count == 0`) that can never fire once
  the counter starts at (or is driven to) 0, leaving the settlement-stripped
  string unchanged instead of empty, and a stray leading space respectively.
- StreetDescription.getStreetNumber() computed which side of the street a
  query point is on using the *opposite* direction convention from the one
  addHouse() used when recording each house's side - so a house recorded on
  the left was looked up as being on the right, and vice versa.
- StreetDescription.distanceAlongStreet() compared the original, unchanged
  `distance` parameter against each way's length instead of the remaining
  distance, causing it to run off the end of a multi-segment street and
  return null instead of interpolating.
- StreetDescription.createDescription() added the last way's length twice
  when recording the trailing descriptive intersection, inflating the
  recorded distance to the street's final junction/dead-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
While investigating why StreetDescription.assignHouseNumberModes() was
returning MIXED for almost every real street tested, found several
compounding issues:

- MvtToGeoJson.kt's addToStreetNumberMap() read a housenumber feature's
  street name from `properties["street"]`, but the tag-parsing loop stores
  a parsed "street" tag in a dedicated `street` field instead - it's never
  copied into `properties`. So any POI/building feature that carries both
  a name and a housenumber (e.g. a car park tagged with both an amenity
  name and a full address) was always misfiled into the "unknown street"
  bucket, regardless of having a perfectly good street tag. This let it
  become a candidate for a nearby, unrelated street's description.
- StreetDescription's own POI-matching code had the identical bug, reading
  `properties["street"]` instead of the `street` field - so even the
  house's own address tag check (added as defense-in-depth here) wasn't
  actually filtering on anything.
- The unknown-street house-number search only checked whether a candidate
  was nearest among a street's *own* segments, never comparing against
  other real named streets nearby - so an untagged house genuinely closer
  to a different street (e.g. across a shared junction) could still be
  attached to the wrong one.
- assignHouseNumberModes()'s odd/even counts were computed before
  checkSortedNumberConsistency() had a chance to filter out numbering
  outliers, so that filtering had no effect on the mode decision it exists
  to inform.

Together these caused real streets to spuriously classify as MIXED even
when their numbering was almost entirely consistent. Verified against
real Glasgow-area map data: 6 of 7 streets in SearchTest.testStreetDescription
now correctly detect odd/even sides, up from 1 of 7 before this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bering

assignHouseNumberModes() required 100% purity on each side (every number
odd, or every number even) before classifying a street as ODD/EVEN, falling
back to MIXED over even a single confidently-tagged exception. Real address
data is rarely that clean - e.g. Sauchiehall Street in Glasgow has 41 even
house numbers against just 2 odd ones on one side, both genuinely tagged
street=Sauchiehall Street in OSM (likely historic renumbering/redevelopment,
not misattribution).

assignHouseNumberModes() now tolerates up to 10% opposite-parity exceptions
per side (isDominantParity()) before giving up and calling a side MIXED.
Since a side can now be classified as ODD/EVEN despite carrying a known
exception, createDescription() strips those exceptions out of
leftSortedNumbers/rightSortedNumbers afterwards (removeParityExceptions()),
so they can't corrupt floor/ceiling interpolation in getStreetNumber() /
getLocationFromStreetNumber() for genuinely unknown addresses nearby.
assignHouseNumberModes() itself stays side-effect-free.

Verified against real Glasgow-area map data: all 7 streets in
SearchTest.testStreetDescription now correctly detect their odd/even sides,
up from 1 of 7 at the start of this investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
davecraig and others added 2 commits August 26, 2026 16:07
… flakiness

The test drives production code that hardcodes Dispatchers.Default, so it
waits on real thread scheduling rather than virtual time. It timed out
waiting for StateFlow emissions on a shared CI runner (32978550205) and
passed with no code changes on the very next run, confirming this was
scheduling jitter rather than a logic bug. Widening the bound doesn't slow
the common case since the loop exits as soon as its condition is met.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The test used java.io.File and java.lang.System, which don't exist on
Kotlin/Native, breaking compileTestKotlinIosSimulatorArm64 on every CI run
since this test was added. Switched to the same Okio Path/systemFileSystem
idiom OfflineMapManager itself and TileSourceResolverTest already use, so
the test now actually runs on iOS instead of just being silently skipped
there. Also replaced a GzipSink.use{} call whose Closeable type doesn't
resolve to kotlin.AutoCloseable on Native with an explicit close().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davecraig
davecraig requested a deployment to development August 26, 2026 15:17 — with GitHub Actions Abandoned
@davecraig
davecraig requested a deployment to development August 26, 2026 15:17 — with GitHub Actions Abandoned
AddressFormatterTest, RoundaboutsTest, and CalloutHistoryTest only
exercise code that already lives in shared/commonMain, so they run
there directly (and now also compile for iOS) instead of only in the
app module's JVM unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
davecraig and others added 2 commits August 27, 2026 08:48
AddressFormatterTest and CalloutHistoryTest moved from app/src/test (JVM-only)
to shared/commonTest in d72dab6, which also compiles for iOS Kotlin/Native
where org.junit isn't available. Switch to kotlin.test equivalents, including
replacing bare stdlib assert() (opt-in-gated on Native) with assertTrue/assertEquals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…signal

myLocation_calledBeforeGeoEngineStarted_resetsActiveFlow_andScopeSurvives
already had its withTimeout bound doubled once (97c3ebc) for CI flakiness
and still recurred. Rather than widen it again, replace the delay(5)-based
polling loop with a Channel signalled from the flow collector, so the wait
suspends until notified instead of depending on polling cadence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davecraig
davecraig merged commit 6e5a706 into Scottish-Tech-Army:main Aug 27, 2026
3 checks passed
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