diff --git a/README.md b/README.md index 6e04a418..7c942833 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`plugins/Jetpack-Compose-Preview/`](plugins/Jetpack-Compose-Preview/) | Renders Compose preview functions on the device. | | [`plugins/Keystore-Generator/`](plugins/Keystore-Generator/) | Creates and manages app signing keystores on the device. | | [`plugins/Layout-Editor/`](plugins/Layout-Editor/) | Edits Android XML layouts by dragging views. | +| [`plugins/Maps/`](plugins/Maps/) | Builds offline map apps from OpenStreetMap regions. | | [`plugins/Markdown-Previewer/`](plugins/Markdown-Previewer/) | Shows a live preview of Markdown and HTML files. | | [`plugins/NDK-Installer/`](plugins/NDK-Installer/) | Installs the NDK and CMake, and adds a native project template. | | [`plugins/Project-to-Template/`](plugins/Project-to-Template/) | Turns the open project into a reusable template. | diff --git a/plugins/Maps/.gitignore b/plugins/Maps/.gitignore new file mode 100644 index 00000000..6af499b7 --- /dev/null +++ b/plugins/Maps/.gitignore @@ -0,0 +1,29 @@ +# Gradle +.gradle/ +build/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.kotlin/ + +# Local configuration +local.properties + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test outputs +test-results/ diff --git a/plugins/Maps/README.md b/plugins/Maps/README.md new file mode 100644 index 00000000..972d02c0 --- /dev/null +++ b/plugins/Maps/README.md @@ -0,0 +1,96 @@ +# Maps: Offline OpenStreetMap for Code on the Go + +`Maps` scaffolds an OpenStreetMap app in minutes. It downloads a map region and wires it into a +MapLibre renderer, so the generated app shows a real, pannable map with no network connection — +built for offline-first, low-bandwidth use. + +You start by creating a project from the **Offline OSM Map** template in the New Project wizard +— a basic MapLibre map app. The plugin then adds a **Maps** tab to the editor bottom sheet, +where you download a map region and bundle it into that app. + +## Features + +- **Start from a template**: create an **Offline OSM Map** project (Kotlin or Java) — a basic + MapLibre map app, ready to drop a region into. +- **Download a region**: from the **Maps** tab, draw a bounding box on a world map and the + plugin slices out just that region's OpenStreetMap vector tiles (PMTiles) from an + Internet-in-a-Box source — with a live size estimate and an automatic zoom cap to keep + downloads small. +- **Bundle it into your app**: apply a downloaded region to your **Offline OSM Map** project and + its tiles are copied into the app's assets, ready to render offline. +- **Builds and runs offline**: the generated app renders tiles — place and street-name labels + included — through MapLibre and an in-process loopback HTTP server, with no network calls. It + even builds on-device without internet, since MapLibre is vendored. + +## Architecture + +``` +plugins/Maps/ +├── addon.json # Gallery metadata +├── build.gradle.kts # Build configuration (plugin API via ../../libs) +├── settings.gradle.kts # Buildscript classpath (shared ../../libs) +├── maps.html # Full plugin documentation +├── src/main/ +│ ├── AndroidManifest.xml # Plugin metadata, permissions, icons +│ ├── kotlin/org/appdevforall/maps/ +│ │ ├── MapsPlugin.kt # Main plugin class (registers the tab + template) +│ │ ├── domain/ # Pure types: bbox, zoom cap, region models +│ │ ├── data/ # Region cache, downloader, installer, stores +│ │ ├── slicer/ # PMTiles region slicer (Hilbert range decomposition) +│ │ ├── templates/ # Project template + MapLibre app emitter +│ │ ├── ui/ # Maps tab, bbox picker, download wizard +│ │ └── util/ # Atomic file I/O, byte-size formatting +│ └── assets/ +│ ├── templates/region-map/ # The emitted MapLibre app (+ offline Maven repo) +│ ├── docs/index.html # In-IDE OSM + MapLibre tutorial +│ ├── maps/natural-earth-*.pmtiles # Bundled world basemap +│ ├── fonts/ # Noto Sans label glyphs (OFL) +│ └── icon_day.png / icon_night.png +└── README.md +``` + +### Plugin metadata (AndroidManifest.xml) +- **plugin.id**: `org.appdevforall.maps` +- **plugin.name**: `Maps` +- **plugin.author**: App Dev For All +- **plugin.main_class**: `org.appdevforall.maps.MapsPlugin` +- **plugin.permissions**: `filesystem.read`, `filesystem.write`, `network.access`, `native.code` + +## Building the plugin + +```bash +cd plugins/Maps +../../gradlew assemblePlugin # release -> build/plugin/maps.cgp +../../gradlew assemblePluginDebug # debug +``` + +### Installation +1. Import the `.cgp` through Code on the Go's Plugin Manager. +2. Open the **Maps** tab in the editor bottom sheet, or create a new project from the + **Offline OSM Map** template. + +## Usage + +1. **Create a new project** from the **Offline OSM Map** template (New Project wizard) — this + scaffolds a basic MapLibre map app (Kotlin or Java). +2. Open the **Maps** tab in the editor bottom sheet and tap **Download new region**: draw a + bounding box, name it, and **Save**. +3. **Apply** the downloaded region — its tiles are bundled into your app's assets. +4. Build and run. The app renders your region offline. + +## Dependencies +- `plugin-api`: Code on the Go Plugin API (`compileOnly`, shared `../../libs/plugin-api.jar`) +- `org.maplibre.gl:android-sdk-opengl`: offline vector map rendering +- `com.github.davidmoten:hilbert-curve`: PMTiles tile-range decomposition +- `okhttp`, `androidx.*`, `material`, `kotlinx-coroutines`: networking and UI + +### Requirements +- Android API 28+ (Android 9) +- Minimum IDE version: 26.38 (the first release that bundles AGP 9, which the generated project needs) +- Network once, to download a region's tiles (the generated app then runs and builds offline) + +## License + +Provided as-is for educational and development purposes. OpenStreetMap data is © OpenStreetMap +contributors (ODbL); bundled fonts are Noto Sans (OFL). See +`src/main/assets/THIRD_PARTY_LICENSES.txt` for full attribution. diff --git a/plugins/Maps/addon.json b/plugins/Maps/addon.json new file mode 100644 index 00000000..c4074e9c --- /dev/null +++ b/plugins/Maps/addon.json @@ -0,0 +1,16 @@ +{ + "summary": "Builds offline map apps from OpenStreetMap regions.", + "description": "Download an OpenStreetMap region from an Internet-in-a-Box server and bundle it into an app made from the Offline OSM Map template. The app shows the map with no internet connection.", + "tags": [ + "maps", + "openstreetmap", + "offline", + "templates" + ], + "origin": "appdevforall", + "license": "AGPL-3.0-or-later", + "author": { + "name": "App Dev For All", + "url": "https://www.appdevforall.org" + } +} diff --git a/plugins/Maps/build.gradle.kts b/plugins/Maps/build.gradle.kts new file mode 100644 index 00000000..1488fa9f --- /dev/null +++ b/plugins/Maps/build.gradle.kts @@ -0,0 +1,175 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("com.itsaky.androidide.plugins.build") + // Unit-test coverage. CoGo's root build uses JaCoCo 0.8.11 + Sonar; this + // plugin keeps a single-module report (`./gradlew jacocoTestReport`) so the + // covered-logic claim in the PR is reproducible. The UI layer is intentionally + // 0% here — fragments + MapLibre GL are device-tested via android-qa, not JVM. + jacoco +} + +jacoco { + toolVersion = "0.8.11" +} + +pluginBuilder { + pluginName = "maps" +} + +android { + namespace = "org.appdevforall.maps" + compileSdk = 36 + + defaultConfig { + applicationId = "org.appdevforall.maps" + minSdk = 28 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + + // Code on the Go ships for these two ABIs only; MapLibre's x86 and x86_64 libraries + // would add about 21 MB that no host can load. + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + jniLibs.useLegacyPackaging = true + } + + androidResources { + noCompress.add("pmtiles") + } + + testOptions { + unitTests { + // Production code logs via android.util.Log; in plain JVM unit tests + // android.util.Log is the stubbed android.jar and throws "not mocked". + // Returning default values makes Log calls no-ops so the pure parsing + // logic stays unit-testable. + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // plugin-api jar is the canonical plugin contract; IDE provides at runtime. + compileOnly(files("../../libs/plugin-api.jar")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.fragment:fragment:1.8.8") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // MapLibre OpenGL ES — Vulkan unreliable on API 26-29 target audience. + // 13.x fixes PMTiles file-source range-header handling vs 11.x. + implementation("org.maplibre.gl:android-sdk-opengl:13.1.0") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Hilbert-curve range decomposition for bbox→tile-id slicing. + implementation("com.github.davidmoten:hilbert-curve:0.2.3") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20210307") + // Local HTTP stub for unit-testing the range-fetch + download paths + // (HttpRangeFetcher, RegionDownloader) without hitting a real server. + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + // plugin-api is compileOnly for the main set (host-provided at runtime). Unit + // tests run on a plain JVM with no host, so they need it on the test classpath + // to compile + run the host-facing classes (MapsPlugin) and the test fakes. + testImplementation(files("../../libs/plugin-api.jar")) +} + +tasks.wrapper { + gradleVersion = "9.6.1" + distributionType = Wrapper.DistributionType.BIN +} + +// Disable AAR metadata checks that fail under the plugin-builder +// pipeline (Beepy and Forms use the same workaround). +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} + +tasks.withType().configureEach { + // Template tests read these files by path, so declare them or a template edit stays UP-TO-DATE. + inputs.dir("src/main/assets/templates").withPropertyName("templates") + System.getProperty("runOnlineSlicerTests")?.let { + systemProperty("runOnlineSlicerTests", it) + } + testLogging { + events("standardOut") + showStandardStreams = true + } + // Match CoGo's JaCoCo agent config so coverage data is written. + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +// Single-module unit-test coverage report. Run: `./gradlew jacocoTestReport` +// (HTML at build/reports/jacoco/jacocoTestReport/html/index.html). UI classes +// register 0% — they're device-tested via android-qa, not JVM-unit-testable. +tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + val fileFilter = listOf( + "**/R.class", "**/R\$*.class", "**/BuildConfig.*", + "**/Manifest*.*", "**/*Test*.*", "**/databinding/**", + ) + // AGP 9 compiles Kotlin itself and writes the classes here, not to tmp/kotlin-classes. + val classes = fileTree( + layout.buildDirectory.dir("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes") + ) { exclude(fileFilter) } + classDirectories.setFrom(classes) + // A wrong class path yields an empty report that still passes, so fail instead. + doFirst { + check(!classes.isEmpty) { "jacocoTestReport: no main classes under ${classes.dir}" } + } + sourceDirectories.setFrom(files("src/main/kotlin")) + // Scope execution data to the exact exec file (not a build-dir-wide scan) so + // Gradle doesn't see this task as implicitly consuming other tasks' outputs. + executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec")) + reports { + xml.required.set(true) + html.required.set(true) + } +} diff --git a/plugins/Maps/gradle.properties b/plugins/Maps/gradle.properties new file mode 100755 index 00000000..35bc3432 --- /dev/null +++ b/plugins/Maps/gradle.properties @@ -0,0 +1,10 @@ +# Android properties +android.useAndroidX=true + +# Kotlin +kotlin.code.style=official + +# Memory settings for builds +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true \ No newline at end of file diff --git a/plugins/Maps/maps.html b/plugins/Maps/maps.html new file mode 100644 index 00000000..b4cee513 --- /dev/null +++ b/plugins/Maps/maps.html @@ -0,0 +1,168 @@ + + + + + + Maps + + + + +

Maps Plugin

+

Offline OpenStreetMap tools for Code on the Go — download a +map region once, then ship an app that renders it with no internet.

+ +

Executive overview

+ +

The Maps plugin adds offline mapping to +Code on the Go, the offline-capable Android IDE built for +low-end phones in low-bandwidth regions. It lets a developer save an +OpenStreetMap region for offline use — downloaded from an +Internet in a Box server over the internet or over +a local LAN — and scaffolds a working Android app that renders that region +with no connectivity required afterward. The map data is bundled +inside the generated APK, so the resulting app works in the field where the only +sustainable map is one that already lives on the phone.

+ +

It contributes a single Maps tab to the editor bottom sheet +and a Maps project template in the New Project grid. Nothing +runs in the background; nothing needs an account or an API key.

+ +

Core functionality

+ +
    +
  • Region download (bbox picker). Open the Maps tab, draw a + bounding box on an interactive MapLibre map, and download just that area's + vector tiles. The plugin estimates the download size and auto-caps the zoom + range so a region stays a sensible size.
  • +
  • Reusable region cache. Downloaded regions live under + /sdcard/CodeOnTheGo/maps/<region-id>/, shared across projects + so the same tile pack is never downloaded twice. The Region Manager lists each + cached region with its size and source, and lets you refresh or delete it.
  • +
  • One-tap "Use in this project". Applying a region copies its + data files into the open project's app/src/main/assets/maps/. The + first region for an empty project auto-activates; switching regions is a single + tap.
  • +
  • Maps project template. The New Project grid offers a + ready-to-build offline region-map app (Kotlin or Java) that already ships its + MapLibre wiring, loopback tile server, layout, style, manifest, and Gradle + configuration.
  • +
  • Place & street labels offline. The rendered map shows + place names and street names carried inside the OSM vector tiles, served from + bundled font glyphs — no network calls.
  • +
+ +

Technical architecture

+ +

The plugin is a single Android module loaded by Code on the Go's plugin system via a +DexClassLoader. It depends on the host's plugin-api as a +compileOnly contract and is organised into clean layers so the pure +logic is JVM-unit-testable:

+ + + + + + + + + + +
LayerResponsibility
domain/Pure value types and logic (bounding + box, zoom-cap policy, tile estimates) — no Android imports.
slicer/PMTiles v3 reader + Hilbert-curve range + decomposition that slices a bounding box out of a remote tile archive using + HTTP range reads.
data/Region cache, downloader, installer, and the + active-region store — all file I/O, run off the main thread.
templates/Builds and registers the Maps project + template, and copies a region's data into an open project on apply.
ui/The bottom-sheet Region Manager fragment and + the download wizard (source → bbox picker → save → progress).
+ +

Tiles. Map data is stored as .pmtiles archives +(a cloud-optimized, range-readable tile format). The generated app runs a tiny +in-process loopback HTTP server on 127.0.0.1 and points MapLibre at +pmtiles://http://127.0.0.1:<port>/…, which is the only +PMTiles URL scheme that dispatches reliably on the MapLibre 13.x OpenGL ES +Android variant. The PMTiles are stored uncompressed in the APK so the server can +seek into them.

+ +

Threading. All disk and network work runs off the main thread +via kotlinx.coroutines (Dispatchers.IO); large copies are +cancellable. dispose() cancels the plugin's coroutines, clears +its static references and releases its HTTP connections. One thing outlives it: +MapLibre keeps the application-scoped context the plugin gives it for the life of +the process, and has no teardown for it.

+ +

Usage

+ +
    +
  1. Install the Maps plugin from the Plugin Manager.
  2. +
  3. Create a new project from the Offline OSM Map template + (Kotlin or Java). Regions can only be applied to a project made from this + template.
  4. +
  5. Open the Maps tab in the editor bottom sheet.
  6. +
  7. Tap + Download new region, draw a bounding box over the + area you need, and confirm. The region downloads into the shared cache.
  8. +
  9. Tap Use in this project on the region (the first one + auto-applies). The tiles are copied into your project's assets.
  10. +
  11. Build and run. The app renders the bundled region fully offline.
  12. +
+ +

A full in-IDE tutorial covering Internet in a Box, +OpenStreetMap, and MapLibre ships with the plugin — long-press the Maps tab +tooltip and open "OSM + MapLibre tutorial".

+ +

Key benefits

+ +
    +
  • Truly offline. Once a region is bundled, the generated app + needs no internet — built for field work where connectivity is scarce or + absent.
  • +
  • No accounts, no keys, no cost. Tiles come from + open OpenStreetMap data served by Internet in a Box; there are no + API keys or usage fees.
  • +
  • Download once, reuse everywhere. The shared region cache + avoids re-downloading the same area for every project.
  • +
  • Ready-to-build output. The template ships a complete, + correct MapLibre app — no boilerplate for the developer to get right.
  • +
  • Small footprint. The bbox picker and zoom-cap keep regions + to only the data you need, which matters on low-storage devices.
  • +
+ +

Licensing

+ +

Map data is © OpenStreetMap contributors (ODbL); the bundled Natural Earth +basemap is public domain; glyph fonts are under the SIL Open Font License. The +generated app surfaces the required OpenStreetMap attribution. See +src/main/assets/THIRD_PARTY_LICENSES.txt for the full list.

+ + + diff --git a/plugins/Maps/proguard-rules.pro b/plugins/Maps/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/plugins/Maps/settings.gradle.kts b/plugins/Maps/settings.gradle.kts new file mode 100644 index 00000000..2c360e26 --- /dev/null +++ b/plugins/Maps/settings.gradle.kts @@ -0,0 +1,30 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(files("../../libs/plugin-api.jar")) + classpath(files("../../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:9.3.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "maps" diff --git a/plugins/Maps/src/main/AndroidManifest.xml b/plugins/Maps/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8b9da45d --- /dev/null +++ b/plugins/Maps/src/main/AndroidManifest.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/Maps/src/main/assets/THIRD_PARTY_LICENSES.txt b/plugins/Maps/src/main/assets/THIRD_PARTY_LICENSES.txt new file mode 100644 index 00000000..4a884743 --- /dev/null +++ b/plugins/Maps/src/main/assets/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,156 @@ +THIRD-PARTY LICENSES — maps-plugin (ADFA-2436 Maps) +==================================================== + +This plugin is licensed GPL-3.0 (inherited from plugin-examples). It uses +the following third-party software + reference implementations. All +licenses below are compatible with GPL-3. + +---------------------------------------------------------------------- +ADAPTED REFERENCE IMPLEMENTATION +---------------------------------------------------------------------- + +protomaps/PMTiles — Python reference implementation + Used in: src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt + Source: https://github.com/protomaps/PMTiles + License: BSD-3-Clause + + Copyright 2021 Protomaps LLC + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +The Hilbert curve algorithm itself is textbook mathematics +(https://en.wikipedia.org/wiki/Hilbert_curve), not subject to copyright. +The PMTiles v3 specification (https://github.com/protomaps/PMTiles/blob/ +main/spec/v3/spec.md) is published as public-domain / CC0. + +---------------------------------------------------------------------- +RUNTIME DEPENDENCIES +---------------------------------------------------------------------- + +org.maplibre.gl:android-sdk-opengl:13.1.0 + Vector map renderer (OpenGL ES variant). Native PMTiles support. + License: BSD-2-Clause + https://github.com/maplibre/maplibre-native/blob/main/LICENSE.md + The generated-app template vendors this AAR in + templates/region-map/offline-maplibre-repo/. It is the Maven Central + artifact (sha1 cf8212d56350d0cef57c132d7c8855afd5ad328d) with jni/x86/ and + jni/x86_64/ removed by `zip -d`; arm64-v8a and armeabi-v7a are unchanged. + The .module beside it records the size and hashes of the stripped file. + +com.squareup.okhttp3:okhttp:4.12.0 + HTTP client for tile range-fetches. + License: Apache-2.0 + https://github.com/square/okhttp/blob/master/LICENSE.txt + +com.github.davidmoten:hilbert-curve:0.2.3 + Hilbert-curve tile-id range decomposition for the bbox->tile slicer. + Transitively bundles com.github.davidmoten:guava-mini:0.1.6 and + com.google.guava:listenablefuture:1.0. + License: Apache-2.0 + https://github.com/davidmoten/hilbert-curve + +org.jetbrains.kotlin:kotlin-stdlib:2.3.21 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 + Kotlin standard library + coroutines. + License: Apache-2.0 + https://github.com/JetBrains/kotlin/blob/master/license/README.md + +androidx.appcompat:appcompat:1.6.1 +androidx.fragment:fragment:1.8.8 +androidx.fragment:fragment-ktx:1.8.8 +androidx.recyclerview:recyclerview:1.3.2 + AndroidX UI / lifecycle libraries. + License: Apache-2.0 + https://developer.android.com/jetpack/androidx + +com.google.android.material:material:1.10.0 + Material Components for Android. + License: Apache-2.0 + https://github.com/material-components/material-components-android/blob/master/LICENSE + +---------------------------------------------------------------------- +BUNDLED FONTS +---------------------------------------------------------------------- + +Noto Sans (Regular + Italic) — MapLibre glyph .pbf ranges + Bundled in: src/main/assets/fonts/Noto Sans {Regular,Italic}/*.pbf + Copyright: The Noto Project Authors (https://github.com/notofonts/noto-fonts) + License: SIL Open Font License 1.1 + https://openfontlicense.org/ + + OFL-1.1 is GPL-compatible. The license requires the copyright + license + notice be distributed with the font; this entry satisfies that for the + bundled glyph ranges. + + A Latin subset of these ranges is ALSO emitted into apps generated from + the "Offline OSM Map" project template (under the emitted app's + assets/maps/fonts/notosans/). To satisfy OFL §2 in those downstream apps, + the template emits the full OFL-1.1 text alongside the .pbf glyphs at + assets/maps/fonts/notosans/OFL.txt, and the emitted README credits Noto + Sans + OFL. Non-Latin scripts are not covered by the bundled ranges. + +---------------------------------------------------------------------- +DATA LICENSES (USER-FACING) +---------------------------------------------------------------------- + +Tile data downloaded into a generated app's APK by this plugin is +licensed by its respective providers: + +OpenStreetMap vector tiles (OpenMapTiles processed; via iiab.switnet.org) + License: Open Database License (ODbL) v1.0 + https://www.openstreetmap.org/copyright + +Natural Earth raster basemap + License: Public domain (CC0) + https://www.naturalearthdata.com/about/terms-of-use/ + +Plugin icon — globe (icon_day.png / icon_night.png) + Source: OpenClipart #236045 "Simple globe showing Africa, Asia and Europe + in black and white detailed" by brylie (2016); recolored for the day/night + themes. License: Public domain (CC0 1.0) — OpenClipart releases all content + CC0, so no attribution is required (recorded here for provenance). + https://openclipart.org/detail/236045/ + +Generated apps using this plugin's templates must surface these +attributions. The emitted app ships them as: MapLibre's on-map +attribution control (OSM/ODbL, from style.json's source attribution), +the emitted README's +"Credits & licenses" section, and the bundled OFL.txt next to the fonts. +(Region content is applied as a pure data copy — no code injection — so +attributions live in these shipped surfaces, not in patched source.) + +---------------------------------------------------------------------- +PLUGIN OWN LICENSE +---------------------------------------------------------------------- + +This plugin's own source code is licensed GPL-3.0, inherited from the +parent plugin-examples repository. +https://github.com/appdevforall/plugin-examples/blob/main/LICENSE + +GPL-3 is one-way compatible with BSD/Apache/MIT — third-party code under +those licenses can be included here; the combined distribution is GPL-3. diff --git a/plugins/Maps/src/main/assets/docs/index.html b/plugins/Maps/src/main/assets/docs/index.html new file mode 100644 index 00000000..4d4dccb9 --- /dev/null +++ b/plugins/Maps/src/main/assets/docs/index.html @@ -0,0 +1,346 @@ + + + + + + Working with OpenStreetMap in Code on the Go + + + + +

Working with OpenStreetMap in Code on the Go

+ +

This is the Tier 3 tutorial that ships with the Maps plugin. +The plugin helps you build offline mapping apps: you save an +OpenStreetMap region for offline use — downloaded from an +Internet in a Box server, either over the internet or over a +local LAN — and the plugin scaffolds an app that renders that region with +no connectivity required afterward. The whole point is field work in +low-bandwidth regions, where the only sustainable map is one that already lives +on the phone.

+ +

This tutorial walks the three pieces that make that work, in the order they +come into play: Internet in a Box (where the data lives), +OpenStreetMap (what the data is), and +MapLibre (what draws it).

+ +

Using the Maps tab

+ +

Open a project made from the Offline OSM Map template, then tap +the Maps tab in the editor's bottom sheet. Long-press any button on +these screens for a short explanation.

+ +

The region list

+ +

Each row is a region saved on this phone, under +/sdcard/CodeOnTheGo/maps/. A saved region can be used by any number of +projects, so you download it only once.

+ +The Maps tab listing one saved region, Lalibela, marked Active in this project, with its switch on and Refresh and Delete buttons. + +
    +
  • The switch on a row copies the region's files into the open + project's app/src/main/assets/maps/ folder and makes it the region the + app shows. The row then turns green and reads ● Active in this + project. One region is active at a time: turning on another one switches + to it.
  • +
  • Refresh opens the download steps again with this region's name + and area already filled in, so you can download a newer copy.
  • +
  • Delete asks first, then removes the region from the phone. + Projects that already copied it keep their own copy. If it was the open project's + region, the project no longer has a region selected.
  • +
  • + Download new region starts the three download steps below.
  • +
+ +

Step 1: Pick the map source

+ +Step 1 of 3 with Local Network selected, a hostname typed in, and the status Reachable. + +
    +
  • Internet downloads from the public Internet in a Box server, + iiab.switnet.org. This is always selected when the step opens.
  • +
  • Local Network downloads from an Internet in a Box on your + Wi-Fi. Type its hostname; the plugin remembers it, and the screen shows whether the + box answers.
  • +
+ +

Step 2: Choose the region

+ +

Drag the box to move it, and drag a corner to resize it. Pan and zoom the map to +find your area.

+ +
    +
  • Where the box starts. For a new region the map opens on a + world view with a large box over West Africa. If Code on the Go has permission to + read your location, the box moves to a 50 km × 50 km square around + your last known location. Code on the Go does not ask for that permission today, so + you will usually see the default box. Refresh starts from the + region's saved area instead.
  • +
  • The size estimate. The first line under the map is the download + size, measured from the server for the box you drew. It needs a connection to the + source; without one it reads “Couldn't calculate size” and + Next stays off. A region over 1 GB turns the box red and + cannot be downloaded.
  • +
  • The zoom limit. The second line shows the box size + and the most detailed zoom level the region will have, for example + “40km × 38km — max zoom level 14 (streets)”. The + region always starts at zoom level 6. The plugin picks the highest limit, up to 14, + that keeps the region under 100,000 map tiles, so a bigger box gets a lower limit.
  • +
+ +

Step 3: Name and save

+ +Step 3 of 3 with the region name Lalibela, a summary showing the source and the total to download, and the Back and Save buttons. + +
    +
  • Type a place name, such as Lalibela, Ethiopia. The name is shown in the + region list.
  • +
  • The region's folder is made from the name: lowercase, with spaces and + punctuation turned into dashes, up to 64 characters. Letters from any language are + kept.
  • +
  • Save stays off while the name is empty, or when another saved + region already uses the same folder name. To download that region again, use + Refresh on it instead.
  • +
  • The summary shows the source and the total to download.
  • +
+ +

Downloading

+ +The download screen: the Natural Earth basemap is done and the OpenStreetMap tiles are at 0 percent, with a Cancel + discard button. + +
    +
  • The low-zoom world basemap is copied from the plugin, so it needs no download. + The map tiles for your region then download from the source.
  • +
  • Cancel + discard stops the download and deletes what was + downloaded so far.
  • +
  • When the download finishes and the open project has no region yet, the new + region is used in the project straight away. If the project already has one, a + message offers Apply to switch.
  • +
+ +

What is Internet in a Box?

+ +

Internet in a Box (IIAB) is an +open-source project that packages a library of the internet's most useful +content — Wikipedia, educational courseware, medical references, and +maps — onto an inexpensive device (often a Raspberry Pi) +that serves it over local WiFi. A community without reliable or affordable +internet plugs in the box and browses the content from any phone or laptop on +the local network. App Dev For All works with IIAB as a distribution partner, so +an IIAB server is the natural place this plugin looks for map data. (Source on +GitHub: iiab/iiab.)

+ +

An IIAB server can be reached two ways, and this plugin supports both:

+ +
    +
  • Over the internet — a hosted IIAB instance (the + plugin defaults to the public iiab.switnet.org).
  • +
  • Over a local LAN — a physical IIAB box on the same + WiFi, so the download never touches the internet at all. This is the field + scenario: a box sitting in a clinic or school, phones pulling map regions off + it locally.
  • +
+ +

What mapping data lives on an IIAB server? IIAB's maps +content is OpenStreetMap-derived:

+ +
    +
  • Vector map tiles — the roads, buildings, rivers, + and labels of OSM, packaged as seekable tile archives. This plugin downloads + them as .pmtiles. They're usually shipped as + regional extracts (a country or area), though a well-stocked + box can carry much larger coverage.
  • +
  • A low-zoom world basemap so there's something to show + before you zoom into a downloaded region (this plugin bundles a Natural Earth + basemap for the same reason).
  • +
+ +

The rest of this tutorial unpacks each layer.

+ +

What is OpenStreetMap?

+ +

OpenStreetMap (OSM) is a free, public-domain map of the world built and +maintained by volunteers. Unlike Google Maps or Apple Maps, the underlying data +— every road, building, river, hospital, school — is +available for download and can be bundled into your app +for offline use. That is the whole reason this plugin exists: in +low-bandwidth regions where many of App Dev For All's users do field work, the +only sustainable map is one that already lives on the phone.

+ +

Two important things to know up front:

+ +
    +
  1. OSM data is structured around tags. Every feature on the + map has a key=value tag describing what it is: + amenity=hospital, highway=primary, + place=village, shop=bakery. There's no rigid schema + — the OSM + wiki documents the conventions but anyone can invent a new tag. When you + write a query against OSM, you write it against tags.
  2. +
  3. OSM tiles are not OSM data. Tiles are pre-rendered images + (or vector geometries with style instructions) that you stack like a mosaic to + show a map. This plugin bundles tiles as a single .pmtiles + archive (a cloud-optimized format that supports HTTP range reads, so one file + serves any zoom/region without a tile server). Raw OSM data, if you ever need + it, lives in .osm.pbf extracts. Your generated app renders the + PMTiles tiles directly, including the place-name and street-name labels carried + in the vector tiles.
  4. +
+ +

How does MapLibre fit?

+ +

MapLibre is the open-source Android library that draws the map. The IDE +template adds a MapView to your activity_map_region.xml; +the library is responsible for rendering tiles at the right zoom level, handling +pinch-to-zoom and pan gestures, and drawing markers / lines / polygons on +top.

+ +

The template wires up the minimum:

+ +
    +
  • MapLibre.getInstance(this) — initialises the renderer + (must run before setContentView)
  • +
  • MapView — the actual map widget
  • +
  • mapView.onCreate / onStart / onResume / onPause / onStop / + onLowMemory / onDestroy / onSaveInstanceState — every Activity + lifecycle callback must be forwarded to the + MapView. Skipping any of these leaks GPU memory or crashes the + renderer.
  • +
  • A style.json in assets/maps/ — tells + MapLibre where to fetch tiles and how to draw them.
  • +
+ +

Where do tiles come from?

+ +

The generated app renders one bundled region, served from +inside the APK. The tiles arrive from an Internet in a Box server +(see above) through the IDE bottom-sheet Maps tab: open it, pick +a region with the bbox picker, and download it — the plugin range-fetches +the region's tiles from the IIAB server (the public iiab.switnet.org +by default, or a local-LAN box) into +/sdcard/CodeOnTheGo/maps/<region-id>/. When you turn on a +region's switch, the plugin copies that region's +data files (tiles.pmtiles, basemap.pmtiles, +meta.json) into the project's fixed +app/src/main/assets/maps/ folder, overwriting any previous region. +That's the whole apply step — a pure data copy. The code, style, manifest, +and Gradle wiring already ship in the project.

+ +

How the bundled tiles render. MapLibre 13.x on Android +(OpenGL ES variant) only reliably dispatches the pmtiles://http://... +URL scheme. pmtiles://asset:///… and +pmtiles://file:///… do not work reliably in +this app — don't use them. So the generated app runs a tiny +in-process loopback HTTP server (PmtilesHttpServer) +on 127.0.0.1 that serves the bundled PMTiles assets with HTTP Range +support, and the MapRegionActivity substitutes +pmtiles://http://127.0.0.1:<port>/maps/tiles.pmtiles into the +style at runtime.

+ +

The generated app runs on 64-bit and 32-bit ARM phones. Its bundled copy of +MapLibre carries native code for arm64-v8a and +armeabi-v7a; the x86 libraries are left out to keep the offline +repository small, so the app does not run on an x86 emulator.

+ +

The loopback server has no authentication, so any other app on the phone can +read from its port while your app is running. That is fine for map tiles, which +are public data; do not reuse it to serve anything private.

+ +

This requires the PMTiles to be stored uncompressed in the APK — +androidResources { noCompress.add("pmtiles") }, already set in +build.gradle.kts — so the loopback server can seek into them. +No internet is needed once a region is bundled.

+ +

If you want to bundle a region by hand instead of using the Maps tab, drop the +data files into app/src/main/assets/maps/ (tiles.pmtiles ++ basemap.pmtiles + a meta.json with the region's +bbox) and rebuild. You can build a .pmtiles archive +yourself:

+ +
    +
  • OpenMapTiles — ready-made + vector packs (convert MBTiles → PMTiles with the pmtiles CLI: + pmtiles convert region.mbtiles region.pmtiles).
  • +
  • Geofabrik — + .osm.pbf extracts you process server-side via + tilemaker (emit MBTiles, then convert to PMTiles).
  • +
+ +

Tag reference (most useful for field-data apps)

+ + + + + + + + + + + + + + +
TagMeaning
amenity=hospital, =clinic, =pharmacyHealth
amenity=school, =university, =libraryEducation
amenity=drinking_water, man_made=water_wellWater
shop=*Retail of any kind
place=village, =town, =cityPopulated places
highway=primary, =secondary, =tertiary, =trackRoads, by importance
leisure=park, landuse=forestOpen space
+ +

The full reference is at +OSM Wiki: Map +features.

+ +

Where to ask for help

+ +
    +
  • The MapLibre community: + maplibre.org
  • +
  • OSM users in your region — most regions have a local mailing list at + talk-{country}@openstreetmap.org
  • +
  • Code on the Go's own help — long-press any tooltip in the IDE for the + relevant docs
  • +
+ + + diff --git a/plugins/Maps/src/main/assets/docs/maps-downloading.png b/plugins/Maps/src/main/assets/docs/maps-downloading.png new file mode 100644 index 00000000..fbd5de1b Binary files /dev/null and b/plugins/Maps/src/main/assets/docs/maps-downloading.png differ diff --git a/plugins/Maps/src/main/assets/docs/maps-region-list.png b/plugins/Maps/src/main/assets/docs/maps-region-list.png new file mode 100644 index 00000000..da038b40 Binary files /dev/null and b/plugins/Maps/src/main/assets/docs/maps-region-list.png differ diff --git a/plugins/Maps/src/main/assets/docs/maps-save-region.png b/plugins/Maps/src/main/assets/docs/maps-save-region.png new file mode 100644 index 00000000..4fa81a85 Binary files /dev/null and b/plugins/Maps/src/main/assets/docs/maps-save-region.png differ diff --git a/plugins/Maps/src/main/assets/docs/maps-source.png b/plugins/Maps/src/main/assets/docs/maps-source.png new file mode 100644 index 00000000..8a3619c4 Binary files /dev/null and b/plugins/Maps/src/main/assets/docs/maps-source.png differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/0-255.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/0-255.pbf new file mode 100644 index 00000000..b58e33ec Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/0-255.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/256-511.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/256-511.pbf new file mode 100644 index 00000000..7dbd369f Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/256-511.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/512-767.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/512-767.pbf new file mode 100644 index 00000000..a056f5e3 Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/512-767.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/7680-7935.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/7680-7935.pbf new file mode 100644 index 00000000..b973f564 Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/7680-7935.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/8192-8447.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/8192-8447.pbf new file mode 100644 index 00000000..239cc0f2 Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Italic/8192-8447.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/0-255.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/0-255.pbf new file mode 100644 index 00000000..78e18c8c Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/0-255.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/256-511.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/256-511.pbf new file mode 100644 index 00000000..9331bf9b Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/256-511.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/512-767.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/512-767.pbf new file mode 100644 index 00000000..ead88fbf Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/512-767.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/7680-7935.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/7680-7935.pbf new file mode 100644 index 00000000..f9b028b0 Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/7680-7935.pbf differ diff --git a/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/8192-8447.pbf b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/8192-8447.pbf new file mode 100644 index 00000000..41f99f80 Binary files /dev/null and b/plugins/Maps/src/main/assets/fonts/Noto Sans Regular/8192-8447.pbf differ diff --git a/plugins/Maps/src/main/assets/icon_day.png b/plugins/Maps/src/main/assets/icon_day.png new file mode 100644 index 00000000..49b50e5b Binary files /dev/null and b/plugins/Maps/src/main/assets/icon_day.png differ diff --git a/plugins/Maps/src/main/assets/icon_night.png b/plugins/Maps/src/main/assets/icon_night.png new file mode 100644 index 00000000..65d9bea0 Binary files /dev/null and b/plugins/Maps/src/main/assets/icon_night.png differ diff --git a/plugins/Maps/src/main/assets/maps/natural-earth-style.json b/plugins/Maps/src/main/assets/maps/natural-earth-style.json new file mode 100644 index 00000000..e9e24b3c --- /dev/null +++ b/plugins/Maps/src/main/assets/maps/natural-earth-style.json @@ -0,0 +1,31 @@ +{ + "version": 8, + "name": "Natural Earth (Z0-Z4)", + "metadata": { + "description": "Bundled Natural Earth raster basemap for the bbox-picker map background. Single raster source pointing at the asset-bundled PMTiles file. Used by Phase 1a of ADFA-2436." + }, + "sources": { + "ne": { + "type": "raster", + "url": "pmtiles://asset:///maps/natural-earth-z0-z4.pmtiles", + "tileSize": 512, + "minzoom": 0, + "maxzoom": 4, + "attribution": "Natural Earth, public domain" + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "#a8c5d3" } + }, + { + "id": "ne-raster", + "type": "raster", + "source": "ne", + "minzoom": 0, + "maxzoom": 6 + } + ] +} diff --git a/plugins/Maps/src/main/assets/maps/natural-earth-z0-z4.pmtiles b/plugins/Maps/src/main/assets/maps/natural-earth-z0-z4.pmtiles new file mode 100644 index 00000000..2f83c4a5 Binary files /dev/null and b/plugins/Maps/src/main/assets/maps/natural-earth-z0-z4.pmtiles differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/README.md.peb b/plugins/Maps/src/main/assets/templates/region-map/README.md.peb new file mode 100644 index 00000000..12518c13 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/README.md.peb @@ -0,0 +1,80 @@ +# ${{APP_NAME}} — Offline OSM Region Map + +Generated by the **Maps plugin** for Code on the Go. This app launches straight to +a full-screen map that renders **one bundled OpenStreetMap region offline**, with +place/street name labels and a recenter-on-me button. This README is the +project-local Tier-2 doc; for the longer "how OpenStreetMap works" tutorial, +long-press the **Maps** tab in the editor bottom sheet and tap "OSM + MapLibre tutorial". + +**Runs on 64-bit and 32-bit ARM phones.** The bundled MapLibre library carries +native code for `arm64-v8a` and `armeabi-v7a`. It does not run on x86 emulators. + +## What's where + +| Path | What it does | +|---|---| +| `app/build.gradle.kts` | MapLibre + Material dependencies, `noCompress("pmtiles")`, `minSdk = 23`. Bump versions here. | +| `app/src/main/AndroidManifest.xml` | `MapRegionActivity` as the launcher; INTERNET + location permissions; the network-security config for the loopback server. | +| `app/src/main/res/layout/activity_map_region.xml` | The MapLibre `MapView`, the empty-state banner, and the recenter button. | +| `app/src/main/res/xml/network_security_config.xml` | Allows cleartext to `127.0.0.1` so the in-app loopback PMTiles server works. | +| `app/src/main/assets/maps/style.json` | MapLibre style. Tile URLs are `PMTILES_URL_*` placeholders the activity fills in at runtime with the loopback server's port. | +| `app/src/main/assets/maps/{tiles,basemap}.pmtiles`, `meta.json` | The bundled region's data. Empty until you apply a region (see below). | +| `app/src/main/java/${{PACKAGE_NAME}}/MapRegionActivity.{kt,java}` | Renders the bundled region via the loopback server, forwards lifecycle to `MapView`, drives the recenter button. | +| `app/src/main/java/${{PACKAGE_NAME}}/PmtilesHttpServer.{kt,java}` | The in-process loopback HTTP server that serves the bundled PMTiles to MapLibre. | + +## How the map renders + +MapLibre 13.x on Android (OpenGL ES variant) only reliably dispatches the +`pmtiles://http://...` URL scheme — `file://` and `asset://` don't work reliably +here. So the app starts a tiny **in-process loopback HTTP server** +(`PmtilesHttpServer`) on `127.0.0.1` that serves the bundled `maps/*.pmtiles` +assets with HTTP Range support, and points the style at +`pmtiles://http://127.0.0.1:/maps/tiles.pmtiles`. The PMTiles must be stored +uncompressed in the APK (`androidResources { noCompress.add("pmtiles") }`, already +set) so the server can seek into them. + +No internet is needed once a region is bundled — the app forces MapLibre's +connectivity state to "connected" so the loopback server keeps working even in +airplane mode. + +## Bundling a region (make it show a map) + +The app ships with an **empty** `maps/` folder, so on first run it shows a blank +background with a "no region configured" banner. To bundle a region: + +1. Open the **Maps** tab in the Code on the Go bottom sheet. +2. Pick a region with the bbox picker and download it (from an Internet-in-a-Box + server — over the internet or a local LAN). +3. Tap **Use in this project** (or the first download auto-applies). This is a + **pure data copy**: the plugin copies the region's `tiles.pmtiles`, + `basemap.pmtiles`, and `meta.json` into + `app/src/main/assets/maps/`, overwriting any previous region. It does **not** + touch your code, manifest, or Gradle — those already ship in the project. +4. Rebuild and run. The map centers on the region and renders offline. + +Applying a different region later just overwrites the data files — the app is +single-region by design. + +## Where to ask for help + +- The **MapLibre Native** Android docs: https://maplibre.org/maplibre-native/android/ +- The **OSM Wiki** map-features index: https://wiki.openstreetmap.org/wiki/Map_features +- Long-press any tooltip in the IDE for category-specific help + +## Credits & licenses + +This app bundles third-party data and fonts. If you publish it, keep these +attributions visible (MapLibre already renders the OpenStreetMap attribution in +its on-map attribution control): + +- **Map data**: © OpenStreetMap contributors, [ODbL](https://www.openstreetmap.org/copyright). +- **Low-zoom basemap**: Natural Earth (public domain / CC0). +- **Place / street labels**: [Noto Sans](https://github.com/notofonts/noto-fonts), + © The Noto Project Authors, [SIL Open Font License 1.1](https://openfontlicense.org/) + — the full license ships in the app at `app/src/main/assets/maps/fonts/notosans/OFL.txt` + (OFL §2 requires it to travel with the fonts). + +> Labels currently bundle **Latin** glyph ranges only. Characters outside them (for example +> Yoruba tone marks, Cyrillic, Arabic or CJK) are left out of the label, because the tile +> server answers any range it does not bundle with an empty glyph set. Add the matching Noto +> glyph ranges under `fonts/notosans/` if you target a region that uses them. diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb b/plugins/Maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb new file mode 100644 index 00000000..5b5024c4 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb @@ -0,0 +1,83 @@ +// AGP 9+ compiles Kotlin itself (built-in Kotlin), so there is no kotlin-android plugin here. +plugins { + id("com.android.application") version "${{AGP_VERSION}}" +} + +android { + namespace = "${{PACKAGE_NAME}}" + // Trailing comments below are load-bearing: the CGT/Pebble renderer trims the + // newline right after a print tag, so a line that ENDS in a substitution merges + // into the next line. Keeping a comment after each tag preserves the line break. + compileSdk = ${{COMPILE_SDK}} // IDE-installed compile SDK + + defaultConfig { + applicationId = "${{PACKAGE_NAME}}" + // MapLibre 13.x requires minSdk >= 23, so this is pinned (not a build + // option) — a lower value fails manifest merger against the AAR. + minSdk = 23 + targetSdk = ${{TARGET_SDK}} // IDE-installed target SDK + versionCode = 1 + versionName = "1.0" + + // The bundled offline-maplibre-repo carries MapLibre's native lib for these + // two ABIs only, so the app runs on 64-bit and 32-bit ARM phones. (ADFA-2436) + ndk { + abiFilters.add("arm64-v8a") + abiFilters.add("armeabi-v7a") + } + } + + compileOptions { + sourceCompatibility = ${{JAVA_SOURCE_COMPAT}} // resolves to JavaVersion.VERSION_NN + targetCompatibility = ${{JAVA_TARGET_COMPAT}} // resolves to JavaVersion.VERSION_NN + } + + // PMTiles assets must stay uncompressed so MapLibre's loopback HTTP server + // can random-access seek into them via AssetManager.openFd (ADFA-2436). + androidResources { + noCompress.add("pmtiles") + // Glyph .pbf fonts are served by the loopback HTTP server too, which uses + // AssetManager.openFd — that only works on uncompressed assets. + noCompress.add("pbf") + } +} + +${% if LANGUAGE == 'kotlin' %} +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget("${{JAVA_TARGET}}")) + // MapLibre 13.1.0 is published with newer Kotlin metadata than some IDE releases' + // compiler accepts; skipping the check lets the app compile against it. (ADFA-2436) + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} +${% endif %} + +dependencies { + // Versions aligned to CoGo's bundled offline Maven repo where one exists, so + // these resolve from the bundle with NO network (appcompat 1.6.1 / cardview + // 1.0.0 / coordinatorlayout 1.1.0 / material 1.9.0 are all bundled). The + // genuine gap — MapLibre and the androidx/material transitive closure that + // floats above the bundled versions — is supplied by the project-local + // `offline-maplibre-repo` declared in settings.gradle.kts. (ADFA-2436 / E13) + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0") + implementation("androidx.cardview:cardview:1.0.0") + // Material — provides the Material3 app theme (themes.xml parent). + implementation("com.google.android.material:material:1.9.0") + + constraints { + // appcompat 1.6.1 asks for core-ktx 1.2.0. The IDE's bundled repo is searched first and + // has only that version's POM, so the build fails; 1.9.0 ships as an AAR there and here. + implementation("androidx.core:core-ktx:1.9.0") + } + + // MapLibre Native — OpenGL ES variant for low-end device compatibility. + // Using android-sdk-opengl (not android-sdk) because many devices in + // ADFA's target audience (API 26-29 low-end phones) don't have Vulkan + // support. The OpenGL ES variant is identical in API; only the renderer + // backend differs. MapLibre 13.x requires minSdk >= 23. The map's + // current-location dot uses MapLibre's built-in default location engine, + // so no Play Services Location dependency is needed. + implementation("org.maplibre.gl:android-sdk-opengl:13.1.0") +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb new file mode 100644 index 00000000..1286bf9a --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf new file mode 100644 index 00000000..78e18c8c Binary files /dev/null and b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/256-511.pbf b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/256-511.pbf new file mode 100644 index 00000000..9331bf9b Binary files /dev/null and b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/256-511.pbf differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/512-767.pbf b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/512-767.pbf new file mode 100644 index 00000000..ead88fbf Binary files /dev/null and b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/512-767.pbf differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/7680-7935.pbf b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/7680-7935.pbf new file mode 100644 index 00000000..f9b028b0 Binary files /dev/null and b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/7680-7935.pbf differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf new file mode 100644 index 00000000..41f99f80 Binary files /dev/null and b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf differ diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt new file mode 100644 index 00000000..28440a43 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json new file mode 100644 index 00000000..54de2e5a --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json @@ -0,0 +1 @@ +{ "bbox": [] } diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json new file mode 100644 index 00000000..26ffdb70 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json @@ -0,0 +1,133 @@ +{ + "version": 8, + "name": "ADFA-2436 OSM Bright (offline)", + "glyphs": "GLYPHS_URL", + "sources": { + "osm": { + "type": "vector", + "url": "PMTILES_URL_TILES", + "attribution": "© OpenStreetMap contributors" + }, + "ne": { + "type": "raster", + "url": "PMTILES_URL_BASEMAP", + "tileSize": 512, + "attribution": "Natural Earth" + } + }, + "layers": [ + { "id": "background", "type": "background", "paint": { "background-color": "#f8f4f0" } }, + + { "id": "ne-basemap", "type": "raster", "source": "ne", "minzoom": 0, "maxzoom": 6, + "paint": { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 1.0, 5, 1.0, 6, 0.4] } }, + + { "id": "landcover-wood", "type": "fill", "source": "osm", "source-layer": "landcover", + "filter": ["==", ["get", "class"], "wood"], + "paint": { "fill-color": "#cfe2c9", "fill-opacity": 0.6 } }, + { "id": "landcover-grass", "type": "fill", "source": "osm", "source-layer": "landcover", + "filter": ["==", ["get", "class"], "grass"], + "paint": { "fill-color": "#dceac0", "fill-opacity": 0.6 } }, + + { "id": "landuse-park", "type": "fill", "source": "osm", "source-layer": "park", + "paint": { "fill-color": "#d0e6c0", "fill-opacity": 0.6 } }, + { "id": "landuse-residential", "type": "fill", "source": "osm", "source-layer": "landuse", + "filter": ["==", ["get", "class"], "residential"], + "paint": { "fill-color": "#ecddca", "fill-opacity": 0.3 } }, + { "id": "landuse-industrial", "type": "fill", "source": "osm", "source-layer": "landuse", + "filter": ["==", ["get", "class"], "industrial"], + "paint": { "fill-color": "#e0dac0", "fill-opacity": 0.4 } }, + + { "id": "waterway", "type": "line", "source": "osm", "source-layer": "waterway", "minzoom": 8, + "paint": { "line-color": "#a0c8f0", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 14, 2] } }, + + { "id": "water", "type": "fill", "source": "osm", "source-layer": "water", + "paint": { "fill-color": "#a0c8f0" } }, + + { "id": "building", "type": "fill", "source": "osm", "source-layer": "building", "minzoom": 13, + "paint": { "fill-color": "#d8d2c0", "fill-outline-color": "#bcb6a4" } }, + + { "id": "road-minor-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 12, + "filter": ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]], + "paint": { "line-color": "#cfcdca", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0.8, 14, 3.5, 18, 16] } }, + { "id": "road-minor", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 12, + "filter": ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]], + "paint": { "line-color": "#ffffff", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0.4, 14, 2.5, 18, 13] } }, + + { "id": "road-secondary-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["in", ["get", "class"], ["literal", ["tertiary", "secondary"]]], + "paint": { "line-color": "#cfcdca", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 1, 14, 5, 18, 22] } }, + { "id": "road-secondary", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["in", ["get", "class"], ["literal", ["tertiary", "secondary"]]], + "paint": { "line-color": "#fff9e6", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.6, 14, 4, 18, 18] } }, + + { "id": "road-primary-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 7, + "filter": ["==", ["get", "class"], "primary"], + "paint": { "line-color": "#dca663", "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.6, 14, 7, 18, 28] } }, + { "id": "road-primary", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 7, + "filter": ["==", ["get", "class"], "primary"], + "paint": { "line-color": "#fcd6a4", "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.3, 14, 5.5, 18, 22] } }, + + { "id": "road-trunk-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 6, + "filter": ["==", ["get", "class"], "trunk"], + "paint": { "line-color": "#e58e60", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 14, 8, 18, 30] } }, + { "id": "road-trunk", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 6, + "filter": ["==", ["get", "class"], "trunk"], + "paint": { "line-color": "#fbb29a", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.4, 14, 6, 18, 24] } }, + + { "id": "road-motorway-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 5, + "filter": ["==", ["get", "class"], "motorway"], + "paint": { "line-color": "#d75b3f", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.6, 14, 9, 18, 32] } }, + { "id": "road-motorway", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 5, + "filter": ["==", ["get", "class"], "motorway"], + "paint": { "line-color": "#fc8d63", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.4, 14, 7, 18, 26] } }, + + { "id": "rail", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["==", ["get", "class"], "rail"], + "paint": { "line-color": "#a0a0a0", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.4, 18, 2], "line-dasharray": [2, 2] } }, + + { "id": "boundary-country", "type": "line", "source": "osm", "source-layer": "boundary", + "filter": ["==", ["get", "admin_level"], 2], + "paint": { "line-color": "#8a6088", "line-width": 1.0, "line-dasharray": [4, 2] } }, + + { "id": "water-name", "type": "symbol", "source": "osm", "source-layer": "water_name", "minzoom": 8, + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": 12, + "text-max-width": 6, + "symbol-placement": "point" + }, + "paint": { "text-color": "#5685a8", "text-halo-color": "#ffffff", "text-halo-width": 1.0 } }, + + { "id": "road-name", "type": "symbol", "source": "osm", "source-layer": "transportation_name", "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": ["interpolate", ["linear"], ["zoom"], 13, 12, 18, 16], + "text-max-angle": 40, + "symbol-spacing": 220 + }, + "paint": { "text-color": "#4a4540", "text-halo-color": "#ffffff", "text-halo-width": 1.6 } }, + + { "id": "place-village", "type": "symbol", "source": "osm", "source-layer": "place", "minzoom": 11, + "filter": ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet", "quarter"]]], + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": 12, + "text-max-width": 8 + }, + "paint": { "text-color": "#5a5550", "text-halo-color": "#ffffff", "text-halo-width": 1.2 } }, + + { "id": "place-town", "type": "symbol", "source": "osm", "source-layer": "place", + "filter": ["in", ["get", "class"], ["literal", ["city", "town"]]], + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 16], + "text-max-width": 8 + }, + "paint": { "text-color": "#33312e", "text-halo-color": "#ffffff", "text-halo-width": 1.4 } } + ] +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb new file mode 100644 index 00000000..4d269a53 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb @@ -0,0 +1,384 @@ +package ${{PACKAGE_NAME}}; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.location.Location; +import android.location.LocationManager; +import android.os.Bundle; +import android.widget.ImageButton; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.maplibre.android.MapLibre; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.location.LocationComponent; +import org.maplibre.android.location.LocationComponentActivationOptions; +import org.maplibre.android.location.modes.CameraMode; +import org.maplibre.android.location.modes.RenderMode; +import org.maplibre.android.maps.MapLibreMap; +import org.maplibre.android.maps.MapView; +import org.maplibre.android.maps.Style; +import org.maplibre.android.net.ConnectivityReceiver; +import org.maplibre.android.style.layers.LineLayer; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.sources.GeoJsonSource; + +/** + * Offline region-map Activity. + * + *

Renders ONE bundled OpenStreetMap region from a fixed, flat asset layout: + * + *

+ *   assets/maps/tiles.pmtiles    — vector OSM tiles
+ *   assets/maps/basemap.pmtiles  — low-zoom Natural Earth raster fallback
+ *   assets/maps/style.json       — MapLibre style with PMTILES_URL_* placeholders
+ *   assets/maps/meta.json        — { "bbox": [s, w, n, e] } for the initial camera
+ * 
+ * + *

A region is bundled by the Maps tab in Code on the Go: download a region, + * then "apply" it — that copies those files in, OVERWRITING any previous region. + * Until a region is applied the assets are empty and the map shows a blank + * background with the "no region configured" banner. + * + *

PMTiles loading strategy. MapLibre 13.1.0 OpenGL ES on Android only + * reliably dispatches the {@code pmtiles://http(s)://...} URL scheme — + * {@code file://} silently no-ops and {@code asset://} mishandles the path. So + * this activity starts an in-process loopback HTTP server ({@link + * PmtilesHttpServer}) serving the bundled {@code maps/} assets with Range + * support, then substitutes {@code pmtiles://http://127.0.0.1:/maps/...} + * into the style at runtime. + * + *

build.gradle.kts requirement: pmtiles assets MUST be uncompressed + * ({@code androidResources { noCompress.add("pmtiles") }}) so the server can seek + * with {@code AssetManager.openFd}. + * + *

AndroidManifest: needs INTERNET + a {@code networkSecurityConfig} + * whitelisting cleartext to 127.0.0.1 (modern Android blocks it by default). + * + *

Lifecycle delegates to the {@link MapView} per upstream docs — missing a + * forward leaks GPU memory / crashes the renderer. + */ +public class MapRegionActivity extends AppCompatActivity { + + private static final int LOCATION_PERMISSION_REQUEST = 4011; + + private MapView mapView; + private MapLibreMap map; + private PmtilesHttpServer pmtilesServer; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // MapLibre native init must run BEFORE setContentView() inflates a MapView. + MapLibre.getInstance(this); + // Force connectivity to "connected" so the loopback HTTP server keeps + // serving in airplane mode — ConnectivityReceiver otherwise fires + // connected=false and drops ALL HTTP, even to 127.0.0.1. The tiles are + // bundled in the APK, so no external internet is ever needed. + ConnectivityReceiver.instance(getApplicationContext()).setConnected(true); + + // Start the loopback server before loading the style so its port can be + // substituted into the PMTiles URLs. + pmtilesServer = new PmtilesHttpServer(getAssets(), "maps/"); + int port; + try { + port = pmtilesServer.start(); + } catch (java.io.IOException e) { + android.util.Log.e("MapRegionActivity", "PmtilesHttpServer start failed", e); + port = -1; + } + + setContentView(R.layout.activity_map_region); + + mapView = findViewById(R.id.mapView); + mapView.onCreate(savedInstanceState); + + ImageButton recenter = findViewById(R.id.recenterButton); + if (recenter != null) { + recenter.setOnClickListener(v -> recenterOnUser()); + } + + // Empty-state banner: shown only when NO region is bundled. Key off the + // region's tiles so a real region (which always ships tiles) hides it. + if (hasBundledRegion()) { + findViewById(R.id.empty_state_banner) + .setVisibility(android.view.View.GONE); + } + + applyBottomInsets(); + + // Substitute the loopback port + fixed flat paths into the style. + String styleJson = loadStyleFromAssets(); + if (styleJson != null && port > 0) { + String base = "pmtiles://http://127.0.0.1:" + port + "/maps"; + styleJson = styleJson + .replace("PMTILES_URL_TILES", base + "/tiles.pmtiles") + .replace("PMTILES_URL_BASEMAP", base + "/basemap.pmtiles") + // Glyphs (fonts for place/street labels) are served as plain HTTP + // (NOT pmtiles://) off the same loopback server; {fontstack}/{range} + // are MapLibre template tokens it fills in per glyph request. + .replace("GLYPHS_URL", + "http://127.0.0.1:" + port + "/maps/fonts/{fontstack}/{range}.pbf"); + } + final String finalStyle = styleJson; + + mapView.addOnDidFailLoadingMapListener(reason -> + android.util.Log.e("MapRegionActivity", "MAP FAIL: " + reason)); + + mapView.getMapAsync(m -> { + map = m; + if (finalStyle != null) { + m.setStyle(new Style.Builder().fromJson(finalStyle), style -> { + android.util.Log.i("MapRegionActivity", + "style loaded. sources=" + style.getSources().size() + + " layers=" + style.getLayers().size()); + enableLocationDot(style); + addBboxOutline(style); + applyInitialCamera(); + }); + } else { + applyInitialCamera(); + } + }); + } + + // ----- Current-location dot + recenter button ----- + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } + + /** + * Activate MapLibre's location dot via its built-in default location engine + * (no Play Services needed). No-op without permission. + */ + @SuppressLint("MissingPermission") + private void enableLocationDot(@NonNull Style style) { + if (map == null || !hasLocationPermission()) return; + LocationComponent lc = map.getLocationComponent(); + lc.activateLocationComponent( + LocationComponentActivationOptions.builder(this, style) + .useDefaultLocationEngine(true) + .build()); + lc.setLocationComponentEnabled(true); + lc.setCameraMode(CameraMode.NONE); + lc.setRenderMode(RenderMode.NORMAL); + } + + /** Animate the camera to the user's last known location; request permission on first tap. */ + @SuppressLint("MissingPermission") + private void recenterOnUser() { + if (!hasLocationPermission()) { + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, + LOCATION_PERMISSION_REQUEST); + return; + } + if (map == null) return; + LocationComponent lc = map.getLocationComponent(); + if (!lc.isLocationComponentActivated() && map.getStyle() != null) { + enableLocationDot(map.getStyle()); + } + Location last = lc.getLastKnownLocation(); + if (last != null) { + map.animateCamera(CameraUpdateFactory.newLatLngZoom( + new LatLng(last.getLatitude(), last.getLongitude()), 14.0)); + } else { + android.util.Log.i("MapRegionActivity", "recenter: no location fix yet"); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == LOCATION_PERMISSION_REQUEST && hasLocationPermission() + && map != null && map.getStyle() != null) { + enableLocationDot(map.getStyle()); + recenterOnUser(); + } + } + + // ----- Assets: style + bbox center ----- + + /** True if a region's vector tiles are bundled (assets/maps/tiles.pmtiles present). */ + private boolean hasBundledRegion() { + try (java.io.InputStream is = getAssets().open("maps/tiles.pmtiles")) { + return true; + } catch (Exception e) { + return false; + } + } + + /** Read assets/maps/style.json verbatim — caller substitutes URL placeholders. */ + private String loadStyleFromAssets() { + try { + return new String(readAsset("maps/style.json"), "UTF-8"); + } catch (Exception e) { + return null; + } + } + + /** + * Position the initial camera: center on the user's location if a fix is already + * available, otherwise frame the whole bundled region (its bbox). Falls back to a + * world view if there's no region. We do NOT prompt for location here — only use a + * fix we already have — so launch never blocks on a permission dialog. + */ + private void applyInitialCamera() { + if (map == null) return; + LatLng here = lastKnownLatLng(); + if (here != null) { + map.setCameraPosition(new CameraPosition.Builder().target(here).zoom(14.0).build()); + return; + } + LatLngBounds bounds = readBboxBounds(); + if (bounds != null) { + int pad = (int) (48 * getResources().getDisplayMetrics().density); + try { + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, pad)); + return; + } catch (Exception e) { + android.util.Log.w("MapRegionActivity", "fit bounds failed: " + e.getMessage()); + } + } + map.setCameraPosition(new CameraPosition.Builder().target(new LatLng(0.0, 0.0)).zoom(1.0).build()); + } + + /** The OS's most recent cached fix across providers, or null (no permission / no fix). */ + @SuppressLint("MissingPermission") + private LatLng lastKnownLatLng() { + if (!hasLocationPermission()) return null; + LocationManager lm = getSystemService(LocationManager.class); + if (lm == null) return null; + Location best = null; + for (String p : new String[]{ + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + }) { + try { + Location l = lm.getLastKnownLocation(p); + if (l != null && (best == null || l.getTime() > best.getTime())) best = l; + } catch (Exception ignored) {} + } + return best != null ? new LatLng(best.getLatitude(), best.getLongitude()) : null; + } + + /** assets/maps/meta.json's bbox ([s, w, n, e]) as MapLibre bounds, or null. */ + private LatLngBounds readBboxBounds() { + try { + JSONObject meta = new JSONObject(new String(readAsset("maps/meta.json"), "UTF-8")); + JSONArray bbox = meta.optJSONArray("bbox"); + if (bbox != null && bbox.length() == 4) { + double s = bbox.getDouble(0); + double w = bbox.getDouble(1); + double n = bbox.getDouble(2); + double e = bbox.getDouble(3); + return new LatLngBounds.Builder() + .include(new LatLng(n, e)) + .include(new LatLng(s, w)) + .build(); + } + } catch (Exception ignored) {} + return null; + } + + private byte[] readAsset(String path) throws java.io.IOException { + java.io.InputStream in = getAssets().open(path); + try { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + return out.toByteArray(); + } finally { + in.close(); + } + } + + /** + * Lift the recenter button above the system navigation bar. The app draws + * edge-to-edge (enforced for targetSdk 35+ on Android 15/16), so without this + * the button would sit UNDER the nav bar. We add the nav-bar inset to the + * button's bottom margin. + */ + private void applyBottomInsets() { + final android.view.View root = findViewById(android.R.id.content); + final ImageButton recenter = findViewById(R.id.recenterButton); + final float density = getResources().getDisplayMetrics().density; + final int baseRecenterMarginPx = (int) (16 * density); + ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> { + int navBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + if (recenter != null + && recenter.getLayoutParams() instanceof android.view.ViewGroup.MarginLayoutParams) { + android.view.ViewGroup.MarginLayoutParams lp = + (android.view.ViewGroup.MarginLayoutParams) recenter.getLayoutParams(); + lp.bottomMargin = baseRecenterMarginPx + navBottom; + recenter.setLayoutParams(lp); + } + return insets; + }); + ViewCompat.requestApplyInsets(root); + } + + // ----- Region boundary outline ----- + + private static final String BBOX_SOURCE = "region-bbox-src"; + private static final String BBOX_LAYER = "region-bbox-outline"; + + /** + * Draw the bundled region's bbox as a dashed rectangle so the user can see the + * downloaded extent at any zoom — the framed area when zoomed out over Natural + * Earth, and the edges when zoomed in. Reads the same {@code bbox} ([s, w, n, e]) + * as the camera framing. + */ + private void addBboxOutline(Style style) { + try { + JSONObject meta = new JSONObject(new String(readAsset("maps/meta.json"), "UTF-8")); + JSONArray bbox = meta.optJSONArray("bbox"); + if (bbox == null || bbox.length() != 4) return; + double s = bbox.getDouble(0), w = bbox.getDouble(1), n = bbox.getDouble(2), e = bbox.getDouble(3); + // GeoJSON coords are [lon, lat]; closed ring NW -> NE -> SE -> SW -> NW. + String geo = "{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":" + + "[[" + w + "," + n + "],[" + e + "," + n + "],[" + e + "," + s + "],[" + + w + "," + s + "],[" + w + "," + n + "]]}}"; + style.addSource(new GeoJsonSource(BBOX_SOURCE, geo)); + style.addLayer(new LineLayer(BBOX_LAYER, BBOX_SOURCE).withProperties( + PropertyFactory.lineColor(Color.parseColor("#1f6feb")), + PropertyFactory.lineWidth(2.5f), + PropertyFactory.lineDasharray(new Float[]{ 3f, 2f }))); + } catch (Exception ignored) {} + } + + // ----- Lifecycle (every callback must reach the MapView) ----- + + @Override protected void onStart() { super.onStart(); mapView.onStart(); } + @Override protected void onResume() { super.onResume(); mapView.onResume(); } + @Override protected void onPause() { super.onPause(); mapView.onPause(); } + @Override protected void onStop() { super.onStop(); mapView.onStop(); } + @Override public void onLowMemory() { super.onLowMemory(); mapView.onLowMemory(); } + @Override protected void onDestroy() { + super.onDestroy(); + mapView.onDestroy(); + if (pmtilesServer != null) pmtilesServer.stop(); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mapView.onSaveInstanceState(outState); + } +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb new file mode 100644 index 00000000..f1aaa854 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb @@ -0,0 +1,344 @@ +package ${{PACKAGE_NAME}} + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Color +import android.location.LocationManager +import android.os.Bundle +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import org.json.JSONObject +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.modes.CameraMode +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.net.ConnectivityReceiver +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineDasharray +import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.sources.GeoJsonSource + +/** + * Offline region-map Activity. + * + * Renders ONE bundled OpenStreetMap region from a fixed, flat asset layout: + * + * assets/maps/tiles.pmtiles — vector OSM tiles + * assets/maps/basemap.pmtiles — low-zoom Natural Earth raster fallback + * assets/maps/style.json — MapLibre style with PMTILES_URL_* placeholders + * assets/maps/meta.json — { "bbox": [s, w, n, e] } for the initial camera + * + * A region is bundled by the Maps tab in Code on the Go: download a region, then + * "apply" it — that copies those files in, OVERWRITING any previous region. Until + * a region is applied the assets are empty and the map shows a blank background + * with the "no region configured" banner. + * + * **PMTiles loading strategy.** MapLibre 13.1.0 OpenGL ES on Android only + * reliably dispatches the `pmtiles://http(s)://...` URL scheme — `file://` + * silently no-ops and `asset://` mishandles the path. So this activity starts an + * in-process loopback HTTP server ([PmtilesHttpServer]) serving the bundled + * `maps/` assets with Range support, then substitutes + * `pmtiles://http://127.0.0.1:/maps/tiles.pmtiles` (and basemap) into the + * style at runtime. + * + * **build.gradle.kts requirement**: pmtiles assets MUST be uncompressed + * (`androidResources { noCompress.add("pmtiles") }`) so the server can seek with + * `AssetManager.openFd`. + * + * **AndroidManifest**: needs INTERNET + a `networkSecurityConfig` whitelisting + * cleartext to 127.0.0.1 (modern Android blocks it by default). + * + * Lifecycle delegates to the [MapView] per upstream docs — missing a forward + * leaks GPU memory / crashes the renderer. + */ +class MapRegionActivity : AppCompatActivity() { + + private lateinit var mapView: MapView + private var map: MapLibreMap? = null + private var pmtilesServer: PmtilesHttpServer? = null + + private val locationPermissionRequest = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + map?.style?.let { enableLocationDot(it) } + recenterOnUser() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // MapLibre native init must run BEFORE setContentView() inflates a MapView. + MapLibre.getInstance(this) + // Force connectivity to "connected" so the loopback HTTP server keeps + // serving in airplane mode — ConnectivityReceiver otherwise fires + // connected=false and drops ALL HTTP, even to 127.0.0.1. The tiles are + // bundled in the APK, so no external internet is ever needed. + ConnectivityReceiver.instance(applicationContext).setConnected(true) + + // Start the loopback server before loading the style so its port can be + // substituted into the PMTiles URLs. + val server = PmtilesHttpServer(assets, "maps/") + val port = try { + server.start() + } catch (e: java.io.IOException) { + android.util.Log.e("MapRegionActivity", "PmtilesHttpServer start failed", e) + -1 + } + pmtilesServer = server + + setContentView(R.layout.activity_map_region) + + mapView = findViewById(R.id.mapView) + mapView.onCreate(savedInstanceState) + + findViewById(R.id.recenterButton)?.setOnClickListener { recenterOnUser() } + + // Empty-state banner: shown only when NO region is bundled. Key off the + // region's tiles so a real region (which always ships tiles) hides it. + if (hasBundledRegion()) { + findViewById(R.id.empty_state_banner)?.visibility = + android.view.View.GONE + } + + applyBottomInsets() + + // Substitute the loopback port + fixed flat paths into the style. + var styleJson = loadStyleFromAssets() + if (styleJson != null && port > 0) { + val base = "pmtiles://http://127.0.0.1:$port/maps" + styleJson = styleJson + .replace("PMTILES_URL_TILES", "$base/tiles.pmtiles") + .replace("PMTILES_URL_BASEMAP", "$base/basemap.pmtiles") + // Glyphs (fonts for place/street labels) are served as plain HTTP + // (NOT pmtiles://) off the same loopback server; {fontstack}/{range} + // are MapLibre template tokens it fills in per glyph request. + .replace("GLYPHS_URL", "http://127.0.0.1:$port/maps/fonts/{fontstack}/{range}.pbf") + } + val finalStyle = styleJson + + mapView.addOnDidFailLoadingMapListener { reason -> + android.util.Log.e("MapRegionActivity", "MAP FAIL: $reason") + } + + mapView.getMapAsync { m -> + map = m + if (finalStyle != null) { + m.setStyle(Style.Builder().fromJson(finalStyle)) { style -> + android.util.Log.i( + "MapRegionActivity", + "style loaded. sources=${style.sources.size} layers=${style.layers.size}", + ) + enableLocationDot(style) + addBboxOutline(style) + applyInitialCamera() + } + } else { + applyInitialCamera() + } + } + } + + // ----- Current-location dot + recenter button ----- + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + /** + * Activate MapLibre's location dot via its built-in default location engine + * (no Play Services needed). No-op without permission. + */ + @SuppressLint("MissingPermission") + private fun enableLocationDot(style: Style) { + val m = map ?: return + if (!hasLocationPermission()) return + val lc = m.locationComponent + lc.activateLocationComponent( + LocationComponentActivationOptions.builder(this, style) + .useDefaultLocationEngine(true) + .build() + ) + lc.isLocationComponentEnabled = true + lc.cameraMode = CameraMode.NONE + lc.renderMode = RenderMode.NORMAL + } + + /** Animate the camera to the user's last known location; request permission on first tap. */ + @SuppressLint("MissingPermission") + private fun recenterOnUser() { + if (!hasLocationPermission()) { + locationPermissionRequest.launch(Manifest.permission.ACCESS_FINE_LOCATION) + return + } + val m = map ?: return + val lc = m.locationComponent + if (!lc.isLocationComponentActivated) { + m.style?.let { enableLocationDot(it) } + } + val last = lc.lastKnownLocation + if (last != null) { + m.animateCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(last.latitude, last.longitude), 14.0) + ) + } else { + android.util.Log.i("MapRegionActivity", "recenter: no location fix yet") + } + } + + // ----- Assets: style + bbox center ----- + + /** True if a region's vector tiles are bundled (assets/maps/tiles.pmtiles present). */ + private fun hasBundledRegion(): Boolean = + runCatching { assets.open("maps/tiles.pmtiles").use { true } }.getOrDefault(false) + + /** Read assets/maps/style.json verbatim — caller substitutes URL placeholders. */ + private fun loadStyleFromAssets(): String? = runCatching { + assets.open("maps/style.json").bufferedReader().use { it.readText() } + }.getOrNull() + + /** + * Position the initial camera: center on the user's location if a fix is already + * available, otherwise frame the whole bundled region (its bbox). Falls back to a + * world view if there's no region. We do NOT prompt for location here — only use a + * fix we already have — so launch never blocks on a permission dialog. + */ + private fun applyInitialCamera() { + val m = map ?: return + lastKnownLatLng()?.let { here -> + m.cameraPosition = CameraPosition.Builder().target(here).zoom(14.0).build() + return + } + readBboxBounds()?.let { bounds -> + val pad = (48 * resources.displayMetrics.density).toInt() + runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, pad)) } + .onFailure { android.util.Log.w("MapRegionActivity", "fit bounds failed: ${it.message}") } + .onSuccess { return } + } + m.cameraPosition = CameraPosition.Builder().target(LatLng(0.0, 0.0)).zoom(1.0).build() + } + + /** The OS's most recent cached fix across providers, or null (no permission / no fix). */ + @SuppressLint("MissingPermission") + private fun lastKnownLatLng(): LatLng? { + if (!hasLocationPermission()) return null + val lm = getSystemService(LocationManager::class.java) ?: return null + var best: android.location.Location? = null + for (p in listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + )) { + val l = runCatching { lm.getLastKnownLocation(p) }.getOrNull() ?: continue + if (best == null || l.time > best!!.time) best = l + } + return best?.let { LatLng(it.latitude, it.longitude) } + } + + /** assets/maps/meta.json's `bbox` ([s, w, n, e]) as MapLibre bounds, or null. */ + private fun readBboxBounds(): LatLngBounds? = runCatching { + val raw = assets.open("maps/meta.json").bufferedReader().use { it.readText() } + val bbox = JSONObject(raw).optJSONArray("bbox") + if (bbox != null && bbox.length() == 4) { + val s = bbox.getDouble(0) + val w = bbox.getDouble(1) + val n = bbox.getDouble(2) + val e = bbox.getDouble(3) + LatLngBounds.Builder() + .include(LatLng(n, e)) + .include(LatLng(s, w)) + .build() + } else { + null + } + }.getOrNull() + + // ----- Region boundary outline ----- + + /** + * Draw the bundled region's bbox as a dashed rectangle so the user can see the + * downloaded extent at any zoom — the framed area when zoomed out over Natural + * Earth, and the edges when zoomed in. Reads the same `bbox` ([s, w, n, e]) as + * the camera framing. + */ + private fun addBboxOutline(style: Style) { + val raw = runCatching { + assets.open("maps/meta.json").bufferedReader().use { it.readText() } + }.getOrNull() ?: return + val bbox = runCatching { JSONObject(raw).optJSONArray("bbox") }.getOrNull() ?: return + if (bbox.length() != 4) return + val s = bbox.getDouble(0); val w = bbox.getDouble(1) + val n = bbox.getDouble(2); val e = bbox.getDouble(3) + // GeoJSON coords are [lon, lat]; closed ring NW -> NE -> SE -> SW -> NW. + val geo = """{"type":"Feature","geometry":{"type":"LineString","coordinates":""" + + "[[$w,$n],[$e,$n],[$e,$s],[$w,$s],[$w,$n]]}}" + style.addSource(GeoJsonSource(BBOX_SOURCE, geo)) + style.addLayer( + LineLayer(BBOX_LAYER, BBOX_SOURCE).withProperties( + lineColor(Color.parseColor("#1f6feb")), + lineWidth(2.5f), + lineDasharray(arrayOf(3f, 2f)), + ) + ) + } + + /** + * Lift the recenter button above the system navigation bar. The app draws + * edge-to-edge (enforced for targetSdk 35+ on Android 15/16), so without this + * the button would sit UNDER the nav bar. We add the nav-bar inset to the + * button's bottom margin. + */ + private fun applyBottomInsets() { + val root = findViewById(android.R.id.content) + val recenter = findViewById(R.id.recenterButton) + val density = resources.displayMetrics.density + val baseRecenterMarginPx = (16 * density).toInt() + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + val navBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + recenter?.let { btn -> + (btn.layoutParams as? android.view.ViewGroup.MarginLayoutParams)?.let { lp -> + lp.bottomMargin = baseRecenterMarginPx + navBottom + btn.layoutParams = lp + } + } + insets + } + ViewCompat.requestApplyInsets(root) + } + + // ----- Lifecycle (every callback must reach the MapView) ----- + + override fun onStart() { super.onStart(); mapView.onStart() } + override fun onResume() { super.onResume(); mapView.onResume() } + override fun onPause() { super.onPause(); mapView.onPause() } + override fun onStop() { super.onStop(); mapView.onStop() } + override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() } + override fun onDestroy() { + super.onDestroy() + mapView.onDestroy() + pmtilesServer?.stop() + } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } + + private companion object { + const val BBOX_SOURCE = "region-bbox-src" + const val BBOX_LAYER = "region-bbox-outline" + } +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb new file mode 100644 index 00000000..3a37fa4e --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb @@ -0,0 +1,230 @@ +package ${{PACKAGE_NAME}}; + +import android.content.res.AssetFileDescriptor; +import android.content.res.AssetManager; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets + * with Range-request support. Used to feed bundled PMTiles into MapLibre via + * {@code pmtiles://http://127.0.0.1:PORT/...} — the only PMTiles URL scheme proven + * to work in MapLibre 13.1.0 OpenGL ES on Android. + * + *

Requires assets to be stored uncompressed in the APK + * ({@code androidResources { noCompress.add("pmtiles") }}) so {@link + * AssetManager#openFd} returns a real seekable fd. + * + *

The server has no authentication: any app on the device can connect to its port. Serve + * only data that is already public, like these map tiles. + */ +public class PmtilesHttpServer { + private static final String TAG = "PmtilesHttpServer"; + private static final int BUF = 64 * 1024; + /** Bounds each read, not the whole request: a client that sends nothing for this long is dropped. */ + static final int READ_TIMEOUT_MS = 5_000; + /** Caps threads without queueing, since a queued tile request waits behind idle sockets and the map stays blank. */ + static final int MAX_CLIENTS = 64; + + private final AssetManager assets; + private final String rootPrefix; + private ServerSocket socket; + private ThreadPoolExecutor pool; + private int port; + private volatile boolean running; + + public PmtilesHttpServer(AssetManager assets, String rootPrefix) { + this.assets = assets; + this.rootPrefix = rootPrefix.endsWith("/") ? rootPrefix : rootPrefix + "/"; + } + + public int start() throws IOException { + socket = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); + port = socket.getLocalPort(); + running = true; + pool = new ThreadPoolExecutor(0, MAX_CLIENTS, 30, TimeUnit.SECONDS, new SynchronousQueue<>()); + Thread accept = new Thread(() -> { + while (running) { + try { + Socket client = socket.accept(); + try { + pool.execute(() -> handle(client)); + } catch (RejectedExecutionException e) { + Log.w(TAG, "all " + MAX_CLIENTS + " threads busy, dropping a connection"); + try { client.close(); } catch (IOException ignored) {} + } + } catch (IOException e) { + if (running) Log.w(TAG, "accept: " + e); + } + } + }, "PmtilesHttpServer-accept"); + accept.setDaemon(true); + accept.start(); + Log.i(TAG, "listening on 127.0.0.1:" + port + " serving assets/" + rootPrefix); + return port; + } + + public int port() { return port; } + + public void stop() { + running = false; + try { if (socket != null) socket.close(); } catch (IOException ignored) {} + if (pool != null) pool.shutdownNow(); + } + + private void handle(Socket client) { + try (Socket c = client; + InputStream in = c.getInputStream(); + OutputStream out = c.getOutputStream()) { + c.setSoTimeout(READ_TIMEOUT_MS); + BufferedReader r = new BufferedReader(new InputStreamReader(in)); + String reqLine = r.readLine(); + if (reqLine == null) return; + String[] parts = reqLine.split(" "); + if (parts.length < 2 || !parts[0].equalsIgnoreCase("GET")) { + writeStatus(out, 405, "Method Not Allowed"); return; + } + String path = parts[1]; + if (path.startsWith("/")) path = path.substring(1); + int q = path.indexOf('?'); + if (q >= 0) path = path.substring(0, q); + if (!path.startsWith(rootPrefix)) { writeStatus(out, 404, "Not Found"); return; } + // Reject path-traversal segments. AssetManager already sandboxes to + // the APK's assets/ tree and the server is loopback-only, but a ".." + // in the request path has no legitimate use here. + if (path.contains("..")) { writeStatus(out, 404, "Not Found"); return; } + + String rangeSpec = null; + String line; + while ((line = r.readLine()) != null && !line.isEmpty()) { + if (line.regionMatches(true, 0, "Range:", 0, 6)) { + String v = line.substring(6).trim(); + if (v.startsWith("bytes=")) rangeSpec = v.substring(6); + } + } + + AssetFileDescriptor afd; + try { afd = assets.openFd(path); } + catch (IOException e) { + // A glyph range the app does not bundle. MapLibre leaves blank every tile whose + // labels need a range that failed to load, so answer an empty glyph set instead. + if (path.endsWith(".pbf")) writeStatus(out, 200, "OK"); + else writeStatus(out, 404, "Not Found"); + return; + } + long totalLen = afd.getLength(); + try { + long[] range = resolveRange(rangeSpec, totalLen); + if (range == null) { + writeStatus(out, 416, "Requested Range Not Satisfiable"); return; + } + long rangeStart = range[0]; + long rangeEnd = range[1]; + long sliceLen = rangeEnd - rangeStart + 1; + boolean partial = (rangeStart != 0) || (rangeEnd != totalLen - 1); + + try (FileInputStream fis = afd.createInputStream()) { + // Position the stream before any header goes out, so a failure can still + // answer 500 instead of sending the wrong bytes under a 206 header. + try { + skipFully(fis, rangeStart); + } catch (IOException e) { + writeStatus(out, 500, "Internal Server Error"); return; + } + StringBuilder hdr = new StringBuilder(partial + ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n"); + hdr.append("Content-Type: application/octet-stream\r\n"); + hdr.append("Accept-Ranges: bytes\r\n"); + hdr.append("Content-Length: ").append(sliceLen).append("\r\n"); + if (partial) { + hdr.append("Content-Range: bytes ").append(rangeStart).append('-') + .append(rangeEnd).append('/').append(totalLen).append("\r\n"); + } + hdr.append("Connection: close\r\n\r\n"); + out.write(hdr.toString().getBytes("US-ASCII")); + + byte[] buf = new byte[BUF]; + long remaining = sliceLen; + while (remaining > 0) { + int want = (int) Math.min(buf.length, remaining); + int n = fis.read(buf, 0, want); + if (n < 0) break; + out.write(buf, 0, n); + remaining -= n; + } + out.flush(); + } + } finally { + try { afd.close(); } catch (IOException ignored) {} + } + } catch (Exception e) { + Log.w(TAG, "handle: " + e); + } + } + + /** + * The {@code {start, end}} bytes (inclusive) a {@code Range: bytes=} value asks for, or null + * when it cannot be satisfied. Handles {@code a-b}, {@code a-} and the suffix form {@code -n}; + * a missing or malformed value (including several ranges) means the whole file. + */ + static long[] resolveRange(String spec, long totalLen) { + long last = totalLen - 1; + long[] whole = {0, last}; + if (spec == null) return totalLen > 0 ? whole : null; + int dash = spec.indexOf('-'); + if (dash < 0 || spec.indexOf(',') >= 0) return whole; + try { + String head = spec.substring(0, dash).trim(); + String tail = spec.substring(dash + 1).trim(); + if (head.isEmpty()) { + long suffix = Long.parseLong(tail); + if (suffix <= 0 || totalLen == 0) return null; + return new long[] {Math.max(0, totalLen - suffix), last}; + } + long start = Long.parseLong(head); + long end = tail.isEmpty() ? last : Math.min(Long.parseLong(tail), last); + if (start >= totalLen) return null; + return end < start ? whole : new long[] {start, end}; + } catch (NumberFormatException e) { + return whole; + } + } + + /** + * Skips exactly {@code n} bytes of {@code in}. {@link InputStream#skip} may return 0 before + * the end of the stream, so a one-byte read tells the two apart; the end throws. + */ + static void skipFully(InputStream in, long n) throws IOException { + long remaining = n; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped > 0) { + remaining -= skipped; + } else if (in.read() >= 0) { + remaining--; + } else { + throw new EOFException("stream ended with " + remaining + " bytes left to skip"); + } + } + } + + private void writeStatus(OutputStream out, int code, String msg) throws IOException { + String s = "HTTP/1.1 " + code + " " + msg + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + out.write(s.getBytes("US-ASCII")); + out.flush(); + } +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb new file mode 100644 index 00000000..9eb45e60 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb @@ -0,0 +1,224 @@ +package ${{PACKAGE_NAME}} + +import android.content.res.AssetManager +import android.util.Log +import java.io.BufferedReader +import java.io.EOFException +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.SynchronousQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +/** + * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets + * with Range-request support. Used to feed bundled PMTiles into MapLibre via + * `pmtiles://http://127.0.0.1:PORT/...` — the only PMTiles URL scheme proven to + * work in MapLibre 13.1.0 OpenGL ES on Android. + * + * Requires assets to be stored uncompressed in the APK + * (`androidResources { noCompress.add("pmtiles") }`) so [AssetManager.openFd] + * returns a real seekable fd. + * + * The server has no authentication: any app on the device can connect to its port. Serve + * only data that is already public, like these map tiles. + */ +class PmtilesHttpServer( + private val assets: AssetManager, + rootPrefix: String, +) { + private val rootPrefix: String = if (rootPrefix.endsWith("/")) rootPrefix else "$rootPrefix/" + private var socket: ServerSocket? = null + private var pool: ThreadPoolExecutor? = null + var port: Int = 0 + private set + @Volatile private var running = false + + @Throws(IOException::class) + fun start(): Int { + val s = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")) + socket = s + port = s.localPort + running = true + val workers = ThreadPoolExecutor(0, MAX_CLIENTS, 30, TimeUnit.SECONDS, SynchronousQueue()) + pool = workers + val accept = Thread({ + while (running) { + try { + val client = s.accept() + try { + workers.execute { handle(client) } + } catch (e: RejectedExecutionException) { + Log.w(TAG, "all $MAX_CLIENTS threads busy, dropping a connection") + try { client.close() } catch (_: IOException) {} + } + } catch (e: IOException) { + if (running) Log.w(TAG, "accept: $e") + } + } + }, "PmtilesHttpServer-accept") + accept.isDaemon = true + accept.start() + Log.i(TAG, "listening on 127.0.0.1:$port serving assets/$rootPrefix") + return port + } + + fun stop() { + running = false + try { socket?.close() } catch (_: IOException) {} + pool?.shutdownNow() + } + + private fun handle(client: Socket) { + try { + client.use { c -> + c.soTimeout = READ_TIMEOUT_MS + val out = c.getOutputStream() + val r = BufferedReader(InputStreamReader(c.getInputStream())) + val reqLine = r.readLine() ?: return + val parts = reqLine.split(" ") + if (parts.size < 2 || !parts[0].equals("GET", ignoreCase = true)) { + writeStatus(out, 405, "Method Not Allowed"); return + } + var path = parts[1] + if (path.startsWith("/")) path = path.substring(1) + val q = path.indexOf('?') + if (q >= 0) path = path.substring(0, q) + if (!path.startsWith(rootPrefix)) { writeStatus(out, 404, "Not Found"); return } + // Reject path-traversal segments. AssetManager already sandboxes + // to the APK's assets/ tree and the server is loopback-only, but a + // ".." in the request path has no legitimate use here. + if (path.contains("..")) { writeStatus(out, 404, "Not Found"); return } + + var rangeSpec: String? = null + var line: String? + while (r.readLine().also { line = it } != null && !line.isNullOrEmpty()) { + val l = line ?: continue + if (l.regionMatches(0, "Range:", 0, 6, ignoreCase = true)) { + val v = l.substring(6).trim() + if (v.startsWith("bytes=")) rangeSpec = v.substring(6) + } + } + + val afd = try { + assets.openFd(path) + } catch (e: IOException) { + // A glyph range the app does not bundle. MapLibre leaves blank every tile whose + // labels need a range that failed to load, so answer an empty glyph set instead. + if (path.endsWith(".pbf")) writeStatus(out, 200, "OK") + else writeStatus(out, 404, "Not Found") + return + } + val totalLen = afd.length + try { + val range = resolveRange(rangeSpec, totalLen) + if (range == null) { + writeStatus(out, 416, "Requested Range Not Satisfiable"); return + } + val (rangeStart, rangeEnd) = range + val sliceLen = rangeEnd - rangeStart + 1 + val partial = rangeStart != 0L || rangeEnd != totalLen - 1 + + afd.createInputStream().use { fis -> + // Position the stream before any header goes out, so a failure can still + // answer 500 instead of sending the wrong bytes under a 206 header. + try { + skipFully(fis, rangeStart) + } catch (e: IOException) { + writeStatus(out, 500, "Internal Server Error"); return + } + val hdr = StringBuilder( + if (partial) "HTTP/1.1 206 Partial Content\r\n" else "HTTP/1.1 200 OK\r\n" + ) + hdr.append("Content-Type: application/octet-stream\r\n") + hdr.append("Accept-Ranges: bytes\r\n") + hdr.append("Content-Length: ").append(sliceLen).append("\r\n") + if (partial) { + hdr.append("Content-Range: bytes ").append(rangeStart).append('-') + .append(rangeEnd).append('/').append(totalLen).append("\r\n") + } + hdr.append("Connection: close\r\n\r\n") + out.write(hdr.toString().toByteArray(Charsets.US_ASCII)) + + val buf = ByteArray(BUF) + var remaining = sliceLen + while (remaining > 0) { + val want = minOf(buf.size.toLong(), remaining).toInt() + val n = fis.read(buf, 0, want) + if (n < 0) break + out.write(buf, 0, n) + remaining -= n + } + out.flush() + } + } finally { + try { afd.close() } catch (_: IOException) {} + } + } + } catch (e: Exception) { + Log.w(TAG, "handle: $e") + } + } + + private fun writeStatus(out: OutputStream, code: Int, msg: String) { + val s = "HTTP/1.1 $code $msg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + out.write(s.toByteArray(Charsets.US_ASCII)) + out.flush() + } + + private companion object { + const val TAG = "PmtilesHttpServer" + const val BUF = 64 * 1024 + + /** Bounds each read, not the whole request: a client that sends nothing for this long is dropped. */ + const val READ_TIMEOUT_MS = 5_000 + + /** Caps threads without queueing, since a queued tile request waits behind idle sockets and the map stays blank. */ + const val MAX_CLIENTS = 64 + + /** + * The (start, end) bytes (inclusive) a `Range: bytes=` value asks for, or null when it + * cannot be satisfied. Handles `a-b`, `a-` and the suffix form `-n`; a missing or + * malformed value (including several ranges) means the whole file. + */ + fun resolveRange(spec: String?, totalLen: Long): Pair? { + val last = totalLen - 1 + val whole = 0L to last + if (spec == null) return if (totalLen > 0) whole else null + val dash = spec.indexOf('-') + if (dash < 0 || ',' in spec) return whole + val head = spec.substring(0, dash).trim() + val tail = spec.substring(dash + 1).trim() + if (head.isEmpty()) { + val suffix = tail.toLongOrNull() ?: return whole + if (suffix <= 0 || totalLen == 0L) return null + return maxOf(0L, totalLen - suffix) to last + } + val start = head.toLongOrNull() ?: return whole + val end = if (tail.isEmpty()) last else minOf(tail.toLongOrNull() ?: return whole, last) + if (start >= totalLen) return null + return if (end < start) whole else start to end + } + + /** + * Skips exactly [n] bytes of [input]. `InputStream.skip` may return 0 before the end of + * the stream, so a one-byte read tells the two apart; the end throws. + */ + fun skipFully(input: java.io.InputStream, n: Long) { + var remaining = n + while (remaining > 0) { + val skipped = input.skip(remaining) + when { + skipped > 0 -> remaining -= skipped + input.read() >= 0 -> remaining-- + else -> throw EOFException("stream ended with $remaining bytes left to skip") + } + } + } + } +} diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml new file mode 100644 index 00000000..d876a510 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..ccd9ffa6 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + #000000 + #FFFFFF + diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb new file mode 100644 index 00000000..01317a29 --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb @@ -0,0 +1,4 @@ + + ${{APP_NAME}} + Location permission is required to centre the map on your position. + diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..ca49106d --- /dev/null +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + + + + + diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt new file mode 100644 index 00000000..2cb12fe5 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt @@ -0,0 +1,434 @@ +package org.appdevforall.maps + +import android.content.Context +import android.content.ContextWrapper +import com.itsaky.androidide.plugins.PluginContext +import org.appdevforall.maps.data.IiabArchiveResolver +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.testing.UnusedPluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ResourceManager +import com.itsaky.androidide.plugins.ServiceRegistry +import java.io.File +import java.io.InputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Test + +/** + * Coverage for [MapsPlugin]'s host-facing surface using hand-written fakes of the + * plugin-api host interfaces — there is no mock framework available in this module, + * so the established pattern is to implement only the members the code under test + * calls. + * + * What's covered on a plain JVM: + * - Companion constants ([MapsPlugin.PLUGIN_ID], tab id, tooltip tag). + * - The [com.itsaky.androidide.plugins.extensions.DocumentationExtension] surface: + * `getTooltipCategory`, `getTier3DocsAssetPath`, `getTooltipEntries` shape, + * `onDocumentationInstall` / `onDocumentationUninstall`. + * - The [com.itsaky.androidide.plugins.extensions.UIExtension] `getEditorTabs` + * TabItem (id / title / order / tooltipTag / enabled / visible), built with no + * resource read. + * - The lifecycle: `initialize` (sets the static `pluginContext`), `activate` + * (no template service → logs + returns true), `deactivate` (no service → no-op + * true), and `dispose` (cancels the scope + clears the static `pluginContext`). + * + * The `registerMapTemplates` background coroutine body (`MapTemplateBuilder.buildAndRegister`) + * is NOT asserted: it needs a real `AssetManager` and the host's zip internals, and + * it runs async on `Dispatchers.IO` so its outcome isn't deterministically observable + * here. The null-service early-return branch IS exercised. See `skipped`. + */ +class MapsPluginCoverageTest { + + @After + fun tearDown() { + // Each test that calls initialize() sets the global static; reset between + // tests so ordering can't leak a stale context. + MapsPlugin.pluginContext = null + } + + // ---------------------------------------------------------------------- + // Hand-written host fakes + // ---------------------------------------------------------------------- + + /** Captures log calls so a test can assert a path logged (not just ran). */ + private class FakeLogger : PluginLogger { + val messages = mutableListOf() + override val pluginId: String = "org.appdevforall.maps" + override fun debug(message: String) { messages += message } + override fun debug(message: String, throwable: Throwable) { messages += message } + override fun info(message: String) { messages += message } + override fun info(message: String, throwable: Throwable) { messages += message } + override fun warn(message: String) { messages += message } + override fun warn(message: String, throwable: Throwable) { messages += message } + override fun error(message: String) { messages += message } + override fun error(message: String, throwable: Throwable) { messages += message } + } + + /** + * Service registry that returns a configurable value for `get` — the plugin + * only ever asks for `IdeTemplateService`. Returning null drives the + * "service unavailable" early-return branches in `activate` / `deactivate`. + */ + private class FakeServiceRegistry(private val service: Any? = null) : ServiceRegistry { + @Suppress("UNCHECKED_CAST") + override fun get(serviceClass: Class): T? = service as T? + override fun register(serviceClass: Class, service: T) = + throw UnsupportedOperationException("not used") + override fun getAll(serviceClass: Class): List = + throw UnsupportedOperationException("not used") + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + + /** ResourceManager whose plugin dir is a real temp dir so any file use is safe. */ + private class FakeResourceManager(private val dir: File) : ResourceManager { + override fun getPluginDirectory(): File = dir + override fun getPluginFile(path: String): File = File(dir, path) + override fun getPluginResource(path: String): ByteArray = + throw UnsupportedOperationException("not used") + override fun openPluginResource(path: String): InputStream = + throw UnsupportedOperationException("not used") + override fun openPluginAsset(path: String): InputStream = + throw UnsupportedOperationException("not used") + } + + /** A [Context] with no base, so any resource read through it fails the test. */ + private class FakeContext : ContextWrapper(null) + + /** PluginContext fake wiring the four sub-fakes together. */ + private class FakePluginContext( + androidCtx: Context, + services: ServiceRegistry, + resources: ResourceManager, + logger: PluginLogger, + ) : PluginContext by UnusedPluginContext { + override val androidContext: Context = androidCtx + override val services: ServiceRegistry = services + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: PluginLogger = logger + override val resources: ResourceManager = resources + override val pluginId: String = "org.appdevforall.maps" + } + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + private val tmpDirs = mutableListOf() + + private fun newContext( + service: Any? = null, + logger: FakeLogger = FakeLogger(), + ): FakePluginContext { + val dir = File.createTempFile("maps-plugin-test", "").let { + it.delete(); it.mkdirs(); it + } + tmpDirs += dir + return FakePluginContext( + androidCtx = FakeContext(), + services = FakeServiceRegistry(service), + resources = FakeResourceManager(dir), + logger = logger, + ) + } + + @After + fun cleanupTmp() { + tmpDirs.forEach { it.deleteRecursively() } + tmpDirs.clear() + } + + // ---------------------------------------------------------------------- + // Companion constants + // ---------------------------------------------------------------------- + + @Test + fun `companion exposes the stable plugin id, tab id and tooltip tag`() { + assertEquals("org.appdevforall.maps", MapsPlugin.PLUGIN_ID) + assertEquals("maps_maps_tab", MapsPlugin.MAPS_BOTTOM_SHEET_TAB_ID) + assertEquals("maps.bottom_sheet.maps_tab", MapsPlugin.TOOLTIP_TAG_MAPS_TAB) + } + + // ---------------------------------------------------------------------- + // DocumentationExtension surface + // ---------------------------------------------------------------------- + + @Test + fun `tooltip category and tier3 docs path are the documented constants`() { + val plugin = MapsPlugin() + // The host derives "plugin_" + the full plugin.id on lookup; a short slug renders n/a. + assertEquals("plugin_org.appdevforall.maps", plugin.getTooltipCategory()) + assertEquals("docs", plugin.getTier3DocsAssetPath()) + } + + @Test + fun `tooltip entries expose the maps tab entry with an html tutorial button`() { + val entries = MapsPlugin().getTooltipEntries() + val entry = entries.single { it.tag == MapsPlugin.TOOLTIP_TAG_MAPS_TAB } + // The entry tag must match the tab's tooltipTag so the host can resolve it. + assertEquals(MapsPlugin.TOOLTIP_TAG_MAPS_TAB, entry.tag) + assertTrue("summary should be the HTML Maps headline", + entry.summary.contains("Maps")) + assertTrue("detail should reference the on-device cache dir", + entry.detail.contains("/sdcard/CodeOnTheGo/maps/")) + + assertEquals("one tutorial button", 1, entry.buttons.size) + val button = entry.buttons[0] + // Tier-3 docs render in a WebView; the asset MUST be .html, not .md. + assertEquals("index.html", button.uri) + assertEquals(0, button.order) + } + + @Test + fun `every tooltip tag the plugin attaches to UI has a registered entry`() { + val registered = MapsPlugin().getTooltipEntries().map { it.tag }.toSet() + // The tab, the template card and each screen carry a tag; an unregistered tag renders n/a. + assertEquals( + setOf( + MapsPlugin.TOOLTIP_TAG_MAPS_TAB, + MapsPlugin.TOOLTIP_TAG_REGION_MAP_TEMPLATE, + MapsPlugin.TOOLTIP_TAG_REGION_LIST, + MapsPlugin.TOOLTIP_TAG_SOURCE_PICKER, + MapsPlugin.TOOLTIP_TAG_BBOX_PICKER, + MapsPlugin.TOOLTIP_TAG_SAVE_REGION, + MapsPlugin.TOOLTIP_TAG_DOWNLOAD_PROGRESS, + ), + registered, + ) + val template = MapsPlugin().getTooltipEntries() + .single { it.tag == MapsPlugin.TOOLTIP_TAG_REGION_MAP_TEMPLATE } + assertTrue(template.summary.contains("Offline OSM Map")) + assertEquals("index.html", template.buttons.single().uri) + } + + /** + * The screens are not JVM-testable, so this reads their source: each of the five must wire a + * tag, and every tag referenced anywhere in the plugin must have an entry. + */ + @Test + fun `every tooltip tag a screen wires has a registered entry`() { + val sourceRoot = File("src/main/kotlin/org/appdevforall/maps") + val tagRef = Regex("""MapsPlugin\.(TOOLTIP_TAG_[A-Z_]+)""") + fun tagsIn(file: File) = tagRef.findAll(file.readText()).map { it.groupValues[1] }.toSet() + + val screens = listOf( + "RegionManagerFragment", "SourcePickerFragment", "BboxPickerFragment", + "Step3SaveFragment", "DownloadProgressFragment", + ) + for (screen in screens) { + val file = File(sourceRoot, "ui/$screen.kt") + assertTrue("$file must exist", file.isFile) + assertTrue("$screen wires no tooltip tag", tagsIn(file).isNotEmpty()) + } + + val sources = sourceRoot.walk().filter { it.extension == "kt" }.toList() + assertTrue("scanned ${sources.size} files", sources.size > 20) + val referenced = sources.flatMap(::tagsIn).toSet() + .associateWith { MapsPlugin::class.java.getField(it).get(null) as String } + val registered = MapsPlugin().getTooltipEntries().map { it.tag }.toSet() + assertEquals( + "tags with no entry", + emptyMap(), + referenced.filterValues { it !in registered }, + ) + } + + @Test + fun `every tutorial link opens a page and anchor that ship in the docs folder`() { + val docsDir = File("src/main/assets/docs") + val buttons = MapsPlugin().getTooltipEntries().flatMap { it.buttons } + assertTrue("entries carry tutorial buttons", buttons.size >= 7) + for (button in buttons) { + val page = button.uri.substringBefore('#') + val file = File(docsDir, page) + assertTrue("${button.uri}: $file must exist", file.isFile) + val anchor = button.uri.substringAfter('#', "") + if (anchor.isNotEmpty()) { + assertTrue("${button.uri}: no id=\"$anchor\" in $page", file.readText().contains("id=\"$anchor\"")) + } + } + } + + /** The page is served from documentation.db offline, so an image must ship beside it. */ + @Test + fun `every image the tutorial shows ships in the docs folder and stays small`() { + val docsDir = File("src/main/assets/docs") + val sources = Regex("""= 4) + for (src in sources) { + val file = File(docsDir, src) + assertTrue("$src: $file must exist", file.isFile) + assertTrue("$src is ${file.length()} bytes, over 150 KB", file.length() <= 150L * 1024) + } + } + + @Test + fun `documentation install returns true and uninstall is a no-op that runs`() { + val plugin = MapsPlugin() + val logger = FakeLogger() + plugin.initialize(newContext(logger = logger)) + + assertTrue("install hook should report success", plugin.onDocumentationInstall()) + plugin.onDocumentationUninstall() // must not throw + assertTrue( + "both doc hooks should have logged", + logger.messages.any { it.contains("documentation installed") } && + logger.messages.any { it.contains("documentation uninstalled") }, + ) + } + + // ---------------------------------------------------------------------- + // UIExtension — getEditorTabs + // ---------------------------------------------------------------------- + + @Test + fun `getEditorTabs builds the Maps tab without reading a plugin resource`() { + val plugin = MapsPlugin() + // FakeContext has no resources: a getString call returns null here and fails the TabItem. + plugin.initialize(newContext()) + + val tab = plugin.getEditorTabs().single() + + assertEquals("Maps", tab.title) + assertEquals(MapsPlugin.MAPS_BOTTOM_SHEET_TAB_ID, tab.id) + assertEquals(MapsPlugin.TOOLTIP_TAG_MAPS_TAB, tab.tooltipTag) + assertEquals(200, tab.order) + assertTrue(tab.isEnabled && tab.isVisible) + } + + // ---------------------------------------------------------------------- + // Lifecycle + // ---------------------------------------------------------------------- + + @Test + fun `initialize sets the static plugin context and returns true`() { + val plugin = MapsPlugin() + val ctx = newContext() + + assertNull("static should be clear before initialize", MapsPlugin.pluginContext) + val ok = plugin.initialize(ctx) + + assertTrue("initialize should succeed", ok) + assertSame("the static must point at the supplied context", ctx, MapsPlugin.pluginContext) + } + + @Test + fun `activate without a template service logs a warning and still returns true`() { + val plugin = MapsPlugin() + val logger = FakeLogger() + plugin.initialize(newContext(service = null, logger = logger)) + + val ok = plugin.activate() + + assertTrue("activate should succeed even if templates can't register", ok) + assertTrue( + "the missing-service branch should warn", + logger.messages.any { it.contains("IdeTemplateService unavailable") }, + ) + } + + @Test + fun `deactivate without a template service is a no-op that returns true`() { + val plugin = MapsPlugin() + plugin.initialize(newContext(service = null)) + + assertTrue("deactivate should report success", plugin.deactivate()) + } + + @Test + fun `dispose clears the static plugin context`() { + val plugin = MapsPlugin() + plugin.initialize(newContext()) + assertNotNull("initialize should have set the static", MapsPlugin.pluginContext) + + plugin.dispose() + + assertNull("dispose must clear the static so a reload starts clean", MapsPlugin.pluginContext) + } + + @Test + fun `dispose after initialize does not throw and leaves a reusable plugin`() { + val plugin = MapsPlugin() + plugin.initialize(newContext()) + plugin.dispose() + // A second initialize must re-arm the static — proves dispose() didn't + // wedge any non-recreatable state. + val ctx2 = newContext() + assertTrue(plugin.initialize(ctx2)) + assertSame(ctx2, MapsPlugin.pluginContext) + } + + @Test + fun `dispose closes the idle connections of both shared http clients`() { + val server = okhttp3.mockwebserver.MockWebServer() + repeat(2) { server.enqueue(okhttp3.mockwebserver.MockResponse().setBody("ok")) } + server.start() + try { + val clients = listOf(RegionDownloader.httpClient, IiabArchiveResolver.catalogClient) + // The pools are process-wide, so drop what earlier tests left before counting. + clients.forEach { it.connectionPool.evictAll() } + for (client in clients) { + client.newCall(okhttp3.Request.Builder().url(server.url("/")).build()).execute().use { it.body!!.string() } + } + assertEquals("each client keeps its connection alive", listOf(1, 1), clients.map { it.connectionPool.idleConnectionCount() }) + + val plugin = MapsPlugin() + plugin.initialize(newContext()) + plugin.dispose() + + assertEquals(listOf(0, 0), clients.map { it.connectionPool.idleConnectionCount() }) + } finally { + server.shutdown() + } + } + + @Test + fun `dispose cancels calls still in flight on both shared http clients`() { + val server = okhttp3.mockwebserver.MockWebServer() + // The server never answers, so each call is still running when dispose() runs. + repeat(2) { + server.enqueue( + okhttp3.mockwebserver.MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + ) + } + server.start() + try { + val clients = listOf(RegionDownloader.httpClient, IiabArchiveResolver.catalogClient) + val failures = java.util.concurrent.ConcurrentLinkedQueue() + val done = java.util.concurrent.CountDownLatch(clients.size) + val calls = clients.map { client -> + client.newCall(okhttp3.Request.Builder().url(server.url("/")).build()).also { call -> + call.enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + failures += e.message.orEmpty() + done.countDown() + } + + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + response.close() + done.countDown() + } + }) + } + } + repeat(2) { server.takeRequest(5, java.util.concurrent.TimeUnit.SECONDS) } + + val plugin = MapsPlugin() + plugin.initialize(newContext()) + plugin.dispose() + + assertTrue("both calls end well before the 5 s timeouts", done.await(3, java.util.concurrent.TimeUnit.SECONDS)) + assertEquals(listOf(true, true), calls.map { it.isCanceled() }) + assertEquals(listOf("Canceled", "Canceled"), failures.toList()) + } finally { + server.shutdown() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginTemplateLifecycleTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginTemplateLifecycleTest.kt new file mode 100644 index 00000000..8bcff68e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginTemplateLifecycleTest.kt @@ -0,0 +1,202 @@ +package org.appdevforall.maps + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.AssetManager +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ResourceManager +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.services.IdeTemplateService +import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder +import java.io.File +import java.io.InputStream +import java.util.Collections +import org.appdevforall.maps.templates.MapTemplateBuilder +import org.appdevforall.maps.testing.TestAssetManagers +import org.appdevforall.maps.testing.UnusedPluginContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Covers [MapsPlugin]'s template lifecycle with a template service present: the background + * registration on activate (success and failure), unregistration on deactivate, and the failure + * paths of initialize and of lifecycle calls made before initialize. + */ +class MapsPluginTemplateLifecycleTest { + + @get:Rule + val tmp = TemporaryFolder() + + @After + fun tearDown() { + MapsPlugin.pluginContext = null + } + + private class RecordingLogger(private val failOnInfo: Boolean = false) : PluginLogger { + val messages: MutableList = Collections.synchronizedList(mutableListOf()) + override val pluginId: String = MapsPlugin.PLUGIN_ID + override fun debug(message: String) { messages += message } + override fun debug(message: String, throwable: Throwable) { messages += message } + override fun info(message: String) { + if (failOnInfo) throw IllegalStateException("logger offline") + messages += message + } + override fun info(message: String, throwable: Throwable) { messages += message } + override fun warn(message: String) { messages += "WARN $message" } + override fun warn(message: String, throwable: Throwable) { messages += "WARN $message" } + override fun error(message: String) { messages += "ERROR $message" } + override fun error(message: String, throwable: Throwable) { messages += "ERROR $message" } + } + + private class Services(private val lookup: () -> Any?) : ServiceRegistry { + override fun get(serviceClass: Class): T? = serviceClass.cast(lookup()) + override fun register(serviceClass: Class, service: T) = throw UnsupportedOperationException() + override fun getAll(serviceClass: Class): List = throw UnsupportedOperationException() + override fun unregister(serviceClass: Class<*>) = throw UnsupportedOperationException() + } + + private class PluginDir(private val dir: File) : ResourceManager { + override fun getPluginDirectory(): File = dir + override fun getPluginFile(path: String): File = File(dir, path) + override fun getPluginResource(path: String): ByteArray = throw UnsupportedOperationException() + override fun openPluginResource(path: String): InputStream = throw UnsupportedOperationException() + override fun openPluginAsset(path: String): InputStream = throw UnsupportedOperationException() + } + + private class AssetContext(private val assets: AssetManager) : ContextWrapper(null) { + override fun getAssets(): AssetManager = assets + } + + private class Ctx( + override val logger: PluginLogger, + override val services: ServiceRegistry, + override val resources: ResourceManager, + override val androidContext: Context, + ) : PluginContext by UnusedPluginContext + + private class TemplateService( + private val failBuild: Boolean = false, + private val unregisterResult: Boolean = true, + ) : IdeTemplateService { + val registered: MutableList = Collections.synchronizedList(mutableListOf()) + val unregistered = mutableListOf() + + override fun createTemplateBuilder(templateName: String): CgtTemplateBuilder { + if (failBuild) throw IllegalStateException("builder unavailable") + return CgtTemplateBuilder(templateName) + } + override fun registerTemplate(cgtFile: File): Boolean { registered += cgtFile; return true } + override fun unregisterTemplate(templateName: String): Boolean { + unregistered += templateName + return unregisterResult + } + override fun isTemplateRegistered(templateName: String): Boolean = throw UnsupportedOperationException() + override fun getRegisteredTemplates(): List = throw UnsupportedOperationException() + override fun reloadTemplates(): Unit = throw UnsupportedOperationException() + } + + private fun context(logger: PluginLogger, lookup: () -> Any?): Ctx = Ctx( + logger = logger, + services = Services(lookup), + resources = PluginDir(tmp.root), + androidContext = AssetContext(TestAssetManagers.fromDirectory(File("src/main/assets"))), + ) + + /** Bounded poll: registration runs on the plugin's IO scope, so its log line lands asynchronously. */ + private fun awaitMessage(logger: RecordingLogger, needle: String) { + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline) { + if (logger.messages.toList().any { needle in it }) return + Thread.sleep(20) + } + fail("no log line containing '$needle'; got ${logger.messages}") + } + + @Test + fun `activate builds and registers the template into the plugin directory`() { + val logger = RecordingLogger() + val svc = TemplateService() + val plugin = MapsPlugin() + plugin.initialize(context(logger) { svc }) + + assertTrue(plugin.activate()) + awaitMessage(logger, "Registered 1 Maps project template(s)") + + val cgt = svc.registered.single() + assertEquals(File(tmp.root, "templates/${MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE}"), cgt) + assertTrue(cgt.isFile) + plugin.dispose() + } + + @Test + fun `a failed template build is logged as a warning, not thrown`() { + val logger = RecordingLogger() + val svc = TemplateService(failBuild = true) + val plugin = MapsPlugin() + plugin.initialize(context(logger) { svc }) + + assertTrue(plugin.activate()) + awaitMessage(logger, "WARN Failed to register Maps templates: builder unavailable") + + assertTrue(svc.registered.isEmpty()) + plugin.dispose() + } + + @Test + fun `deactivate unregisters the template file from the host`() { + val logger = RecordingLogger() + val svc = TemplateService(unregisterResult = false) + val plugin = MapsPlugin() + plugin.initialize(context(logger) { svc }) + + assertTrue(plugin.deactivate()) + + assertEquals(listOf(MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE), svc.unregistered) + assertTrue(logger.messages.contains("Unregistered Maps project template: false")) + } + + @Test + fun `deactivate still succeeds when the service lookup throws`() { + val logger = RecordingLogger() + val plugin = MapsPlugin() + plugin.initialize(context(logger) { throw IllegalStateException("registry gone") }) + + assertTrue(plugin.deactivate()) + + assertTrue( + "got ${logger.messages}", + logger.messages.contains("WARN Failed to unregister Maps template: registry gone"), + ) + } + + @Test + fun `initialize reports failure when the logger throws`() { + val logger = RecordingLogger(failOnInfo = true) + val plugin = MapsPlugin() + + assertFalse(plugin.initialize(context(logger) { null })) + assertEquals(listOf("ERROR MapsPlugin initialize() failed"), logger.messages.toList()) + } + + @Test + fun `lifecycle and documentation hooks fail loudly before initialize`() { + val calls: List Unit>> = listOf( + "activate" to { p -> p.activate() }, + "deactivate" to { p -> p.deactivate() }, + "dispose" to { p -> p.dispose() }, + "onDocumentationInstall" to { p -> p.onDocumentationInstall() }, + "onDocumentationUninstall" to { p -> p.onDocumentationUninstall() }, + ) + for ((name, call) in calls) { + val thrown = runCatching { call(MapsPlugin()) }.exceptionOrNull() + assertTrue("$name before initialize threw $thrown", thrown is UninitializedPropertyAccessException) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/PluginLogTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/PluginLogTest.kt new file mode 100644 index 00000000..92b49295 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/PluginLogTest.kt @@ -0,0 +1,57 @@ +package org.appdevforall.maps + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import java.io.IOException +import org.appdevforall.maps.testing.UnusedPluginContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +class PluginLogTest { + + /** Records each call as "level:message[:error message]". */ + private class RecordingLogger : PluginLogger { + val lines = mutableListOf() + override val pluginId: String = MapsPlugin.PLUGIN_ID + override fun debug(message: String) = Unit + override fun debug(message: String, throwable: Throwable) = Unit + override fun info(message: String) { lines += "info:$message" } + override fun info(message: String, throwable: Throwable) { lines += "info:$message:${throwable.message}" } + override fun warn(message: String) { lines += "warn:$message" } + override fun warn(message: String, throwable: Throwable) { lines += "warn:$message:${throwable.message}" } + override fun error(message: String) { lines += "error:$message" } + override fun error(message: String, throwable: Throwable) { lines += "error:$message:${throwable.message}" } + } + + @After + fun tearDown() { + MapsPlugin.pluginContext = null + } + + @Test + fun `each level reaches the host logger, with the error when one is given`() { + val logger = RecordingLogger() + MapsPlugin.pluginContext = object : PluginContext by UnusedPluginContext { + override val logger: PluginLogger = logger + } + + PluginLog.info("started") + PluginLog.warn("slow") + PluginLog.warn("retry", IOException("timeout")) + PluginLog.error("failed", IOException("disk")) + + assertEquals( + listOf("info:started", "warn:slow", "warn:retry:timeout", "error:failed:disk"), + logger.lines, + ) + } + + @Test + fun `with no plugin context every level is a no-op`() { + PluginLog.info("started") + PluginLog.warn("slow") + PluginLog.warn("retry", IOException("timeout")) + PluginLog.error("failed", IOException("disk")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt new file mode 100644 index 00000000..f0513ee1 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt @@ -0,0 +1,104 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Branch-coverage top-up for [ActiveRegionStore] — the branches the round-trip + * suite in `ActiveRegionStoreTest` leaves uncovered: + * + * - `clear` when neither marker file exists (the `if (f.exists())` false branch). + * - `readMarker` when the path is a directory, not a regular file (`isFile` false). + * - `write` happy path with mapsSubpath created on demand (the onSuccess log path). + * - `read` falling through to null when both markers are absent. + */ +class ActiveRegionStoreBranchCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val mapsSubpath = "app/src/main/assets/maps" + + private fun activeFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + + @Test + fun clearIsIdempotentWhenNoMarkersExist() = runBlocking { + // Neither active.txt nor region-id.txt present → clear is a no-op and + // must not throw (exercises the `if (f.exists())` false branch twice). + val project = tmp.newFolder("project") + ActiveRegionStore.clear(project, mapsSubpath) + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun readIgnoresMarkerThatIsADirectory() = runBlocking { + // A directory named active.txt exists → `isFile` is false, so readMarker + // returns null and read falls through to the (absent) legacy marker. + val project = tmp.newFolder("project") + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}").mkdirs() + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeCreatesMapsSubpathOnDemand() = runBlocking { + // mapsSubpath doesn't exist yet → write mkdirs() it and lands a valid id. + val project = tmp.newFolder("project") + val mapsRoot = File(project, mapsSubpath) + assertFalse("precondition: subpath absent", mapsRoot.exists()) + + ActiveRegionStore.write(project, mapsSubpath, "kandahar-afg") + + assertTrue("subpath created", mapsRoot.exists()) + assertTrue(activeFile(project).exists()) + assertEquals("kandahar-afg", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeSwallowsFailureWhenMapsPathIsBlockedByAFile() = runBlocking { + // Force AtomicFiles.writeText to throw: plant a regular FILE where the + // maps subpath's parent directory needs to be, so mkdirs() can't create + // the maps dir and the temp-file write fails. write() must catch it (the + // onFailure log arm) and not propagate — the panel can't crash on a + // wedged filesystem. + val project = tmp.newFolder("project") + // mapsSubpath = "app/src/main/assets/maps"; block it at "app" by making + // "app" a file, so File(project, mapsSubpath) can never be a directory. + File(project, "app").writeText("I am a file, not a directory") + + // Must not throw. + ActiveRegionStore.write(project, mapsSubpath, "blocked-region") + + // And nothing landed. + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun readPrefersActiveOverLegacyWhenBothValid() = runBlocking { + // Both markers present & valid → the first readMarker returns non-null, + // so `?.let { return it }` short-circuits before the legacy read (the + // success arm of the first marker lookup). + val project = tmp.newFolder("project") + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + .apply { parentFile!!.mkdirs(); writeText("active-region") } + File(project, "$mapsSubpath/${ActiveRegionStore.REGION_MARKER_FILE}") + .writeText("legacy-region") + assertEquals("active-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeOverwritesPreviousActiveRegion() = runBlocking { + // Two writes — the second wins (no stale value left behind). + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "first-region") + ActiveRegionStore.write(project, mapsSubpath, "second-region") + assertEquals("second-region", ActiveRegionStore.read(project, mapsSubpath)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt new file mode 100644 index 00000000..92e70449 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt @@ -0,0 +1,97 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [ActiveRegionStore]. Covers: read/write round-trip, the legacy + * region-id.txt fallback, clear removing both markers, bounded reads, and + * invalid-id refusal. + */ +class ActiveRegionStoreTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val mapsSubpath = "app/src/main/assets/maps" + + private fun activeFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + + private fun legacyFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.REGION_MARKER_FILE}") + + @Test + fun `write then read round-trips the regionId`() = runBlocking { + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "addis-ababa") + assertEquals("addis-ababa", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read returns null when no marker exists`() = runBlocking { + val project = tmp.newFolder("project") + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read prefers active-txt over the legacy marker`() = runBlocking { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("current-region") } + legacyFile(project).writeText("old-region") + assertEquals("current-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read falls back to the legacy region-id-txt marker`() = runBlocking { + val project = tmp.newFolder("project") + legacyFile(project).apply { parentFile!!.mkdirs(); writeText("legacy-region") } + assertEquals("legacy-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `clear removes both active-txt and the legacy marker`() = runBlocking { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("region-a") } + legacyFile(project).writeText("region-b") + ActiveRegionStore.clear(project, mapsSubpath) + assertTrue("active.txt should be gone", !activeFile(project).exists()) + assertTrue("region-id.txt should be gone", !legacyFile(project).exists()) + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `write refuses an invalid regionId`() = runBlocking { + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "../escape") + assertNull("invalid id must not land in active.txt", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores a blank marker`() = runBlocking { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText(" ") } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores an oversized marker`() = runBlocking { + val project = tmp.newFolder("project") + // Over the 1 KB defensive bound — even if it parses to a valid-looking id. + activeFile(project).apply { parentFile!!.mkdirs(); writeText("a".repeat(2048)) } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores a marker whose content is not a valid regionId`() = runBlocking { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("Not A Valid Id!") } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreUnreadableTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreUnreadableTest.kt new file mode 100644 index 00000000..3b5e4765 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreUnreadableTest.kt @@ -0,0 +1,37 @@ +package org.appdevforall.maps.data + +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** Covers [ActiveRegionStore.read] falling back to the legacy marker when active.txt cannot be read. */ +class ActiveRegionStoreUnreadableTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val subpath = "app/src/main/assets/maps" + + @Test + fun `an unreadable active marker falls back to the legacy marker`() = runBlocking { + val project = tmp.newFolder("project") + val active = File(project, "$subpath/${ActiveRegionStore.ACTIVE_REGION_FILE}").apply { + parentFile.mkdirs() + writeText("lagos") + } + File(project, "$subpath/${ActiveRegionStore.REGION_MARKER_FILE}").writeText("dhaka") + active.setReadable(false) + try { + // Root ignores file modes, so the read would succeed and test nothing. + assumeFalse("running as root", active.canRead()) + + assertEquals("dhaka", ActiveRegionStore.read(project, subpath)) + } finally { + active.setReadable(true) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt new file mode 100644 index 00000000..c13b3b83 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt @@ -0,0 +1,71 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DownloadCompleteMessage] — the pure + * `FirstRegionAutoActivator.Result` → post-download Snackbar mapping extracted + * from `RegionManagerFragment.onDownloadComplete`. Asserts the string resource and its argument + * for each branch; the text itself lives in `strings.xml`. + */ +class DownloadCompleteMessageTest { + + @Test + fun `Activated - confirms the apply, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.Activated(regionId = "lagos", displayName = "Lagos"), + displayName = "Lagos", + ) + assertEquals(R.string.maps_download_done_applied, spec.messageRes) + assertEquals("Lagos", spec.messageArg) + assertFalse(spec.showApplyAction) + } + + @Test + fun `Updated - says the project's map is updated, no Apply action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.Updated(regionId = "lagos", displayName = "Lagos"), + displayName = "lagos", + ) + assertFalse("the copy is already updated, so there is nothing to apply", spec.showApplyAction) + assertEquals(R.string.maps_download_done_updated, spec.messageRes) + assertEquals("Lagos", spec.messageArg) + } + + @Test + fun `NoOpAlreadyActive - active region untouched, offers the Apply action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.NoOpAlreadyActive, + displayName = "Lagos", + ) + assertEquals(R.string.maps_download_done_unchanged, spec.messageRes) + assertEquals("Lagos", spec.messageArg) + assertTrue("the only branch with a one-tap Apply escape hatch", spec.showApplyAction) + } + + @Test + fun `NoOpRegionNotFound - surfaces the cache miss, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.NoOpRegionNotFound, + displayName = "Lagos", + ) + assertEquals(R.string.maps_download_done_not_found, spec.messageRes) + assertEquals("Lagos", spec.messageArg) + assertFalse(spec.showApplyAction) + } + + @Test + fun `ApplyFailed - surfaces the reason, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.ApplyFailed(regionId = "lagos", reason = "disk full"), + displayName = "Lagos", + ) + assertEquals(R.string.maps_download_done_apply_failed, spec.messageRes) + assertEquals("disk full", spec.messageArg) + assertFalse(spec.showApplyAction) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt new file mode 100644 index 00000000..bf6c7c4d --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt @@ -0,0 +1,148 @@ +package org.appdevforall.maps.data + +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** [DownloadCompletion] against a temp project and a temp region cache. */ +class DownloadCompletionTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private lateinit var project: FakeProjectRegions + private var listFails = false + + private val completion by lazy { + DownloadCompletion( + project = project, + listRegions = { if (listFails) error("cache unreadable") else RegionCache.listFromRoot(cacheRoot) }, + cacheRoot = { cacheRoot }, + ) + } + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-dc-project").toFile() + cacheRoot = Files.createTempDirectory("maps-dc-cache").toFile() + project = FakeProjectRegions(projectDir) + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + private fun seed(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON) + .writeText( + JSONObject() + .apply { + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(listOf(0.0, 0.0, 1.0, 1.0))) + put("source", JSONObject().apply { put("kind", "internet") }) + } + .toString() + ) + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(16)) + } + + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + + @Test + fun `no open project is reported by region name and nothing is copied`() = runBlocking { + seed("alpha", "Alpha Town") + project.projectDir = null + assertEquals(DownloadCompletion.Outcome.NoProject("Alpha Town"), completion.complete("alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `the first region in a project is activated in that project`() = runBlocking { + seed("alpha", "Alpha Town") + val outcome = completion.complete("alpha") + assertEquals( + DownloadCompletion.Outcome.Finished( + projectDir, + FirstRegionAutoActivator.Result.Activated("alpha", "Alpha Town"), + "Alpha Town", + ), + outcome, + ) + assertEquals("alpha", active()) + } + + @Test + fun `a project with a live active region keeps it`() = runBlocking { + seed("alpha", "Alpha Town") + seed("beta") + project.writeActiveRegionId(projectDir, "beta") + val outcome = completion.complete("alpha") as DownloadCompletion.Outcome.Finished + assertEquals(FirstRegionAutoActivator.Result.NoOpAlreadyActive, outcome.result) + assertEquals("Alpha Town", outcome.displayName) + assertEquals("beta", active()) + } + + @Test + fun `a region missing from an unreadable cache is named by its id`() = runBlocking { + listFails = true + project.projectDir = null + assertEquals(DownloadCompletion.Outcome.NoProject("alpha"), completion.complete("alpha")) + } + + @Test + fun `snackbar apply copies the region in, activates it and names it`() = runBlocking { + seed("alpha", "Alpha Town") + project.writeActiveRegionId(projectDir, "beta") + assertEquals( + DownloadCompletion.ApplyResult.Switched("Alpha Town"), + completion.applyFromSnackbar(projectDir, "alpha"), + ) + assertEquals(listOf("alpha"), project.applied) + assertEquals("alpha", active()) + } + + @Test + fun `snackbar apply of the already-active region says so and refreshes its copy`() = runBlocking { + seed("alpha", "Alpha Town") + project.writeActiveRegionId(projectDir, "alpha") + assertEquals( + DownloadCompletion.ApplyResult.AlreadyActive("Alpha Town"), + completion.applyFromSnackbar(projectDir, "alpha"), + ) + assertEquals(listOf("alpha"), project.applied) + } + + @Test + fun `snackbar apply of a region gone from the cache does nothing`() = runBlocking { + seed("beta") + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `snackbar apply with an unreadable cache does nothing`() = runBlocking { + seed("alpha") + listFails = true + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `a failed snackbar copy reports failure and leaves active_txt alone`() = runBlocking { + seed("alpha") + project.writeActiveRegionId(projectDir, "beta") + project.applyResult = false + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals("beta", active()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt new file mode 100644 index 00000000..b4d9f288 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt @@ -0,0 +1,52 @@ +package org.appdevforall.maps.data + +import java.io.File + +/** + * A [ProjectRegions] over a temp project dir: the active id is the real `active.txt` through + * [ActiveRegionStore], and the region copy only records its call and returns [applyResult]. + */ +internal class FakeProjectRegions( + var projectDir: File?, + var applyResult: Boolean = true, +) : ProjectRegions { + + val applied = mutableListOf() + + override suspend fun currentProjectRoot(): File? = projectDir + + override suspend fun readActiveRegionId(projectDir: File): String? = + ActiveRegionStore.read(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + + override suspend fun writeActiveRegionId(projectDir: File, regionId: String) { + ActiveRegionStore.write(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, regionId) + } + + override suspend fun clearActiveRegionId(projectDir: File) { + ActiveRegionStore.clear(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + } + + override suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { + val ok = applyRegionToProject(info, projectDir) + if (ok) writeActiveRegionId(projectDir, info.regionId) + return ok + } + + override suspend fun applyRegionToProject(info: RegionInfo, projectDir: File): Boolean { + applied += info.regionId + return applyResult + } +} + +/** A [RegionInfo] whose directory need not exist; enough for code that only reads its fields. */ +internal fun regionInfo(id: String, name: String = "Name of $id", bbox: DoubleArray? = null) = + RegionInfo( + regionId = id, + displayName = name, + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = File("/nonexistent/$id"), + bbox = bbox, + ) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt new file mode 100644 index 00000000..f4c3a17b --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt @@ -0,0 +1,200 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Branch-coverage top-up for [FirstRegionAutoActivator] — the + * `readActiveSentinel` branches the policy suite in + * `FirstRegionAutoActivatorTest` leaves uncovered: + * + * - the sentinel path exists but is a DIRECTORY (`isFile` false) → treated as + * no active region, so the download auto-activates. + * - no `active.txt` at all (the `!file.exists()` true branch) with a region + * present → activates. + */ +class FirstRegionAutoActivatorBranchCoverageTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private val mapsSubpath = "app/src/main/assets/maps" + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-fra-branch-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-branch-cache").toFile() + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + private fun seedCachedRegion(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(listOf(0.0, 0.0, 1.0, 1.0))) + put("source", JSONObject().apply { put("kind", "internet") }) + }.toString() + ) + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(8)) + } + + private val applyOk: suspend (RegionInfo, File) -> Boolean = { _, _ -> true } + + private val writeCalls = mutableListOf>() + private val writeActive: (File, String) -> Unit = { dir, id -> + writeCalls += dir to id + File(dir, mapsSubpath).apply { mkdirs() } + val sentinel = File(File(dir, mapsSubpath), "active.txt") + // The dir-as-sentinel test seeds active.txt as a directory; clear it + // before writing the real marker (mirrors the activate-proceeds path). + if (sentinel.isDirectory) sentinel.deleteRecursively() + sentinel.writeText(id) + } + + @Test + fun activatesWhenSentinelPathIsADirectory() { + runBlocking { + // active.txt exists but is a directory → readActiveSentinel's + // `!file.isFile` guard returns null → activator proceeds. + seedCachedRegion("freetown", "Freetown") + File(projectDir, "$mapsSubpath/active.txt").mkdirs() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "freetown", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "Directory sentinel should be ignored; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals(1, writeCalls.size) + assertEquals("freetown", writeCalls.first().second) + } + } + + @Test + fun applyFailedCarriesRegionIdAndReason() { + runBlocking { + // Drive the ApplyFailed arm and read its generated accessors so the + // data class's component/equals/toString branches are covered, and + // assert the contract: the failing region's id + a reason string, + // and writeActive is NOT called (no half-applied active.txt). + seedCachedRegion("monrovia", "Monrovia") + writeCalls.clear() + + val applyFails: suspend (RegionInfo, File) -> Boolean = { _, _ -> false } + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "monrovia", + applyRegionToProject = applyFails, + writeActiveRegion = writeActive, + ) + + assertTrue(result is FirstRegionAutoActivator.Result.ApplyFailed) + result as FirstRegionAutoActivator.Result.ApplyFailed + assertEquals("monrovia", result.regionId) + assertTrue("reason should be non-blank", result.reason.isNotBlank()) + assertTrue("apply failure must not write active.txt", writeCalls.isEmpty()) + // toString is part of the generated surface — exercise it. + assertTrue(result.toString().contains("monrovia")) + } + } + + @Test + fun activatedResultExposesRegionIdAndDisplayName() { + runBlocking { + // Read both generated accessors of the Activated data class. + seedCachedRegion("bamako", "Bamako, Mali") + writeCalls.clear() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "bamako", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + result as FirstRegionAutoActivator.Result.Activated + assertEquals("bamako", result.regionId) + assertEquals("Bamako, Mali", result.displayName) + } + } + + @Test + fun noOpAlreadyActiveWhenSentinelNamesAStillPresentRegion() { + runBlocking { + // existingActive non-blank AND the named region still exists in the + // cache → the `activeStillExists` true branch returns NoOpAlreadyActive + // without touching apply/write. (The policy suite covers this too, but + // it's the decision arm paired with the stale-sentinel fall-through, so + // assert it explicitly here against the directory-sentinel doubles.) + seedCachedRegion("dakar", "Dakar") + seedCachedRegion("conakry", "Conakry") + File(projectDir, "$mapsSubpath").mkdirs() + File(projectDir, "$mapsSubpath/active.txt").writeText("dakar") + writeCalls.clear() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "conakry", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "active region present → NoOp; got $result", + result is FirstRegionAutoActivator.Result.NoOpAlreadyActive, + ) + assertTrue("must not write when already active", writeCalls.isEmpty()) + } + } + + @Test + fun activatesWhenNoSentinelFileExistsAtAll() { + runBlocking { + // No active.txt on disk — the `!file.exists()` true branch in + // readActiveSentinel. A seeded region auto-activates. + seedCachedRegion("juba", "Juba") + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "juba", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "Missing sentinel should activate; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals("juba", (result as FirstRegionAutoActivator.Result.Activated).regionId) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt new file mode 100644 index 00000000..9d5203e6 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt @@ -0,0 +1,153 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Coverage top-up for [FirstRegionAutoActivator] beyond the policy suites: + * + * - the injected `applyRegionToProject` / `writeActiveRegion` callbacks + * GENUINELY suspend (production passes real I/O suspend lambdas; the other + * suites' fakes return synchronously, so the coroutine resume arms of + * `maybeAutoActivate` never run there); + * - an EXISTING but UNREADABLE `active.txt` — `readActiveSentinel`'s + * `runCatching { readText() }` failure arm → treated as "no active region", + * so the new download still auto-activates instead of wedging the project. + */ +class FirstRegionAutoActivatorSuspendCoverageTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private val mapsSubpath = "app/src/main/assets/maps" + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-fra-susp-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-susp-cache").toFile() + } + + @After + fun tearDown() { + // Restore readability so deleteRecursively can't be tripped up by the + // unreadable-sentinel test's chmod. + projectDir.walkBottomUp().forEach { it.setReadable(true) } + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + private fun seedCachedRegion(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", regionId) + put("displayName", displayName) + }.toString() + ) + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(16)) + } + + @Test + fun activates_when_apply_and_write_callbacks_genuinely_suspend() = runBlocking { + // Production wires real suspend I/O into both callbacks; the activator + // must come back through its resume path with the same Activated result + // and the same ordering guarantee (write only after apply succeeded). + seedCachedRegion("timbuktu", "Timbuktu, Mali") + val order = mutableListOf() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "timbuktu", + applyRegionToProject = { info, _ -> + delay(5) // force a real suspension point + order += "apply:${info.regionId}" + true + }, + writeActiveRegion = { dir, id -> + delay(5) // force a real suspension point + order += "write:$id" + val mapsRoot = File(dir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(id) + }, + ) + + assertTrue("Expected Activated, got $result", result is FirstRegionAutoActivator.Result.Activated) + assertEquals("Timbuktu, Mali", (result as FirstRegionAutoActivator.Result.Activated).displayName) + assertEquals( + "apply must complete before the active sentinel is written", + listOf("apply:timbuktu", "write:timbuktu"), + order, + ) + assertEquals( + "timbuktu", + File(projectDir, "$mapsSubpath/active.txt").readText().trim(), + ) + } + + @Test + fun apply_failure_still_propagates_through_a_suspending_callback() = runBlocking { + // The ApplyFailed arm reached via a genuinely-suspending apply lambda — + // the resume path must carry the `false` back into the !applied branch. + seedCachedRegion("mopti") + var writeCalled = false + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "mopti", + applyRegionToProject = { _, _ -> + delay(5) + false + }, + writeActiveRegion = { _, _ -> writeCalled = true }, + ) + + assertTrue("Expected ApplyFailed, got $result", result is FirstRegionAutoActivator.Result.ApplyFailed) + assertEquals("mopti", (result as FirstRegionAutoActivator.Result.ApplyFailed).regionId) + assertTrue("active sentinel must not be written on apply failure", !writeCalled) + } + + @Test + fun activates_when_existing_sentinel_is_unreadable() = runBlocking { + // active.txt exists, is a small regular file, but can't be read → + // readActiveSentinel's runCatching yields null → no active region → + // the new download auto-activates (rather than NoOpAlreadyActive + // on a sentinel whose content we couldn't even see). + seedCachedRegion("zinder", "Zinder") + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + val sentinel = File(mapsRoot, "active.txt").apply { writeText("zinder") } + // If the platform/user can't actually revoke read (e.g. running as + // root), the scenario can't be produced — skip rather than fake-pass. + assumeTrue(sentinel.setReadable(false, false) && !sentinel.canRead()) + + val applied = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "zinder", + applyRegionToProject = { info, _ -> applied += info.regionId; true }, + writeActiveRegion = { dir, id -> + val root = File(dir, mapsSubpath).apply { mkdirs() } + val f = File(root, "active.txt") + f.setReadable(true) + f.writeText(id) + }, + ) + + assertTrue("Unreadable sentinel must not block activation; got $result", + result is FirstRegionAutoActivator.Result.Activated) + assertEquals(listOf("zinder"), applied) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt new file mode 100644 index 00000000..0d259fbb --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt @@ -0,0 +1,317 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for [FirstRegionAutoActivator]. Exercises the policy + applies it + * end-to-end against temp project / cache directories. + * + * The actual file-copy (`applyRegionToProject`) is injected as a fake that + * just records what it was called with — same shape RegionManagerFragment + * passes in production, but no real I/O. This keeps the test fast + isolated. + */ +class FirstRegionAutoActivatorTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private val mapsSubpath = "app/src/main/assets/maps" + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-fra-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-cache").toFile() + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + /** Drop a minimal valid region dir into [cacheRoot] so `RegionCache.listFromRoot` finds it. */ + private fun seedCachedRegion(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(listOf(0.0, 0.0, 1.0, 1.0))) + put("zoomMin", 6) + put("zoomMax", 14) + put("source", JSONObject().apply { put("kind", "internet") }) + put("sizeBytes", 1024) + put("downloadedAt", System.currentTimeMillis()) + put("lastUsedAt", System.currentTimeMillis()) + }.toString() + ) + // The size sum reads from on-disk artifacts; touch a stub tiles file so + // the listed RegionInfo isn't a phantom. + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(1024)) + } + + private fun writeActiveSentinel(text: String) { + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(text) + } + + /** Apply fake that records its arguments + returns true. */ + private val applyOk: (RegionInfo, File) -> Boolean = { _, _ -> true } + + /** Apply fake that simulates a failure (disk full / perms). */ + private val applyFail: (RegionInfo, File) -> Boolean = { _, _ -> false } + + private val writeActiveCalls = mutableListOf>() + private val writeActive: (File, String) -> Unit = { dir, id -> + writeActiveCalls += dir to id + // Mirror RegionManagerFragment's writeActiveRegionId behavior — write a + // real file so subsequent assertions can verify it. + val mapsRoot = File(dir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(id) + } + + // ----- Empty project (the user's first download) ----- + + @Test + fun activates_when_no_active_sentinel_yet() = runBlocking { + seedCachedRegion("lalibela", "Lalibela, Ethiopia") + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected Activated, got $result", + result is FirstRegionAutoActivator.Result.Activated + ) + assertEquals("lalibela", (result as FirstRegionAutoActivator.Result.Activated).regionId) + assertEquals("Lalibela, Ethiopia", result.displayName) + // File-copy was triggered exactly once. + assertEquals(1, applyCalls.size) + assertEquals("lalibela", applyCalls[0].regionId) + // active.txt was written via the injected writer. + val activeFile = File(projectDir, "$mapsSubpath/active.txt") + assertTrue("active.txt should exist after auto-activation", activeFile.exists()) + assertEquals("lalibela", activeFile.readText().trim()) + } + + // ----- Project already has an active region — must not overwrite ----- + + @Test + fun noOp_when_active_sentinel_already_names_a_region() = runBlocking { + // lalibela MUST also be in the cache — otherwise the sentinel is + // stale (the region was deleted out from under us) and the activator + // falls through to apply the new region. Tested separately below. + seedCachedRegion("lalibela", "Lalibela") + seedCachedRegion("kathmandu", "Kathmandu") + writeActiveSentinel("lalibela") // user's prior choice + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "kathmandu", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected NoOpAlreadyActive, got $result", + result is FirstRegionAutoActivator.Result.NoOpAlreadyActive + ) + assertTrue("apply must not be invoked when an active region exists", applyCalls.isEmpty()) + assertTrue("writeActive must not be invoked", writeActiveCalls.isEmpty()) + // Existing active.txt unchanged. + assertEquals("lalibela", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + // ----- The active region downloaded again (Bryan, 2026-10-28: update the copy on its own) ----- + + @Test + fun reapplies_when_the_active_region_is_downloaded_again() = runBlocking { + seedCachedRegion("lalibela", "Lalibela") + writeActiveSentinel("lalibela") + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertEquals(FirstRegionAutoActivator.Result.Updated("lalibela", "Lalibela"), result) + assertEquals("the new tiles must be copied into the project", listOf("lalibela"), applyCalls.map { it.regionId }) + assertEquals("lalibela", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + @Test + fun reports_the_failure_when_reapplying_the_active_region_fails() = runBlocking { + seedCachedRegion("lalibela", "Lalibela") + writeActiveSentinel("lalibela") + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = applyFail, + writeActiveRegion = writeActive, + ) + assertTrue("Expected ApplyFailed, got $result", result is FirstRegionAutoActivator.Result.ApplyFailed) + } + + /** + * Bryan, 2026-05-26 — observed bug: deleted all regions, downloaded + * Spain, got "active region is unchanged" because the previously-active + * region's name was still in active.txt even though its cache files + * were gone. Fix: treat such a sentinel as stale and proceed. + */ + @Test + fun activates_when_active_sentinel_names_a_deleted_region() = runBlocking { + // spain is in the cache; lalibela was deleted (only its stale + // sentinel survives). + seedCachedRegion("spain", "Spain") + writeActiveSentinel("lalibela") // stale — region cache doesn't have it + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "spain", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Stale sentinel should fall through to apply; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals(1, writeActiveCalls.size) + assertEquals("spain", writeActiveCalls.first().second) + // active.txt now updated to spain. + assertEquals("spain", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + // ----- Blank active sentinel = treat as no active region ----- + + @Test + fun activates_when_active_sentinel_is_blank() = runBlocking { + seedCachedRegion("kandahar") + writeActiveSentinel(" \n") // whitespace-only — should not block + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "kandahar", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue(result is FirstRegionAutoActivator.Result.Activated) + } + + // ----- Race: regionId not in cache (e.g., deleted mid-flight) ----- + + @Test + fun noOp_when_region_missing_from_cache() = runBlocking { + // Don't seed any region. + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "phantom", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected NoOpRegionNotFound, got $result", + result is FirstRegionAutoActivator.Result.NoOpRegionNotFound + ) + assertTrue(writeActiveCalls.isEmpty()) + } + + // ----- Apply failure (disk full, perms): MUST NOT write active.txt ----- + + @Test + fun does_not_write_active_when_apply_fails() = runBlocking { + seedCachedRegion("goma") + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "goma", + applyRegionToProject = applyFail, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected ApplyFailed, got $result", + result is FirstRegionAutoActivator.Result.ApplyFailed + ) + assertTrue("writeActive must not be invoked when apply fails", writeActiveCalls.isEmpty()) + // active.txt must NOT exist (or exists empty); otherwise the project + // would name a region whose files weren't copied → build fails with + // a misleading "missing tiles" error. + val activeFile = File(projectDir, "$mapsSubpath/active.txt") + assertFalse( + "active.txt must NOT exist after apply failure", + activeFile.exists() && activeFile.readText().trim().isNotEmpty() + ) + } + + // ----- Sentinel-format robustness ----- + + @Test + fun ignores_oversized_sentinel_treats_as_no_active() = runBlocking { + // A 1 KB file in active.txt is suspicious — could be a corrupted dump. + // The bounded-read in maybeAutoActivate caps at 256 bytes, so anything + // larger reads as "no active" and lets the auto-activate proceed. + seedCachedRegion("maiduguri") + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeBytes(ByteArray(1024) { 'x'.code.toByte() }) + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "maiduguri", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Oversized sentinel should be ignored; expected Activated, got $result", + result is FirstRegionAutoActivator.Result.Activated + ) + } + + // ----- Nested mapsSubpath is created when missing ----- + + @Test + fun creates_maps_subpath_if_absent_during_write() = runBlocking { + seedCachedRegion("port-au-prince", "Port-au-Prince") + val mapsRoot = File(projectDir, mapsSubpath) + assertFalse("precondition: maps subpath should not exist yet", mapsRoot.exists()) + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "port-au-prince", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue(result is FirstRegionAutoActivator.Result.Activated) + assertTrue("maps subpath must exist after activation", mapsRoot.exists()) + assertEquals( + "port-au-prince", + File(mapsRoot, "active.txt").readText().trim(), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt new file mode 100644 index 00000000..1fd44a99 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt @@ -0,0 +1,153 @@ +package org.appdevforall.maps.data + +import java.io.InterruptedIOException +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** [IiabCatalog] parsing and [IiabArchiveResolver] resolution, fallback and size cap. */ +class IiabCatalogTest { + + /** The upstream catalog, fetched from IIAB's `maps_catalog_url` on 2026-10-14. */ + private val realCatalog: String = + javaClass.getResource("/iiab/maps-catalog-2026-10-14.json")!!.readText() + + private fun catalog(vector14: String) = + """{"data":{"vector":{"14":"$vector14","11":"x"}}}""" + + @Test + fun `reads the z00-z14 archive from the real upstream catalog`() { + assertEquals( + "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles", + IiabCatalog.vectorArchive(realCatalog), + ) + } + + @Test + fun `rejects an entry that is off the mirror, the wrong zoom range or not a dated name`() { + val base = IiabCatalog.MIRROR_BASE + assertNull(IiabCatalog.vectorArchive(catalog("https://evil.example/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles"))) + assertNull(IiabCatalog.vectorArchive(catalog("${base}openstreetmap-openmaptiles.2026-10-01.z00-z11.pmtiles"))) + assertNull(IiabCatalog.vectorArchive(catalog("${base}openstreetmap-openmaptiles.latest.z00-z14.pmtiles"))) + assertNull(IiabCatalog.vectorArchive(catalog("${base}../openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles"))) + } + + @Test + fun `returns null for malformed json or a missing key`() { + assertNull(IiabCatalog.vectorArchive("not json")) + assertNull(IiabCatalog.vectorArchive("""{"data":{"vector":{"11":"x"}}}""")) + assertNull(IiabCatalog.vectorArchive("""{"README":[]}""")) + } + + @Test + fun `dateOf extracts the archive date and rejects other names`() { + assertEquals("2026-10-01", IiabCatalog.dateOf(IiabCatalog.FALLBACK_VECTOR_ARCHIVE)) + assertNull(IiabCatalog.dateOf("naturalearth-openmaptiles.2025-12-10.z00-z08.pmtiles")) + } + + @Test + fun `listedFiles keeps file entries and rejects a listing that is not a JSON array`() { + val listing = """[{"name":"a.pmtiles","type":"file"},{"name":"d","type":"directory"},"x",{"type":"file"}]""" + assertEquals(listOf("a.pmtiles", ""), IiabCatalog.listedFiles(listing)) + assertNull(IiabCatalog.listedFiles("""{"name":"a.pmtiles"}""")) + assertNull(IiabCatalog.listedFiles("")) + } + + @Test + fun `newestVectorArchive picks the latest date and ignores near-miss names`() { + val names = + listOf( + "openstreetmap-openmaptiles.2026-07-01.z00-z14.pmtiles", + "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles.old", + "openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", + "openstreetmap-openmaptiles.2026-12-01.z00-z08.pmtiles", + ) + assertEquals("openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", IiabCatalog.newestVectorArchive(names)) + assertNull(IiabCatalog.newestVectorArchive(emptyList())) + } + + @Test + fun `resolver falls back when the fetch fails, returns null or yields an unusable catalog`() = runBlocking { + val fallback = IiabCatalog.FALLBACK_VECTOR_ARCHIVE + assertEquals(fallback, IiabArchiveResolver { throw java.io.IOException("offline") }.resolve()) + assertEquals(fallback, IiabArchiveResolver { null }.resolve()) + assertEquals(fallback, IiabArchiveResolver { "{}" }.resolve()) + } + + @Test + fun `resolver fetches once after success and retries after a failure`() = runBlocking { + var calls = 0 + var answer: String? = null + val resolver = IiabArchiveResolver { calls++; answer } + + assertEquals(IiabCatalog.FALLBACK_VECTOR_ARCHIVE, resolver.resolve()) + assertEquals(IiabCatalog.FALLBACK_VECTOR_ARCHIVE, resolver.current()) + // A date the fallback does not carry, so a resolver that ignored the catalog fails here. + val newer = "openstreetmap-openmaptiles.2027-04-01.z00-z14.pmtiles" + answer = catalog("${IiabCatalog.MIRROR_BASE}$newer") + assertEquals(newer, resolver.resolve()) + assertEquals(newer, resolver.current()) + resolver.resolve() + assertEquals("a failed fetch is retried; a successful one is not", 2, calls) + } + + @Test + fun `resolver does not swallow cancellation`() = runBlocking { + try { + IiabArchiveResolver { throw CancellationException("cancelled") }.resolve() + fail("cancellation must propagate") + } catch (e: CancellationException) { + assertEquals("cancelled", e.message) + } + } + + @Test + fun `the catalog client gives up on a server that trickles its body`() = runBlocking { + val server = MockWebServer() + // One byte a second never trips the 5 s read timeout, so only a whole-call limit ends it. + server.enqueue(MockResponse().setResponseCode(200).setBody("z".repeat(30)).throttleBody(1, 1, TimeUnit.SECONDS)) + server.start() + try { + val started = System.nanoTime() + try { + IiabArchiveResolver.fetchText(IiabArchiveResolver.catalogClient, server.url("/slow").toString()) + fail("a 30 s trickle must not complete") + } catch (e: InterruptedIOException) { + val secs = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started) + assertTrue("gave up after $secs s", secs < 10) + } + } finally { + server.shutdown() + } + } + + @Test + fun `fetchText returns a 200 body and null for errors and oversized bodies`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(200).setBody(realCatalog)) + server.enqueue(MockResponse().setResponseCode(404).setBody("missing")) + server.enqueue(MockResponse().setResponseCode(200).setBody("x".repeat(101))) + // Chunked, so there is no Content-Length to refuse up front. + server.enqueue(MockResponse().setResponseCode(200).setChunkedBody(Buffer().writeUtf8("y".repeat(101)), 10)) + server.start() + try { + val client = OkHttpClient() + val url = server.url("/maps-catalog.json").toString() + assertEquals(realCatalog, IiabArchiveResolver.fetchText(client, url)) + assertNull(IiabArchiveResolver.fetchText(client, url)) + assertNull("declared length over the cap", IiabArchiveResolver.fetchText(client, url, maxBytes = 100)) + assertNull("streamed length over the cap", IiabArchiveResolver.fetchText(client, url, maxBytes = 100)) + } finally { + server.shutdown() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt new file mode 100644 index 00000000..ed742b08 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt @@ -0,0 +1,130 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.ResourceManager +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Covers [MapFontExtractor.extract]: copying every bundled glyph range out of plugin assets, + * skipping files already cached, and leaving no truncated file behind when an asset is + * missing or a copy fails. + */ +class MapFontExtractorTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val stacks = listOf("Noto Sans Regular", "Noto Sans Italic") + private val ranges = listOf("0-255", "256-511", "512-767", "7680-7935", "8192-8447") + private val allAssets = stacks.flatMap { s -> ranges.map { r -> "fonts/$s/$r.pbf" } } + + /** Serves each asset path as its own bytes, unless [streamFor] overrides it; records every open. */ + private class FakeAssets( + private val streamFor: (String) -> InputStream? = { ByteArrayInputStream(it.toByteArray()) }, + ) : ResourceManager { + val opened = mutableListOf() + override fun openPluginAsset(path: String): InputStream? { + opened += path + return streamFor(path) + } + override fun getPluginDirectory(): File = throw UnsupportedOperationException() + override fun getPluginFile(path: String): File = throw UnsupportedOperationException() + override fun getPluginResource(path: String): ByteArray = throw UnsupportedOperationException() + override fun openPluginResource(path: String): InputStream = throw UnsupportedOperationException() + } + + private fun fontFile(root: File, asset: String) = File(root, asset.removePrefix("fonts/")) + + @Test + fun `extracts every bundled range byte for byte under the cache dir`() { + val assets = FakeAssets() + + val root = MapFontExtractor.extract(assets, tmp.root) + + assertEquals(File(tmp.root, "maps-plugin-fonts"), root) + assertEquals(allAssets.toSet(), assets.opened.toSet()) + for (asset in allAssets) { + assertArrayEquals(asset.toByteArray(), fontFile(root!!, asset).readBytes()) + } + } + + @Test + fun `every unbundled range gets an empty file and bundled ranges keep their bytes`() { + val root = MapFontExtractor.extract(FakeAssets(), tmp.root)!! + + for (stack in stacks) { + val dir = File(root, stack) + // MapLibre leaves a tile blank when a range its labels need fails to load. + val combining = File(dir, "768-1023.pbf") + assertTrue("$combining was not written", combining.isFile) + assertEquals(0L, combining.length()) + assertTrue(File(dir, "65280-65535.pbf").isFile) + assertEquals(256, dir.listFiles()!!.size) + assertArrayEquals("fonts/$stack/0-255.pbf".toByteArray(), File(dir, "0-255.pbf").readBytes()) + } + } + + @Test + fun `a second run reuses cached files but re-extracts an empty one`() { + val root = MapFontExtractor.extract(FakeAssets(), tmp.root)!! + val emptied = fontFile(root, allAssets[3]) + emptied.writeBytes(ByteArray(0)) + val again = FakeAssets() + + assertEquals(root, MapFontExtractor.extract(again, tmp.root)) + + assertEquals(listOf(allAssets[3]), again.opened) + assertArrayEquals(allAssets[3].toByteArray(), emptied.readBytes()) + } + + @Test + fun `a missing asset is skipped and the other ranges still extract`() { + val missing = "fonts/Noto Sans Italic/512-767.pbf" + val assets = FakeAssets { if (it == missing) null else ByteArrayInputStream(byteArrayOf(7)) } + + val root = MapFontExtractor.extract(assets, tmp.root)!! + + assertFalse(fontFile(root, missing).exists()) + assertEquals(allAssets.size - 1, allAssets.count { fontFile(root, it).isFile }) + } + + @Test + fun `a failed copy deletes the partial file`() { + val broken = "fonts/Noto Sans Regular/0-255.pbf" + val assets = FakeAssets { + if (it != broken) ByteArrayInputStream(byteArrayOf(1)) + else object : InputStream() { + private var sent = false + override fun read(): Int = throw IOException("asset truncated") + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (sent) throw IOException("asset truncated") + sent = true + b[off] = 9 + return 1 + } + } + } + + val root = MapFontExtractor.extract(assets, tmp.root)!! + + assertFalse("no truncated glyph file may remain", fontFile(root, broken).exists()) + assertTrue(fontFile(root, "fonts/Noto Sans Regular/256-511.pbf").isFile) + } + + @Test + fun `returns null when the font root cannot be created`() { + val notADir = tmp.newFile("cache-is-a-file") + + assertNull(MapFontExtractor.extract(FakeAssets(), notADir)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt new file mode 100644 index 00000000..90bb846a --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt @@ -0,0 +1,89 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.PluginContext +import org.appdevforall.maps.testing.UnusedPluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ServiceRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * [ProjectRegionCoordinator.applyRegionToProject]'s error-routing lambda: when + * the underlying install fails with an exception (on a plain JVM the cache root + * can't resolve — `Environment` external storage is null), the failure must be + * routed to the LIVE plugin logger resolved through `pluginContextProvider`. + * `ProjectRegionCoordinatorTest` covers the provider-null side; this covers the + * provider-present side with a recording logger. + */ +class ProjectRegionCoordinatorLoggerRoutingTest { + + @get:Rule + val tmp = TemporaryFolder() + + private class RecordingLogger : PluginLogger { + val errors = mutableListOf>() + override val pluginId: String = "org.appdevforall.maps" + override fun debug(message: String) {} + override fun debug(message: String, throwable: Throwable) {} + override fun info(message: String) {} + override fun info(message: String, throwable: Throwable) {} + override fun warn(message: String) {} + override fun warn(message: String, throwable: Throwable) {} + override fun error(message: String) { errors += message to null } + override fun error(message: String, throwable: Throwable) { errors += message to throwable } + } + + private fun contextWithLogger(recording: PluginLogger): PluginContext = object : PluginContext by UnusedPluginContext { + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException("not used") + override val services: ServiceRegistry = object : ServiceRegistry { + override fun register(serviceClass: Class, implementation: T) = + throw UnsupportedOperationException("not used") + override fun get(serviceClass: Class): T? = null + override fun getAll(serviceClass: Class): List = emptyList() + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: PluginLogger = recording + override val resources: com.itsaky.androidide.plugins.ResourceManager + get() = throw UnsupportedOperationException("not used") + override val pluginId: String = "org.appdevforall.maps" + } + + @Test + fun install_failure_is_routed_to_the_plugin_logger() = runBlocking { + val logger = RecordingLogger() + val coordinator = ProjectRegionCoordinator( + pluginContextProvider = { contextWithLogger(logger) }, + ) + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("regions", "failregion") + val info = RegionInfo( + regionId = "failregion", + displayName = "Fail Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = regionDir, + ) + + val ok = coordinator.applyRegionToProject(info, project) + + assertFalse("JVM install (no external storage) must fail", ok) + assertEquals("exactly one error routed to the plugin logger", 1, logger.errors.size) + val (message, throwable) = logger.errors.single() + assertTrue( + "message must name the failing operation + region, got: $message", + message.contains("applyRegionToProject failed") && message.contains("failregion"), + ) + assertTrue("the causing exception must travel with the log call", throwable != null) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt new file mode 100644 index 00000000..975b6a5b --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt @@ -0,0 +1,183 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.PluginContext +import org.appdevforall.maps.testing.UnusedPluginContext +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.services.IdeProjectService +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Focused tests for [ProjectRegionCoordinator] — the project/active-region + * facade extracted from `RegionManagerFragment`. Temp-dir based like + * [ActiveRegionStoreTest]; the underlying stores have their own exhaustive + * suites, so this covers the coordinator's routing + its one policy rule + * (apply-then-activate pairing). + */ +class ProjectRegionCoordinatorTest { + + @get:Rule + val tmp = TemporaryFolder() + + /** No live PluginContext (standalone JVM) — every host lookup degrades to null. */ + private val coordinator = ProjectRegionCoordinator(pluginContextProvider = { null }) + + private fun activeFile(project: File) = File( + project, + "${ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH}/${ActiveRegionStore.ACTIVE_REGION_FILE}", + ) + + @Test + fun `currentProjectRoot is null without a plugin context`() = runBlocking { + assertNull(coordinator.currentProjectRoot()) + } + + @Test + fun `no-arg readActiveRegionId is null when no project is open`() = runBlocking { + assertNull(coordinator.readActiveRegionId()) + } + + @Test + fun `write then read round-trips through the coordinator`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "addis-ababa") + assertEquals("addis-ababa", coordinator.readActiveRegionId(project)) + } + + @Test + fun `write lands the sentinel under the default maps subpath`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "lagos") + assertEquals("lagos", activeFile(project).readText().trim()) + } + + @Test + fun `clear removes the active marker`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "addis-ababa") + coordinator.clearActiveRegionId(project) + assertNull(coordinator.readActiveRegionId(project)) + } + + @Test + fun `custom mapsSubpath is honored`() = runBlocking { + val custom = ProjectRegionCoordinator( + pluginContextProvider = { null }, + mapsSubpath = "custom/maps", + ) + val project = tmp.newFolder("project") + custom.writeActiveRegionId(project, "lagos") + assertEquals( + "lagos", + File(project, "custom/maps/${ActiveRegionStore.ACTIVE_REGION_FILE}").readText().trim(), + ) + assertEquals("lagos", custom.readActiveRegionId(project)) + } + + @Test + fun `applyAndActivate does not write active-txt when the apply fails`() = runBlocking { + // A bare temp dir is not a Maps project, so the region copy fails — + // the pairing rule says active.txt must then stay absent (otherwise the + // project would reference a region whose tiles were never copied). + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("regions", "test-region") + val info = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = regionDir, + ) + + val ok = coordinator.applyAndActivate(info, project) + + assertFalse("apply into a non-Maps project must fail", ok) + assertFalse("failed apply must NOT mark the region active", activeFile(project).exists()) + assertNull(coordinator.readActiveRegionId(project)) + } + + // ----- currentProjectRoot host-chain arms (hand-rolled interface fakes; + // this module deliberately has no mocking library) ----- + + /** PluginContext whose ServiceRegistry serves exactly [service] (or nothing). */ + private fun contextWith(service: IdeProjectService?): PluginContext = object : PluginContext by UnusedPluginContext { + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException("not used") + override val services: ServiceRegistry = object : ServiceRegistry { + override fun register(serviceClass: Class, implementation: T) = + throw UnsupportedOperationException("not used") + @Suppress("UNCHECKED_CAST") + override fun get(serviceClass: Class): T? = + if (serviceClass == IdeProjectService::class.java) service as T? else null + override fun getAll(serviceClass: Class): List = emptyList() + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: com.itsaky.androidide.plugins.PluginLogger + get() = throw UnsupportedOperationException("not used") + override val resources: com.itsaky.androidide.plugins.ResourceManager + get() = throw UnsupportedOperationException("not used") + override val pluginId: String = "org.appdevforall.maps" + } + + private fun projectServiceReturning(project: IProject?): IdeProjectService = + object : IdeProjectService { + override fun getCurrentProject(): IProject? = project + override fun getAllProjects(): List = emptyList() + override fun getProjectByPath(path: File): IProject? = null + override fun getModuleContext(name: String): com.itsaky.androidide.plugins.services.ModuleContext = + throw UnsupportedOperationException("not used") + } + + private fun projectAt(root: File): IProject = object : IProject { + override val name: String = root.name + override val rootDir: File = root + override val type: com.itsaky.androidide.plugins.extensions.ProjectType + get() = throw UnsupportedOperationException("not used") + override fun getModules() = throw UnsupportedOperationException("not used") + override fun getBuildFiles() = throw UnsupportedOperationException("not used") + } + + @Test + fun `currentProjectRoot is null when the host has no project service`() = runBlocking { + val c = ProjectRegionCoordinator(pluginContextProvider = { contextWith(service = null) }) + assertNull(c.currentProjectRoot()) + } + + @Test + fun `currentProjectRoot is null when no project is open`() = runBlocking { + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(project = null)) }, + ) + assertNull(c.currentProjectRoot()) + } + + @Test + fun `currentProjectRoot returns the open project's root`() = runBlocking { + val root = tmp.newFolder("openproj") + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(projectAt(root))) }, + ) + assertEquals(root, c.currentProjectRoot()) + } + + @Test + fun `no-arg readActiveRegionId resolves through the open project`() = runBlocking { + val root = tmp.newFolder("openproj2") + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(projectAt(root))) }, + ) + c.writeActiveRegionId(root, "lagos-v3") + assertEquals("lagos-v3", c.readActiveRegionId()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt new file mode 100644 index 00000000..f3c9b3b4 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt @@ -0,0 +1,112 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.appdevforall.maps.domain.SourceKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Exercises the HEAD-probe I/O path of [ReachabilityProbe.isReachable] against a + * local [MockWebServer] — the arms `ReachabilityProbeTest` (pure URL logic only) + * leaves untouched: + * + * - 2xx HEAD response → reachable (the `responseCode in 200..399` true arm) + * - non-2xx (404 / 500) → not reachable (the range-check false arm) + * - malformed probe URL → `runCatching` failure arm → `getOrDefault(false)` + * - LAN source with a blank host → the `probeUrlFor(...) ?: return false` + * null arm inside `isReachable` itself + * - explicit `http://` scheme host honoured by [ReachabilityProbe.buildLanProbeUrl] + * + * Deliberately NOT covered: the `withTimeoutOrNull` timeout arm — PROBE_TIMEOUT_MS + * is a private 6 s constant with no injection point, and a 6-second sleep per run + * isn't worth one defensive branch (production code untouchable per task rules). + */ +class ReachabilityProbeHeadCoverageTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun isReachable_true_on_200_head_response() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertTrue(probe.isReachable(SourceKind.INTERNET, host = "")) + // The probe really issued a HEAD against the configured path. + val recorded = server.takeRequest() + assertEquals("HEAD", recorded.method) + assertEquals("/maps/2/", recorded.path) + } + + @Test + fun isReachable_false_on_404() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertFalse("404 must read as unreachable", probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun isReachable_false_on_500() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertFalse("5xx must read as unreachable", probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun isReachable_probes_the_lan_inventory_url_for_iiab() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200)) + val probe = ReachabilityProbe(internetProbeUrl = "https://unused.example.org/") + // Point the "LAN host" at the mock server (host:port form, no scheme). + val host = "${server.hostName}:${server.port}" + + assertTrue(probe.isReachable(SourceKind.IIAB_LAN, host = host)) + assertEquals("/maps/extracts.json", server.takeRequest().path) + } + + @Test + fun isReachable_false_for_lan_with_blank_host() = runBlocking { + // probeUrlFor returns null → isReachable's elvis short-circuits to false + // without any network I/O (no request must reach the server). + val probe = ReachabilityProbe(internetProbeUrl = server.url("/").toString()) + + assertFalse(probe.isReachable(SourceKind.IIAB_LAN, host = " ")) + assertEquals("no request may be issued for a blank LAN host", 0, server.requestCount) + } + + @Test + fun isReachable_false_when_probe_url_is_malformed() = runBlocking { + // URL(...) throws MalformedURLException inside the runCatching → + // getOrDefault(false). Never touches the network. + val probe = ReachabilityProbe(internetProbeUrl = "::definitely not a url::") + + assertFalse(probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun buildLanProbeUrl_honours_explicit_plain_http_scheme() { + // ReachabilityProbeTest covers https + schemeless; this is the http:// arm. + val probe = ReachabilityProbe() + assertEquals( + "http://box.lan/maps/extracts.json", + probe.buildLanProbeUrl("http://box.lan"), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt new file mode 100644 index 00000000..9483b8e0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.SourceKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Tests the pure URL logic extracted from SourcePickerFragment. (The HEAD probe itself is I/O.) */ +class ReachabilityProbeTest { + + private val probe = ReachabilityProbe(internetProbeUrl = "https://example.org/maps/") + + @Test + fun `LAN url appends the extracts inventory path`() { + assertEquals("http://192.168.1.5/maps/extracts.json", probe.buildLanProbeUrl("192.168.1.5")) + } + + @Test + fun `LAN url honours an explicit scheme and trims a trailing slash`() { + assertEquals("https://box.local/maps/extracts.json", probe.buildLanProbeUrl("https://box.local/")) + } + + @Test + fun `probeUrlFor returns the internet url for INTERNET`() { + assertEquals("https://example.org/maps/", probe.probeUrlFor(SourceKind.INTERNET, host = "")) + } + + @Test + fun `probeUrlFor is null for LAN with a blank host`() { + assertNull(probe.probeUrlFor(SourceKind.IIAB_LAN, host = " ")) + } + + @Test + fun `probeUrlFor builds the LAN url when a host is present`() { + assertEquals( + "http://10.0.0.2/maps/extracts.json", + probe.probeUrlFor(SourceKind.IIAB_LAN, host = "10.0.0.2"), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt new file mode 100644 index 00000000..7a8ce2e6 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt @@ -0,0 +1,253 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Branch-coverage top-up for [RegionCache] — the parse branches the safety-path + * suite in `RegionCacheTest` leaves uncovered: + * + * - source object present but with a BLANK host → host coalesces to null. + * - `downloadedAt` / `lastUsedAt` positive (the `takeIf { it > 0 }` true branch). + * - `layers` array with blank / null-ish entries dropped. + * - `deleteFromRoot` invalid-id refusal (regex guard, before canonicalisation). + * - `listFromRoot` skipping a directory without `meta.json`. + */ +class RegionCacheBranchCoverageTest { + + private lateinit var tempRoot: File + + @Before + fun setUp() { + tempRoot = Files.createTempDirectory("maps-region-cache-branch").toFile() + } + + @After + fun tearDown() { + tempRoot.deleteRecursively() + } + + @Test + fun sourceObjectWithBlankHostCoalescesToNull() = runBlocking { + // Object-form source whose host is blank → sourceHost must read as null, + // exercising the `takeIf { it.isNotBlank() }` false branch. + val dir = File(tempRoot, "blank-host").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "blank-host") + put("source", JSONObject().apply { + put("kind", "internet") + put("host", " ") + }) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("internet", info!!.source) + assertNull("blank host must coalesce to null", info.sourceHost) + } + + @Test + fun sourceObjectMissingKindFallsBackToUnknown() = runBlocking { + // Object-form source with no "kind" → optString default "unknown". + val dir = File(tempRoot, "no-kind").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "no-kind") + put("source", JSONObject().apply { put("host", "box.lan") }) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir) + assertEquals("unknown", info!!.source) + assertEquals("box.lan", info.sourceHost) + } + + @Test + fun downloadedAtAndLastUsedAtSurfacedWhenPositive() = runBlocking { + // The `takeIf { it > 0L }` TRUE branch — both timestamps are non-null. + val dir = File(tempRoot, "with-times").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "with-times") + put("downloadedAt", 1735000000000L) + put("lastUsedAt", 1735100000000L) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertEquals(1735000000000L, info.downloadedAt) + assertEquals(1735100000000L, info.lastUsedAt) + } + + @Test + fun zeroTimestampsCoalesceToNull() = runBlocking { + // The `takeIf { it > 0L }` FALSE branch — explicit 0 reads as null. + val dir = File(tempRoot, "zero-times").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "zero-times") + put("downloadedAt", 0L) + put("lastUsedAt", 0L) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNull(info.downloadedAt) + assertNull(info.lastUsedAt) + } + + @Test + fun layersDropsBlankEntries() = runBlocking { + // Layers array containing blank strings — the `takeIf { isNotBlank }` + // filter must drop them, keeping only the real layer names. + val dir = File(tempRoot, "blank-layers").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "blank-layers") + put("layers", JSONArray(listOf("vector", "", " ", "basemap"))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertEquals(listOf("vector", "basemap"), info.layers) + } + + @Test + fun bboxWithNonNumericEntriesIsDroppedViaRunCatching() = runBlocking { + // A length-4 bbox whose entries aren't parseable as doubles makes + // arr.getDouble(i) throw inside the runCatching, so getOrNull() yields + // null — the catch arm, distinct from the `!has` / `length != 4` early + // returns. The rest of the record still parses. + val dir = File(tempRoot, "bad-bbox-values").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "bad-bbox-values") + put("bbox", JSONArray(listOf("north", "south", "east", "west"))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNull("non-numeric bbox must be dropped", info.bbox) + assertEquals("bad-bbox-values", info.regionId) + } + + @Test + fun bboxFullyParsedWhenAllFourPresentAndNumeric() = runBlocking { + // The success arm: has("bbox") true, length == 4, all getDouble succeed. + val dir = File(tempRoot, "good-bbox").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "good-bbox") + put("bbox", JSONArray(listOf(1.5, 2.5, 3.5, 4.5))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNotNull(info.bbox) + assertEquals(4, info.bbox!!.size) + assertEquals(4.5, info.bbox!![3], 0.0) + } + + @Test + fun layersPresentButEmptyArrayYieldsEmptyList() = runBlocking { + // layers key present but the array is empty → the for-loop body never + // runs, buildList returns empty. Distinct from the no-layers-key path. + val dir = File(tempRoot, "empty-layers").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "empty-layers") + put("layers", JSONArray()) + } + File(dir, "meta.json").writeText(meta.toString()) + + assertEquals(emptyList(), RegionCache.readDir(dir)!!.layers) + } + + @Test + fun deleteFromRootRefusesInvalidIdViaRegex() = runBlocking { + // Hits the `!isValidRegionId` true branch in deleteFromRoot (the regex + // guard) — returns false before any canonicalisation. + assertFalse(RegionCache.deleteFromRoot(tempRoot, "Bad Id!")) + assertFalse(RegionCache.deleteFromRoot(tempRoot, "UPPER")) + } + + @Test + fun deleteFromRootSucceedsForValidExistingRegion() = runBlocking { + // The happy path: valid id, path contained, target exists → deleted. + val dir = File(tempRoot, "goodregion").apply { mkdirs() } + File(dir, "meta.json").writeText("{}") + assertTrue(dir.exists()) + assertTrue(RegionCache.deleteFromRoot(tempRoot, "goodregion")) + assertFalse(dir.exists()) + } + + @Test + fun listFromRootSkipsDirWithoutMeta_butKeepsValidSibling() = runBlocking { + // No-meta dir is skipped (warn path); the valid sibling still lists. + File(tempRoot, "ghost").apply { mkdirs() } + val ok = File(tempRoot, "real").apply { mkdirs() } + File(ok, "meta.json").writeText( + JSONObject().apply { put("regionId", "real") }.toString() + ) + + val regions = RegionCache.listFromRoot(tempRoot) + assertEquals(1, regions.size) + assertEquals("real", regions[0].regionId) + } + + @Test + fun listFromRootReturnsEmptyWhenRootHasNoSubdirs() = runBlocking { + // listFiles filter returns an empty array (root has only files). + File(tempRoot, "loose-file.txt").writeText("x") + assertTrue(RegionCache.listFromRoot(tempRoot).isEmpty()) + } + + @Test + fun listReturnsEmptyWhenExternalStorageUnavailableOnJvm() = runBlocking { + // The no-arg list() routes through rootDir(), which calls + // Environment.getExternalStorageDirectory() — unavailable on the JVM, so + // it throws and the `runCatching { rootDir() }.getOrNull() ?: return + // emptyList()` elvis-true branch fires. Must yield an empty list, never + // throw (the bottom-sheet still has to render). + assertTrue(RegionCache.list().isEmpty()) + } + + @Test + fun deleteReturnsFalseWhenExternalStorageUnavailableOnJvm() = runBlocking { + // The no-arg delete() also reaches rootDir(); when it throws, the + // `getOrNull() ?: return false` branch returns false (couldn't even + // resolve the root, so nothing was deleted). + val dir = File(tempRoot, "goodregion").apply { mkdirs() } + assertFalse(RegionCache.delete(RegionCache.readDir(dir)!!)) + } + + @Test + fun deleteByInfoRemovesTheFolderEvenWhenMetaNamesAnotherId() = runBlocking { + val dir = File(tempRoot, "folder-name").apply { mkdirs() } + File(dir, "meta.json").writeText(JSONObject().apply { put("regionId", "meta-name") }.toString()) + val info = RegionCache.readDir(dir)!! + assertEquals("meta-name", info.regionId) + + assertTrue(RegionCache.deleteFromRoot(tempRoot, info)) + // Keyed off regionId, the delete found no "meta-name" folder, reported success and left this one. + assertFalse("the region's own folder must be gone", dir.exists()) + } + + @Test + fun readDirReturnsNullWhenReadThrowsOnNonDirectory() = runBlocking { + // read() opening a path that isn't a usable directory: readDir wraps in + // runCatching and yields a best-effort RegionInfo (dir name fallbacks), + // never throwing. + val dir = File(tempRoot, "empty-region").apply { mkdirs() } + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("empty-region", info!!.regionId) + // No artifacts on disk → size sum is 0. + assertEquals(0L, info.sizeBytes) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt new file mode 100644 index 00000000..35039117 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt @@ -0,0 +1,99 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Edge-arm top-up for [RegionCache] beyond `RegionCacheTest` / + * `RegionCacheBranchCoverageTest`: + * + * - `listFromRoot` where `root.listFiles` returns NULL (root missing, or a + * plain file) — distinct from the empty-array case already covered; + * - `meta.json` present but ZERO bytes — the `metaFile.length() > 0` false arm + * with `exists()` true: parses as an empty JSON object, all defaults from + * the directory; + * - the suspend entry points invoked from an ALREADY-IO-dispatched coroutine + * (production calls arrive both ways: cold from the main scope, and nested + * inside other `Dispatchers.IO` work like FirstRegionAutoActivator). + */ +class RegionCacheEdgeCoverageTest { + + private lateinit var tempRoot: File + + @Before + fun setUp() { + tempRoot = Files.createTempDirectory("maps-region-cache-edge").toFile() + } + + @After + fun tearDown() { + tempRoot.deleteRecursively() + } + + @Test + fun listFromRootIsEmptyWhenRootDoesNotExist() = runBlocking { + val missing = File(tempRoot, "never-created") + assertFalse("precondition", missing.exists()) + assertTrue(RegionCache.listFromRoot(missing).isEmpty()) + } + + @Test + fun listFromRootIsEmptyWhenRootIsAPlainFile() = runBlocking { + // listFiles on a non-directory returns null — must degrade to an empty + // list, never throw (the Region Manager tab still has to render). + val fileRoot = File(tempRoot, "not-a-dir.txt").apply { writeText("x") } + assertTrue(RegionCache.listFromRoot(fileRoot).isEmpty()) + } + + @Test + fun zeroByteMetaJsonFallsBackToDirectoryDefaults() = runBlocking { + // exists() true but length() == 0 → parsed as an empty meta object: + // regionId/displayName fall back to the dir name and the size column + // reflects the on-disk artifact sum. + val dir = File(tempRoot, "empty-meta").apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText("") + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(64)) + + val listed = RegionCache.listFromRoot(tempRoot) + assertEquals(1, listed.size) + assertEquals("empty-meta", listed[0].regionId) + assertEquals("empty-meta", listed[0].displayName) + assertEquals(64L, listed[0].sizeBytes) + } + + @Test + fun suspendEntryPointsWorkWhenAlreadyOnIoDispatcher() = runBlocking { + // Nested-in-IO invocation (withContext(IO) inside IO returns + // undispatched) must behave identically to the cold-dispatch path. + val dir = File(tempRoot, "nestedregion").apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", "nestedregion") + put("displayName", "Nested Region") + }.toString() + ) + + withContext(Dispatchers.IO) { + val listed = RegionCache.listFromRoot(tempRoot) + assertEquals(listOf("nestedregion"), listed.map { it.regionId }) + + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("Nested Region", info!!.displayName) + + assertTrue(RegionCache.deleteFromRoot(tempRoot, "nestedregion")) + assertFalse(dir.exists()) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt new file mode 100644 index 00000000..9916ba39 Binary files /dev/null and b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt differ diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt new file mode 100644 index 00000000..938e3105 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt @@ -0,0 +1,241 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Coverage tests for [RegionDownloader]'s JVM-testable surface. + * + * The download orchestration was refactored into [RegionDownloader.downloadInto], + * which takes the three IO seams (cache root, basemap copy, tile slice) plus the + * clock injected — so the partial→final dir flow, `meta.json` assembly, atomic + * rename, progress sequencing, regionId validation, and cleanup-on-failure are all + * exercised here with a temp dir + fakes. The pure URL builders + * ([RegionDownloader.buildTilesUrl] / `base`) are covered too. + * + * The thin remaining IO — `copyBundledBasemap` (reads a bundled asset via + * `MapsPlugin.pluginContext`) and `sliceDownload` (range-fetches over the network) — + * is Android/host-bound (no `Environment` / `AssetManager` on a plain JVM) and is + * covered by the android-qa device walk instead, documented at each call site. + */ +class RegionDownloaderCoverageTest { + + private lateinit var tmpDir: File + + @Before + fun setUp() { + tmpDir = File.createTempFile("region-downloader-cov", "").let { + it.delete() + it.mkdirs() + it + } + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + // ----- downloadInto(): the injected-seam orchestration. The refactor that + // extracted this from download() made it testable with a temp dir + + // fakes — no Environment, no plugin asset bundle, no network. ----- + + private val tinyBbox = Bbox(south = 5.0, west = -1.0, north = 6.0, east = 0.0) + + /** copyBasemap fake: writes [n] bytes to dest + reports progress once. */ + private fun fakeBasemap(n: Int): (File, (Long, Long) -> Unit) -> Unit = { dest, op -> + dest.writeBytes(ByteArray(n)) + op(n.toLong(), n.toLong()) + } + + /** sliceTiles fake: writes [n] bytes to target + reports progress once. */ + private fun fakeSlice(n: Int): suspend (File, (Long, Long) -> Unit) -> Unit = { target, op -> + target.writeBytes(ByteArray(n)) + op(n.toLong(), n.toLong()) + } + + @Test + fun `downloadInto writes the region layout plus meta-json and renames atomically`() = runBlocking { + val progress = mutableListOf>() + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, + regionId = "freetown", + displayName = "Freetown", + bbox = tinyBbox, + zoomMin = 6, + zoomMax = 14, + sourceKind = SourceKind.IIAB_LAN, + sourceHost = "box.local", + sourceVersion = "2026-04-01", + nowMillis = 1_700_000_000_000L, + onProgress = { phase, bytes, _ -> progress += phase to bytes }, + copyBasemap = fakeBasemap(11), + sliceTiles = fakeSlice(22), + ) + + // Renamed to the final dir; the .partial is gone. + assertEquals(File(tmpDir, "freetown"), result) + assertTrue("final dir exists", result.isDirectory) + assertFalse(".partial removed", File(tmpDir, "freetown.partial").exists()) + + // All three artifacts present with the written sizes. + assertEquals(11L, File(result, RegionCache.FILE_BASEMAP_PMTILES).length()) + assertEquals(22L, File(result, RegionCache.FILE_TILES_PMTILES).length()) + val meta = File(result, RegionCache.FILE_META_JSON) + assertTrue("meta.json written", meta.isFile) + + // meta.json is exactly what was passed in (deterministic via nowMillis). + val json = org.json.JSONObject(meta.readText()) + assertEquals(RegionDownloader.META_SCHEMA_VERSION, json.getInt("version")) + assertEquals("freetown", json.getString("regionId")) + assertEquals("Freetown", json.getString("displayName")) + assertEquals(6, json.getInt("zoomMin")) + assertEquals(14, json.getInt("zoomMax")) + assertEquals(33L, json.getLong("sizeBytes")) // 11 + 22 + assertEquals(1_700_000_000_000L, json.getLong("downloadedAt")) + assertEquals(1_700_000_000_000L, json.getLong("lastUsedAt")) + assertEquals(2, json.getJSONArray("layers").length()) // [vector, basemap] + val source = json.getJSONObject("source") + assertEquals(SourceKind.IIAB_LAN.wireValue, source.getString("kind")) + assertEquals("box.local", source.getString("host")) + assertEquals("2026-04-01", source.getString("version")) + + // Progress fired BASEMAP first, then TILES. + assertEquals(RegionDownloader.Phase.BASEMAP, progress.first().first) + assertEquals(RegionDownloader.Phase.TILES, progress.last().first) + } + + @Test + fun `downloadInto omits source host and version when null`() = runBlocking { + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "a", displayName = "A", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(1), sliceTiles = fakeSlice(1), + ) + val source = org.json.JSONObject(File(result, RegionCache.FILE_META_JSON).readText()) + .getJSONObject("source") + assertFalse("host omitted when null", source.has("host")) + assertFalse("version omitted when null", source.has("version")) + } + + @Test + fun `downloadInto rejects a malformed regionId before any IO`() = runBlocking { + try { + RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "Bad Region ID", displayName = "X", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = { _, _ -> fail("basemap must not run for an invalid id") }, + sliceTiles = { _, _ -> fail("slice must not run for an invalid id") }, + ) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue("message names the offending id", e.message?.contains("Bad Region ID") == true) + } + assertFalse(File(tmpDir, "Bad Region ID.partial").exists()) + } + + @Test + fun `downloadInto cleans up the partial dir when slicing fails`() = runBlocking { + try { + RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "kakuma", displayName = "Kakuma", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(8), + sliceTiles = { _, _ -> throw IllegalStateException("slice exploded") }, + ) + fail("expected the slice failure to propagate") + } catch (e: IllegalStateException) { + assertEquals("slice exploded", e.message) + } + // The whole partial dir is removed; no final dir was created. + assertFalse("partial cleaned up", File(tmpDir, "kakuma.partial").exists()) + assertFalse("no final dir on failure", File(tmpDir, "kakuma").exists()) + } + + @Test + fun `downloadInto replaces a pre-existing final dir`() = runBlocking { + File(tmpDir, "goma").apply { mkdirs() }.let { File(it, "stale.txt").writeText("old") } + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "goma", displayName = "Goma", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(3), sliceTiles = fakeSlice(3), + ) + assertFalse("stale file gone after replace", File(result, "stale.txt").exists()) + assertTrue(File(result, RegionCache.FILE_META_JSON).isFile) + } + + // ----- buildTilesUrl / base: the pure URL-builder branches ----- + + private val tilesFile = "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + + @Test + fun `buildTilesUrl uses the internet base for non-LAN sources`() { + val url = RegionDownloader.buildTilesUrl(SourceKind.INTERNET, sourceHost = null) + assertEquals("https://iiab.switnet.org/maps/2/$tilesFile", url) + // UNKNOWN also routes to the internet base (the else arm). + assertEquals(url, RegionDownloader.buildTilesUrl(SourceKind.UNKNOWN, "ignored.host")) + } + + @Test + fun `buildTilesUrl prefixes a bare LAN host with http and the maps path`() { + assertEquals( + "http://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "box.local"), + ) + } + + @Test + fun `buildTilesUrl keeps an explicit scheme and trims a trailing slash on the LAN host`() { + assertEquals( + "http://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "http://box.local/"), + ) + assertEquals( + "https://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "https://box.local"), + ) + } + + @Test + fun `a download reads the archive named in IIAB's catalog, not the fallback date`() = runBlocking { + // Dated after the fallback, so a download that ignored the catalog fails here. + val catalog = """{"data":{"vector":{"14":"https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2027-04-01.z00-z14.pmtiles"}}}""" + val resolver = IiabArchiveResolver { catalog } + assertEquals( + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2027-04-01.z00-z14.pmtiles", + RegionDownloader.resolveTilesUrl(SourceKind.INTERNET, null, resolver), + ) + // The estimate path reads the same resolved archive without I/O. + assertEquals( + RegionDownloader.resolveTilesUrl(SourceKind.INTERNET, null, resolver), + RegionDownloader.buildTilesUrl(SourceKind.INTERNET, null, resolver), + ) + } + + @Test + fun `buildTilesUrl requires a non-blank host for a LAN source`() { + try { + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, sourceHost = " ") + fail("expected IllegalArgumentException for a blank LAN host") + } catch (e: IllegalArgumentException) { + assertTrue(e.message?.contains("non-blank host") == true) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderDownloadTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderDownloadTest.kt new file mode 100644 index 00000000..e709f155 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderDownloadTest.kt @@ -0,0 +1,120 @@ +package org.appdevforall.maps.data + +import android.content.ContextWrapper +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.slicer.HttpRangeByteCache +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Covers the parts of [RegionDownloader] around [RegionDownloader.downloadInto] that the seam + * tests do not: the public `download` entry point on a JVM with no external storage, a failed + * final rename, and seams that genuinely suspend. + */ +class RegionDownloaderDownloadTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val bbox = Bbox(south = 5.0, west = -1.0, north = 6.0, east = 0.0) + + @After + fun tearDown() { + HttpRangeByteCache.clear() + } + + @Test + fun `download drops cached ranges first, then fails fast without external storage`() { + HttpRangeByteCache.put("http://box.local/maps/x.pmtiles", 0L, 4, byteArrayOf(1, 2, 3, 4)) + + val thrown = runCatching { + runBlocking { + RegionDownloader.download( + context = ContextWrapper(null), + regionId = "dhaka", + displayName = "Dhaka", + bbox = bbox, + sourceKind = SourceKind.IIAB_LAN, + sourceHost = "box.local", + ) + } + }.exceptionOrNull() + + // Environment has no external storage on a plain JVM, so the cache root cannot resolve. + assertTrue("got $thrown", thrown is IllegalStateException) + assertEquals("External storage unavailable", thrown!!.message) + assertNull( + "a download must not reuse ranges cached by the estimate loop", + HttpRangeByteCache.get("http://box.local/maps/x.pmtiles", 0L, 4), + ) + } + + @Test + fun `a failed rename to the final dir throws and leaves no final dir`() { + val root = tmp.newFolder("cache") + val thrown = try { + runCatching { + runBlocking { + RegionDownloader.downloadInto( + cacheRoot = root, regionId = "dhaka", displayName = "Dhaka", bbox = bbox, + zoomMin = 6, zoomMax = 10, sourceKind = SourceKind.INTERNET, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = { dest, _ -> dest.writeBytes(ByteArray(3)) }, + sliceTiles = { target, _ -> + target.writeBytes(ByteArray(5)) + // A read-only cache root lets meta.json land in the partial dir but + // blocks the rename out of it. + root.setWritable(false) + }, + ) + } + }.exceptionOrNull() + } finally { + root.setWritable(true) + } + + assertTrue("got $thrown", thrown is IllegalStateException) + assertTrue(thrown!!.message!!.startsWith("Couldn't rename")) + assertFalse(File(root, "dhaka").exists()) + } + + @Test + fun `seams that suspend still complete the download when called from the IO dispatcher`() { + val root = tmp.newFolder("cache") + val phases = mutableListOf() + + val dir = runBlocking(Dispatchers.IO) { + RegionDownloader.downloadInto( + cacheRoot = root, regionId = "dhaka", displayName = "Dhaka", bbox = bbox, + zoomMin = 6, zoomMax = 10, sourceKind = SourceKind.IIAB_LAN, + sourceHost = "box.local", sourceVersion = "2026-10-01", nowMillis = 42L, + onProgress = { phase, _, _ -> phases += phase }, + copyBasemap = { dest, op -> dest.writeBytes(ByteArray(3)); op(3, 3) }, + sliceTiles = { target, op -> + yield() + target.writeBytes(ByteArray(5)) + op(5, 5) + }, + ) + } + + assertEquals(File(root, "dhaka"), dir) + assertEquals(listOf(RegionDownloader.Phase.BASEMAP, RegionDownloader.Phase.TILES), phases) + val meta = JSONObject(File(dir, RegionCache.FILE_META_JSON).readText()) + assertEquals(8L, meta.getLong("sizeBytes")) + assertEquals("box.local", meta.getJSONObject("source").getString("host")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt new file mode 100644 index 00000000..39016878 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt @@ -0,0 +1,180 @@ +package org.appdevforall.maps.data + +import android.content.ContextWrapper +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ResourceManager +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.slicer.HttpRangeByteCache +import org.appdevforall.maps.testing.UnusedPluginContext +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Drives the public [RegionDownloader.download] end to end: a LAN server serves a real PMTiles + * archive by byte range, and the basemap comes from a fake plugin asset bundle. + */ +class RegionDownloaderEndToEndTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val archive = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles").readBytes() + private val world = Bbox(south = -85.0, west = -180.0, north = 85.0, east = 180.0) + private val server = MockWebServer() + + /** Serves [archive] at the LAN path, honouring `Range: bytes=a-b` with a 206. */ + private val rangeDispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path != "/maps/${IiabCatalog.FALLBACK_VECTOR_ARCHIVE}") { + return MockResponse().setResponseCode(404) + } + val (start, end) = request.getHeader("Range")!!.removePrefix("bytes=").split('-') + .map { it.toLong() } + val last = minOf(end, archive.size - 1L) + val slice = archive.copyOfRange(start.toInt(), last.toInt() + 1) + return MockResponse().setResponseCode(206).setBody(Buffer().write(slice)) + } + } + + private class AssetBundle(private val basemap: ByteArray?) : ResourceManager { + override fun openPluginAsset(path: String): InputStream? = + if (path == "maps/natural-earth-z0-z4.pmtiles") basemap?.inputStream() else null + override fun getPluginDirectory(): File = throw UnsupportedOperationException() + override fun getPluginFile(path: String): File = throw UnsupportedOperationException() + override fun getPluginResource(path: String): ByteArray = throw UnsupportedOperationException() + override fun openPluginResource(path: String): InputStream = throw UnsupportedOperationException() + } + + private object SilentLogger : PluginLogger { + override val pluginId: String = MapsPlugin.PLUGIN_ID + override fun debug(message: String) = Unit + override fun debug(message: String, throwable: Throwable) = Unit + override fun info(message: String) = Unit + override fun info(message: String, throwable: Throwable) = Unit + override fun warn(message: String) = Unit + override fun warn(message: String, throwable: Throwable) = Unit + override fun error(message: String) = Unit + override fun error(message: String, throwable: Throwable) = Unit + } + + private fun installAssets(basemap: ByteArray?) { + MapsPlugin.pluginContext = object : PluginContext by UnusedPluginContext { + override val resources: ResourceManager = AssetBundle(basemap) + override val logger: PluginLogger = SilentLogger + } + } + + @Before + fun setUp() { + server.dispatcher = rangeDispatcher + server.start() + HttpRangeByteCache.clear() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + MapsPlugin.pluginContext = null + } + + private fun download(root: File, zoomMax: Int, sourceVersion: String? = null, zoomMin: Int = 0) = + runBlocking { + RegionDownloader.download( + context = ContextWrapper(null), + regionId = "world", + displayName = "World", + bbox = world, + zoomMin = zoomMin, + zoomMax = zoomMax, + sourceKind = SourceKind.IIAB_LAN, + sourceHost = server.url("/").toString(), + sourceVersion = sourceVersion, + cacheRoot = { root }, + ) + } + + @Test + fun `download slices the region from the server and copies the bundled basemap`() { + installAssets(archive) + val root = tmp.newFolder("cache") + + val dir = download(root, zoomMax = 1) + + assertEquals(File(root, "world"), dir) + assertArrayEquals(archive, File(dir, RegionCache.FILE_BASEMAP_PMTILES).readBytes()) + val tiles = File(dir, RegionCache.FILE_TILES_PMTILES).readBytes() + assertEquals("PMTiles", String(tiles, 0, 7, Charsets.US_ASCII)) + // With no explicit version, the archive date comes from the file name. + val source = JSONObject(File(dir, RegionCache.FILE_META_JSON).readText()).getJSONObject("source") + assertEquals("2026-10-01", source.getString("version")) + assertTrue("the server must have been asked for ranges", server.requestCount > 0) + } + + @Test + fun `a zoom range the archive does not hold fails and leaves no region behind`() { + installAssets(archive) + val root = tmp.newFolder("cache") + + val thrown = runCatching { download(root, zoomMin = 10, zoomMax = 12) }.exceptionOrNull() + + assertTrue("got $thrown", thrown is RegionDownloader.NoTilesInRegionException) + assertTrue(thrown!!.message!!.startsWith("No tiles intersect bbox")) + assertFalse(File(root, "world").exists()) + assertFalse(File(root, "world.partial").exists()) + } + + @Test + fun `no plugin context is a packaging error, not a network fallback`() { + val root = tmp.newFolder("cache") + + val thrown = runCatching { download(root, zoomMax = 1, sourceVersion = "2026-10-01") } + .exceptionOrNull() + + assertTrue("got $thrown", thrown is IOException) + assertEquals( + "bundled basemap asset 'maps/natural-earth-z0-z4.pmtiles' unavailable", + thrown!!.message, + ) + // Only LAN archive discovery (the listing, then the probe) may run first, like the internet + // path's catalog fetch. + val requests = List(server.requestCount) { server.takeRequest() } + assertEquals( + "no tile data may be read before the basemap", + listOf("/maps/data/" to null, "/maps/${IiabCatalog.FALLBACK_VECTOR_ARCHIVE}" to "bytes=0-0"), + requests.map { it.path to it.getHeader("Range") }, + ) + } + + @Test + fun `a bundle without the basemap asset is the same packaging error`() { + installAssets(basemap = null) + val root = tmp.newFolder("cache") + + val thrown = runCatching { download(root, zoomMax = 1) }.exceptionOrNull() + + assertTrue("got $thrown", thrown is IOException) + assertTrue(thrown!!.message!!.endsWith("unavailable")) + assertFalse(File(root, "world.partial").exists()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt new file mode 100644 index 00000000..2adec300 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt @@ -0,0 +1,186 @@ +package org.appdevforall.maps.data + +import java.io.IOException +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.appdevforall.maps.domain.SourceKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** [RegionDownloader.resolveTilesUrl] picks the newest archive name a LAN box actually serves. */ +class RegionDownloaderLanArchiveTest { + + private val newArchive = "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + private val julyArchive = "openstreetmap-openmaptiles.2026-07-01.z00-z14.pmtiles" + private val oldArchive = "openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" + private val server = MockWebServer() + + /** + * Answers [code] (a one-byte range by default) for each name in [held], [listing] at + * `/maps/data/`, and 404 for anything else. + */ + private fun serve( + vararg held: String, + code: Int = 206, + listing: MockResponse = MockResponse().setResponseCode(404), + ) { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path == "/maps/data/" -> listing + held.any { request.path == "/maps/$it" } -> + MockResponse().setResponseCode(code).setBody("P") + else -> MockResponse().setResponseCode(404) + } + } + server.start() + } + + /** An nginx JSON autoindex entry, in the shape `autoindex_format json` emits. */ + private fun entry(name: String, type: String = "file") = + """{"name":"$name","type":"$type","mtime":"Wed, 01 Oct 2026 00:00:00 GMT","size":1024}""" + + private fun listing(vararg entries: String) = + MockResponse().setResponseCode(200).setBody(entries.joinToString(",", "[", "]")) + + private fun requestedPaths(): List = List(server.requestCount) { server.takeRequest().path } + + private val host get() = server.url("/").toString() + + private fun resolve(resolver: IiabArchiveResolver = IiabArchiveResolver { null }) = + runBlocking { RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, host, resolver) } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `a box with the current build gets the current archive and the catalog is never read`() { + serve(newArchive, oldArchive) + var catalogRead = false + val url = resolve(IiabArchiveResolver { catalogRead = true; null }) + assertEquals(server.url("/maps/$newArchive").toString(), url) + assertFalse(catalogRead) + } + + @Test + fun `a box installed before the 2026-10 update gets its 2026-04 archive`() { + serve(oldArchive) + assertEquals(server.url("/maps/$oldArchive").toString(), resolve()) + // The size estimate builds its URL without I/O and must read the same file. + assertEquals( + server.url("/maps/$oldArchive").toString(), + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, host), + ) + } + + @Test + fun `a box with neither archive fails with every name it was asked for`() { + serve() + try { + resolve() + fail("expected IOException for a box with no known archive") + } catch (e: IOException) { + val message = e.message.orEmpty() + assertTrue(message, message.contains("no zoom 0-14 OpenStreetMap archive")) + assertTrue(message, message.contains("internet source")) + assertTrue(message, message.contains("/maps/data/ listing: 404")) + assertTrue(message, message.contains("$newArchive: 404")) + assertTrue(message, message.contains("$oldArchive: 404")) + } + } + + @Test + fun `a box that ignores the Range header and sends the whole file still counts as holding it`() { + serve(newArchive, code = 200) + assertEquals(server.url("/maps/$newArchive").toString(), resolve()) + assertEquals("/maps/data/", server.takeRequest().path) + assertEquals("bytes=0-0", server.takeRequest().getHeader("Range")) + } + + @Test + fun `a host that refuses connections fails naming each archive as unanswered`() { + serve() + val deadHost = host + server.shutdown() + try { + runBlocking { RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, deadHost, IiabArchiveResolver { null }) } + fail("expected IOException for a host that answers no probe") + } catch (e: IOException) { + val message = e.message.orEmpty() + assertTrue(message, message.contains("$newArchive: no answer")) + assertTrue(message, message.contains("$oldArchive: no answer")) + } + } + + @Test + fun `a listing with two archives picks the newer one without probing`() { + serve(newArchive, julyArchive, listing = listing(entry(julyArchive), entry(newArchive))) + assertEquals(server.url("/maps/$newArchive").toString(), resolve()) + assertEquals(listOf("/maps/data/"), requestedPaths()) + } + + @Test + fun `a retired archive renamed to pmtiles-old is ignored`() { + serve(listing = listing(entry(julyArchive), entry("$newArchive.old"))) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + } + + @Test + fun `directory entries are ignored`() { + serve(listing = listing(entry(newArchive, type = "directory"), entry(oldArchive))) + assertEquals(server.url("/maps/$oldArchive").toString(), resolve()) + } + + @Test + fun `a missing listing falls back to probing newest first`() { + serve(julyArchive) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + assertEquals( + listOf( + "/maps/data/", + "/maps/$newArchive", + "/maps/openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", + "/maps/$julyArchive", + ), + requestedPaths(), + ) + } + + @Test + fun `a listing that is not JSON falls back to probing`() { + serve(oldArchive, listing = MockResponse().setResponseCode(200).setBody("Index of /maps/")) + assertEquals(server.url("/maps/$oldArchive").toString(), resolve()) + } + + @Test + fun `a listing with no z00-z14 archive and no probe hit says to use the internet source`() { + serve(listing = listing(entry("osm-zoom-0-8.pmtiles"), entry("$newArchive.old"))) + try { + resolve() + fail("expected IOException for a box with only a zoom 0-8 archive") + } catch (e: IOException) { + val message = e.message.orEmpty() + assertTrue(message, message.contains("no zoom 0-14 OpenStreetMap archive")) + assertTrue(message, message.contains("IIAB installs zoom 0-8 by default")) + assertTrue(message, message.contains("internet source")) + assertTrue(message, message.contains("/maps/data/ listing: no z00-z14 archive")) + assertTrue(message, message.contains("$oldArchive: 404")) + } + } + + @Test + fun `an oversized listing is not read and falls back to probing`() { + val padded = "[" + entry(newArchive) + " ".repeat(1024 * 1024) + "]" + serve(julyArchive, listing = MockResponse().setResponseCode(200).setBody(padded)) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt new file mode 100644 index 00000000..e5c642d6 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt @@ -0,0 +1,33 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Trivial enum coverage for [RegionDownloader.Phase] — `values()` and `valueOf` + * generated members. The wizard progress UI switches on these constants, so the + * set is part of the public contract. + */ +class RegionDownloaderPhaseTest { + + @Test + fun enumHasExactlyTheTwoExpectedPhases() { + val phases = RegionDownloader.Phase.values() + assertEquals(2, phases.size) + assertEquals(RegionDownloader.Phase.BASEMAP, phases[0]) + assertEquals(RegionDownloader.Phase.TILES, phases[1]) + } + + @Test + fun valueOfRoundTripsEachName() { + assertSame( + RegionDownloader.Phase.BASEMAP, + RegionDownloader.Phase.valueOf("BASEMAP"), + ) + assertSame( + RegionDownloader.Phase.TILES, + RegionDownloader.Phase.valueOf("TILES"), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt new file mode 100644 index 00000000..43af268e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt @@ -0,0 +1,94 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.SourceKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * URL-builder smoke tests for [RegionDownloader]. + * + * The class is `internal` so we reach into it via reflection on the + * package-private `base` function rather than re-exposing it for tests + * alone. Tests cover: + * - INTERNET source → iiab.switnet.org/maps/2/ regardless of host + * - IIAB_LAN with bare hostname → http:///maps/ + * - IIAB_LAN with scheme'd URL → preserves scheme + * - IIAB_LAN with blank host → throws (Phase 2 guard) + */ +class RegionDownloaderUrlTest { + + private fun invokeBase(sourceKind: SourceKind, sourceHost: String?): String { + val method = RegionDownloader::class.java.getDeclaredMethod( + "base", SourceKind::class.java, String::class.java, + ).apply { isAccessible = true } + return method.invoke(RegionDownloader, sourceKind, sourceHost) as String + } + + @Test + fun `internet source ignores host`() { + assertEquals( + "https://iiab.switnet.org/maps/2/", + invokeBase(SourceKind.INTERNET, null), + ) + assertEquals( + "https://iiab.switnet.org/maps/2/", + invokeBase(SourceKind.INTERNET, "ignored.example.com"), + ) + } + + @Test + fun `lan bare hostname gets http scheme + maps suffix`() { + assertEquals( + "http://box.adfa.lan/maps/", + invokeBase(SourceKind.IIAB_LAN, "box.adfa.lan"), + ) + } + + @Test + fun `lan host with scheme preserves scheme`() { + assertEquals( + "https://secure.box.local/maps/", + invokeBase(SourceKind.IIAB_LAN, "https://secure.box.local"), + ) + } + + @Test + fun `lan host with trailing slashes is normalised`() { + assertEquals( + "http://box.adfa.lan/maps/", + invokeBase(SourceKind.IIAB_LAN, "http://box.adfa.lan///"), + ) + } + + @Test + fun `lan with blank host rejects`() { + try { + invokeBase(SourceKind.IIAB_LAN, null) + fail("Expected IllegalArgumentException for null host") + } catch (e: java.lang.reflect.InvocationTargetException) { + assertTrue( + "Expected IllegalArgumentException, got ${e.targetException.javaClass}", + e.targetException is IllegalArgumentException, + ) + } + try { + invokeBase(SourceKind.IIAB_LAN, " ") + fail("Expected IllegalArgumentException for blank host") + } catch (e: java.lang.reflect.InvocationTargetException) { + assertTrue( + "Expected IllegalArgumentException, got ${e.targetException.javaClass}", + e.targetException is IllegalArgumentException, + ) + } + } + + @Test + fun `source kind wire values match meta-json contract`() { + // Catches accidental rename — the meta.json schema depends on these. + assertEquals("iiab-lan", SourceKind.IIAB_LAN.wireValue) + assertEquals("internet", SourceKind.INTERNET.wireValue) + assertEquals("unknown", SourceKind.UNKNOWN.wireValue) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt new file mode 100644 index 00000000..ab3132ae --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt @@ -0,0 +1,78 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.RegionId +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RegionCache.isValidRegionId] — the security-critical allowlist + * that gates a regionId becoming a cache-directory name. Covers the Unicode + * (non-Latin) support added 2026-06-04 alongside the path-traversal, casing and + * length guards that must keep holding. + * + * Control/format chars are built from code points (not embedded raw) so this + * source stays free of null bytes and bidi-control "Trojan Source" sequences; the + * script literals are ordinary UTF-8 letters and safe to embed verbatim. + */ +class RegionIdValidationTest { + + @Test + fun accepts_ascii_kebab() { + assertTrue(RegionCache.isValidRegionId("san-francisco")) + assertTrue(RegionCache.isValidRegionId("region1")) + assertTrue(RegionCache.isValidRegionId("a")) + } + + @Test + fun accepts_non_latin_scripts() { + // The point of the change: a user names a region in their own script. + assertTrue("CJK", RegionCache.isValidRegionId("北京")) + assertTrue("Arabic", RegionCache.isValidRegionId("القاهرة")) + assertTrue("Cyrillic", RegionCache.isValidRegionId("москва")) + assertTrue("accented Latin", RegionCache.isValidRegionId("córdoba")) + assertTrue("Devanagari", RegionCache.isValidRegionId("मुंबई")) + assertTrue("script + ascii + hyphen", RegionCache.isValidRegionId("tokyo-東京")) + } + + @Test + fun rejects_uppercase() { + // Lowercase-only keeps case-variants from spawning colliding directories. + assertFalse(RegionCache.isValidRegionId("Café")) + assertFalse(RegionCache.isValidRegionId("SF")) + assertFalse(RegionCache.isValidRegionId("Москва")) + } + + @Test + fun rejects_path_traversal_and_separators() { + assertFalse(RegionCache.isValidRegionId("..")) + assertFalse(RegionCache.isValidRegionId("../etc")) + assertFalse(RegionCache.isValidRegionId("a/b")) + assertFalse(RegionCache.isValidRegionId("a\\b")) + assertFalse(RegionCache.isValidRegionId("a.b")) + assertFalse(RegionCache.isValidRegionId("/abs")) + } + + @Test + fun rejects_leading_hyphen_blank_and_whitespace() { + assertFalse(RegionCache.isValidRegionId("-foo")) + assertFalse(RegionCache.isValidRegionId("")) + assertFalse(RegionCache.isValidRegionId("a b")) + assertFalse(RegionCache.isValidRegionId("北京 ")) // trailing space + } + + @Test + fun rejects_control_format_and_separator_codepoints() { + // null, tab, RTL override, zero-width joiner, fullwidth solidus. + for (cp in listOf(0x0000, 0x0009, 0x202E, 0x200D, 0xFF0F)) { + val s = "a" + Char(cp) + "b" + assertFalse("U+%04X must be rejected".format(cp), RegionCache.isValidRegionId(s)) + } + } + + @Test + fun length_is_capped() { + assertTrue(RegionCache.isValidRegionId("a".repeat(RegionId.MAX_LEN))) + assertFalse(RegionCache.isValidRegionId("a".repeat(RegionId.MAX_LEN + 1))) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt new file mode 100644 index 00000000..f5b7b930 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt @@ -0,0 +1,171 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Branch-coverage tests for [RegionInfo]'s hand-written `equals` / `hashCode`. + * + * The data class overrides equality so the `bbox` [DoubleArray] is compared + * structurally (`contentEquals`) rather than by reference. This walks every + * field-mismatch branch, the self / other-type short-circuits, and the four + * bbox null-vs-set permutations. + */ +class RegionInfoCoverageTest { + + private val dir = File("/tmp/region-info-coverage") + + /** A fully-populated canonical instance every test mutates one field of. */ + private fun base(): RegionInfo = RegionInfo( + regionId = "addis-ababa", + displayName = "Addis Ababa", + sizeBytes = 4096L, + downloadedAt = 1735000000000L, + lastUsedAt = 1735100000000L, + source = "internet", + sourceHost = "box.adfa.lan", + layers = listOf("vector", "basemap"), + directory = dir, + bbox = doubleArrayOf(1.0, 2.0, 3.0, 4.0), + ) + + // ----- self / other-type short-circuits ----- + + @Test + fun equalsIsReflexive() { + val a = base() + assertTrue(a == a) + } + + @Test + fun equalsRejectsOtherType() { + assertFalse(base().equals("not-a-region-info")) + assertFalse(base().equals(null)) + } + + @Test + fun equalIdenticalInstances() { + // Two independently-constructed-but-equal instances compare equal and + // share a hashCode (the contract the override exists to satisfy). + val a = base() + val b = base() + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + // ----- one branch per scalar field differing ----- + + @Test + fun differsByRegionId() { + assertNotEquals(base(), base().copy(regionId = "kathmandu")) + } + + @Test + fun differsByDisplayName() { + assertNotEquals(base(), base().copy(displayName = "Other")) + } + + @Test + fun differsBySizeBytes() { + assertNotEquals(base(), base().copy(sizeBytes = 1L)) + } + + @Test + fun differsByDownloadedAt() { + assertNotEquals(base(), base().copy(downloadedAt = 999L)) + // null-vs-set on the same nullable field. + assertNotEquals(base(), base().copy(downloadedAt = null)) + } + + @Test + fun differsByLastUsedAt() { + assertNotEquals(base(), base().copy(lastUsedAt = 999L)) + assertNotEquals(base(), base().copy(lastUsedAt = null)) + } + + @Test + fun differsBySource() { + assertNotEquals(base(), base().copy(source = "iiab-lan")) + } + + @Test + fun differsBySourceHost() { + assertNotEquals(base(), base().copy(sourceHost = "other.lan")) + assertNotEquals(base(), base().copy(sourceHost = null)) + } + + @Test + fun differsByLayers() { + assertNotEquals(base(), base().copy(layers = listOf("vector"))) + assertNotEquals(base(), base().copy(layers = emptyList())) + } + + @Test + fun differsByDirectory() { + assertNotEquals(base(), base().copy(directory = File("/tmp/other-dir"))) + } + + // ----- the four bbox null-vs-set permutations ----- + + @Test + fun bbothBboxNull_areEqual() { + val a = base().copy(bbox = null) + val b = base().copy(bbox = null) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun bboxNull_vs_set_areNotEqual() { + // this.bbox == null, other.bbox != null. + assertNotEquals(base().copy(bbox = null), base()) + } + + @Test + fun bboxSet_vs_null_areNotEqual() { + // this.bbox != null, other.bbox == null. + assertNotEquals(base(), base().copy(bbox = null)) + } + + @Test + fun bothBboxSet_sameContents_areEqual() { + // Different array instances, identical contents → contentEquals true. + val a = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + val b = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun bothBboxSet_differentContents_areNotEqual() { + val a = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + val b = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 9.0)) + assertNotEquals(a, b) + } + + // ----- hashCode null-coalescing branches ----- + + @Test + fun hashCodeHandlesAllNullableFieldsNull() { + // Exercises the `?: 0` fallbacks for downloadedAt/lastUsedAt/sourceHost/bbox. + val sparse = RegionInfo( + regionId = "x", + displayName = "x", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "unknown", + sourceHost = null, + layers = emptyList(), + directory = dir, + bbox = null, + ) + // Stable + equal to an identical sparse instance. + assertEquals(sparse.hashCode(), sparse.copy().hashCode()) + assertEquals(sparse, sparse.copy()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt new file mode 100644 index 00000000..6981943c --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt @@ -0,0 +1,172 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Coverage for [RegionInstaller]. + * + * `apply` takes an injectable `cacheRoot` (null → resolved via + * `RegionCache.rootDir()` *inside* the try). Passing a temp dir lets the whole + * orchestration run on a plain JVM: + * - the success path (valid project + region under the cache root → copied → true), + * - the `!result.success` branch (ProjectMapEmitter rejects → logged → false), + * - and the cache-root-resolution failure (default null → `rootDir()` throws on the + * JVM → caught + routed to `logError` → false), in its injected- and default- + * logger shapes. + * + * The free-space precheck's *insufficient-space* arm needs a constrained volume + * (a temp dir reports the host disk's usable space), so it's left to the android-qa + * device walk; the precheck's compute + proceed path is exercised here. + */ +class RegionInstallerCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun regionInfo(dir: File) = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = dir, + ) + + /** A project dir that satisfies ProjectMapEmitter's "is a Maps project" gate. */ + private fun mapsProject(name: String): File { + val project = tmp.newFolder(name) + // app/ dir + a MapRegionActivity source anywhere under app/src/main. + File(project, "app/src/main/java/com/example/MapRegionActivity.kt").apply { + parentFile.mkdirs() + writeText("class MapRegionActivity") + } + return project + } + + @Test + fun applyCopiesRegionDataIntoTheProjectAndReturnsTrue() = runBlocking { + val cacheRoot = tmp.newFolder("cache-ok") + val regionDir = File(cacheRoot, "test-region").apply { mkdirs() } + File(regionDir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(16)) + File(regionDir, RegionCache.FILE_BASEMAP_PMTILES).writeBytes(ByteArray(8)) + File(regionDir, RegionCache.FILE_META_JSON).writeText("{}") + val project = mapsProject("project-ok") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + cacheRoot = cacheRoot, + ) + + assertTrue("a valid region + project must apply successfully", result) + // The data landed in the project's flat maps assets with the right bytes. + val dest = File(project, "app/src/main/assets/maps") + assertEquals(16L, File(dest, RegionCache.FILE_TILES_PMTILES).length()) + assertEquals(8L, File(dest, RegionCache.FILE_BASEMAP_PMTILES).length()) + assertTrue(File(dest, RegionCache.FILE_META_JSON).exists()) + } + + @Test + fun applyReturnsFalseWhenEmitterRejectsTheRegion() = runBlocking { + // Region is under the cache root but has NO tiles.pmtiles, so + // ProjectMapEmitter returns success=false — exercising RegionInstaller's + // post-rootDir orchestration: the free-space `needed` compute + proceed, + // the ProjectMapEmitter.apply call, and the `!result.success` log + false. + val cacheRoot = tmp.newFolder("cache-notiles") + val regionDir = File(cacheRoot, "test-region").apply { mkdirs() } + File(regionDir, RegionCache.FILE_META_JSON).writeText("{}") // no tiles.pmtiles + val project = mapsProject("project-notiles") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + cacheRoot = cacheRoot, + ) + + assertFalse("a region without tiles must be rejected", result) + // Nothing copied into the project. + assertFalse( + File(project, "app/src/main/assets/maps/${RegionCache.FILE_TILES_PMTILES}").exists(), + ) + } + + @Test + fun applyRoutesFailureToInjectedLogger() = runBlocking { + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("cache", "test-region") + var reportedMessage: String? = null + var reportedThrowable: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { msg, t -> reportedMessage = msg; reportedThrowable = t }, + ) + + assertFalse("must return false on cache-root failure", result) + assertNotNull("logError message must be supplied", reportedMessage) + assertTrue( + "message should name the failing region", + reportedMessage!!.contains("test-region"), + ) + assertNotNull("the caught throwable must be routed to logError", reportedThrowable) + } + + @Test + fun applyUsesDefaultNoOpLoggerWithoutThrowing() = runBlocking { + // Calling apply without a logError lambda exercises the default no-op + // parameter; the catch block must still swallow the exception and + // return false rather than propagating. + val project = tmp.newFolder("project2") + val regionDir = tmp.newFolder("cache2", "test-region") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + ) + + assertFalse("default-logger apply must still return false, not throw", result) + } + + @Test + fun applyRoutesTheExternalStorageGuardSpecifically() = runBlocking { + // Pin the failure MODE, not just "some throwable". On the JVM the very + // first statement in `apply` — `RegionCache.rootDir()` — throws an + // IllegalStateException("External storage unavailable") because + // Environment.getExternalStorageDirectory() returns null + // (isReturnDefaultValues). If a future refactor reordered apply so a + // DIFFERENT exception surfaced first (e.g. an NPE on info.directory), this + // would catch it. The free-space precheck + ProjectMapEmitter copy + + // !result.success branches all sit AFTER this throw and are unreachable on + // a plain JVM (they need a resolvable cache root — Robolectric/Mockk or a + // device); they're covered by ProjectMapEmitterTest + the android-qa walk. + val project = tmp.newFolder("project3") + val regionDir = tmp.newFolder("cache3", "test-region") + var reportedThrowable: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { _, t -> reportedThrowable = t }, + ) + + assertFalse(result) + assertTrue( + "the routed failure must be an IllegalStateException", + reportedThrowable is IllegalStateException, + ) + assertEquals( + "External storage unavailable", + reportedThrowable?.message, + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerFreeSpaceTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerFreeSpaceTest.kt new file mode 100644 index 00000000..cd4ea1e7 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerFreeSpaceTest.kt @@ -0,0 +1,52 @@ +package org.appdevforall.maps.data + +import java.io.File +import java.io.RandomAccessFile +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Covers [RegionInstaller.apply]'s free-space precheck refusing a region larger than the + * project volume can hold, using a sparse tiles file whose length exceeds the free space. + */ +class RegionInstallerFreeSpaceTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `a region larger than the free space is refused before any copy starts`() = runBlocking { + val project = tmp.newFolder("project") + File(project, "app/src/main/java/com/example/MapRegionActivity.kt").apply { + parentFile.mkdirs() + writeText("class MapRegionActivity") + } + val cacheRoot = tmp.newFolder("cache") + val regionDir = File(cacheRoot, "big-region").apply { mkdirs() } + val usable = File(project, "app").usableSpace + assertTrue("the test volume must report its free space", usable > 0) + // Sparse: reports this length without allocating it. + RandomAccessFile(File(regionDir, RegionCache.FILE_TILES_PMTILES), "rw").use { + it.setLength(usable + (1L shl 30)) + } + var chunks = 0 + val info = RegionInfo( + regionId = "big-region", displayName = "Big", sizeBytes = 0L, downloadedAt = null, + lastUsedAt = null, source = "internet", directory = regionDir, + ) + + val ok = RegionInstaller.apply( + info, project, cacheRoot = cacheRoot, + // Aborts the copy at once should the precheck ever let this region through. + onChunk = { chunks++; throw IllegalStateException("copy must not start") }, + ) + + assertFalse(ok) + assertTrue("no chunk may be copied", chunks == 0) + assertFalse(File(project, "app/src/main/assets/maps/${RegionCache.FILE_TILES_PMTILES}").exists()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt new file mode 100644 index 00000000..afb73948 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt @@ -0,0 +1,53 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [RegionInstaller]. + * + * The happy-path copy reaches `RegionCache.rootDir()`, which calls + * `Environment.getExternalStorageDirectory()` — unavailable in plain JVM unit + * tests. So this test exercises the failure contract: when the cache root can't be + * resolved, [RegionInstaller.apply] must fail gracefully (return false) and route + * the exception to the injected logger rather than throwing. Flat-copy + the + * require-a-Maps-project gate are covered by + * [org.appdevforall.maps.templates.ProjectMapEmitterTest]; full apply coverage + * lives in the android-qa device walk. + */ +class RegionInstallerTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun regionInfo(dir: File) = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = dir, + ) + + @Test + fun `apply fails gracefully and reports the error when the cache root is unavailable`() = runBlocking { + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("cache", "test-region") + var reported: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { _, t -> reported = t }, + ) + + assertFalse("must not throw; must return false when cache root is unavailable", result) + assertTrue("the caught exception should be routed to logError", reported != null) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt new file mode 100644 index 00000000..d2caf15a --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt @@ -0,0 +1,152 @@ +package org.appdevforall.maps.data + +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.data.RegionListActions.ToggleResult +import org.appdevforall.maps.domain.RegionWizardStateMachine.Event +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** [RegionListActions] against a real `active.txt` in a temp project and an in-memory cache. */ +class RegionListActionsTest { + + private lateinit var projectDir: File + private lateinit var project: FakeProjectRegions + private val alpha = regionInfo("alpha", "Alpha Town") + private val beta = regionInfo("beta", "Beta City") + private var cached = listOf(alpha, beta) + private var listFails = false + private var deleteResult = true + private val deleted = mutableListOf() + + private val actions by lazy { + RegionListActions( + project = project, + listRegions = { if (listFails) error("cache unreadable") else cached }, + deleteRegion = { + deleted += it.regionId + deleteResult + }, + ) + } + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-rla").toFile() + project = FakeProjectRegions(projectDir) + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + } + + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + + private fun setActive(id: String) = runBlocking { project.writeActiveRegionId(projectDir, id) } + + @Test + fun `toggle with no open project reports it and touches nothing`() = runBlocking { + project.projectDir = null + assertEquals(ToggleResult.NoProject, actions.toggleActive(alpha, true)) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `toggle on with no prior active region activates it`() = runBlocking { + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + assertEquals(listOf("alpha"), project.applied) + } + + @Test + fun `toggle on over another active region names both`() = runBlocking { + setActive("beta") + assertEquals(ToggleResult.Switched("Beta City", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `a prior region missing from the cache is named by its id`() = runBlocking { + setActive("gone") + assertEquals(ToggleResult.Switched("gone", "Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `an unreadable cache still switches, naming the prior region by its id`() = runBlocking { + setActive("beta") + listFails = true + assertEquals(ToggleResult.Switched("beta", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `toggling on the region that is already active is an activation, not a switch`() = + runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `a failed copy reports it and leaves the prior active region in place`() = runBlocking { + setActive("beta") + project.applyResult = false + assertEquals(ToggleResult.ApplyFailed, actions.toggleActive(alpha, true)) + assertEquals("beta", active()) + } + + @Test + fun `toggle off clears the active region without copying`() = runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Deactivated("Alpha Town"), actions.toggleActive(alpha, false)) + assertNull(active()) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `deleting the active region clears active_txt`() = runBlocking { + setActive("alpha") + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + assertNull(active()) + } + + @Test + fun `deleting another region keeps the active one`() = runBlocking { + setActive("beta") + assertTrue(actions.delete(alpha)) + assertEquals("beta", active()) + } + + @Test + fun `a failed delete reports false and keeps active_txt`() = runBlocking { + setActive("alpha") + deleteResult = false + assertFalse(actions.delete(alpha)) + assertEquals("alpha", active()) + } + + @Test + fun `deleting with no open project still deletes`() = runBlocking { + project.projectDir = null + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + } + + @Test + fun `redownload re-opens the wizard on the region's id, name and bounds`() { + val bounds = doubleArrayOf(1.0, 2.0, 3.0, 4.0) + val event = actions.redownloadRequest(regionInfo("gamma", "Gamma", bounds)) + event as Event.RedownloadRequested + assertEquals("gamma", event.regionId) + assertEquals("Gamma", event.displayName) + assertArrayEquals(bounds, event.bbox, 0.0) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorTest.kt new file mode 100644 index 00000000..cb513674 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorTest.kt @@ -0,0 +1,141 @@ +package org.appdevforall.maps.data + +import java.util.Collections +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.slicer.SliceEstimateCache +import org.appdevforall.maps.slicer.TileEntry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Pins [RegionSizeEstimator]'s orchestration contract — the debounce/timeout/cache + * plumbing is timing code that a green build says nothing about: + * + * 1. A cache hit reports [RegionSizeEstimator.State.Done] synchronously, with no + * Calculating flicker and no network. + * 2. A cache hit CANCELS a prior in-flight walk, so the superseded request's + * eventual failure can never arrive after the fresh Done and flip the UI + * into a bogus error state (the stale-Failed trap). + * 3. A failure for the CURRENT request still reports Failed — the staleness + * guard must not swallow legitimate errors. + * + * Uses a real scope + MockWebServer (no coroutines-test dependency in this + * module); waits are bounded polls on the recorded state list, not bare sleeps. + */ +internal class RegionSizeEstimatorTest { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val states = Collections.synchronizedList(mutableListOf()) + private lateinit var server: MockWebServer + + // Short-timeout client so a hung/failed slicer walk resolves within test time. + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .build() + + private val bboxA = Bbox(south = 5.0, west = -5.0, north = 6.0, east = -4.0) + private val bboxB = Bbox(south = 40.0, west = 10.0, north = 41.0, east = 11.0) + private val cachedTiles = listOf( + TileEntry(z = 6, x = 31, y = 30, tileId = 1L, byteOffset = 0L, byteLength = 128L), + ) + + @Before + fun setUp() { + SliceEstimateCache.clear() + server = MockWebServer() + } + + @After + fun tearDown() { + scope.cancel() + server.shutdown() + SliceEstimateCache.clear() + } + + /** Poll (bounded) until [predicate] holds on the state list, else fail-fast at timeout. */ + private fun awaitStates(timeoutMs: Long = 5_000, predicate: (List) -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (predicate(states.toList())) return + Thread.sleep(50) + } + assertTrue("timed out waiting; states=$states", predicate(states.toList())) + } + + @Test + fun `cache hit reports Done synchronously without Calculating`() { + server.start() + val url = server.url("/tiles.pmtiles").toString() + SliceEstimateCache.put(url, bboxA, 6, 10, cachedTiles) + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(url, bboxA, 6, 10) + + // Synchronous contract: Done is already there when estimate() returns. + assertEquals(listOf(RegionSizeEstimator.State.Done(cachedTiles)), states.toList()) + assertEquals(0, server.requestCount) // no network on a cache hit + } + + @Test + fun `cache hit cancels stale in-flight walk so its failure never lands after Done`() { + // Request #1's walk hangs on a server that never responds, then dies with + // a client timeout — WITHOUT the cancel-on-cache-hit + latest-guard it + // would report Failed ~2s after request #2's Done. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + server.start() + val hangingUrl = server.url("/world.pmtiles").toString() + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(hangingUrl, bboxA, 6, 10) + awaitStates { it.contains(RegionSizeEstimator.State.Calculating) } + // Let the debounce elapse so the walk is genuinely in flight on the socket. + Thread.sleep(500) + + // Request #2 hits the cache → Done, and must cancel request #1's walk. + val cachedUrl = "http://cache.invalid/other.pmtiles" + SliceEstimateCache.put(cachedUrl, bboxB, 6, 10, cachedTiles) + estimator.estimate(cachedUrl, bboxB, 6, 10) + awaitStates { it.lastOrNull() == RegionSizeEstimator.State.Done(cachedTiles) } + + // Give the stale walk's failure window time to fire if it were going to + // (client timeouts are 2 s; wait past them), then assert it never did. + Thread.sleep(3_000) + val trailing = states.toList().dropWhile { it != RegionSizeEstimator.State.Done(cachedTiles) } + assertEquals( + "no state may arrive after the fresh Done; states=$states", + listOf(RegionSizeEstimator.State.Done(cachedTiles)), + trailing, + ) + } + + @Test + fun `failure for the current request still reports Failed`() { + // Server answers 500 immediately — the walk for the CURRENT request + // fails, and the staleness guard must let that Failed through. + server.enqueue(MockResponse().setResponseCode(500)) + server.start() + val url = server.url("/broken.pmtiles").toString() + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(url, bboxA, 6, 10) + + awaitStates { it.lastOrNull() == RegionSizeEstimator.State.Failed } + assertEquals( + listOf(RegionSizeEstimator.State.Calculating, RegionSizeEstimator.State.Failed), + states.toList(), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorWalkTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorWalkTest.kt new file mode 100644 index 00000000..c48d2983 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorWalkTest.kt @@ -0,0 +1,126 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import java.io.File +import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okhttp3.OkHttpClient +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.slicer.SliceEstimateCache +import org.appdevforall.maps.testing.UnusedPluginContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Covers [RegionSizeEstimator]'s completed walk against a real PMTiles archive (the bundled + * Natural Earth file, read over `file://`) and the failure report to the plugin logger. + */ +internal class RegionSizeEstimatorWalkTest { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val states = Collections.synchronizedList(mutableListOf()) + private val archiveUrl = "file://" + File("src/main/assets/maps/natural-earth-z0-z4.pmtiles").absolutePath + private val world = Bbox(south = -85.0, west = -180.0, north = 85.0, east = 180.0) + + private class RecordingLogger : PluginLogger { + val warnings: MutableList = Collections.synchronizedList(mutableListOf()) + override val pluginId: String = MapsPlugin.PLUGIN_ID + override fun debug(message: String) = Unit + override fun debug(message: String, throwable: Throwable) = Unit + override fun info(message: String) = Unit + override fun info(message: String, throwable: Throwable) = Unit + override fun warn(message: String) { warnings += message } + override fun warn(message: String, throwable: Throwable) { warnings += message } + override fun error(message: String) = Unit + override fun error(message: String, throwable: Throwable) = Unit + } + + @Before + fun setUp() { + SliceEstimateCache.clear() + } + + @After + fun tearDown() { + scope.cancel() + SliceEstimateCache.clear() + MapsPlugin.pluginContext = null + } + + private fun awaitStates(predicate: (List) -> Boolean) { + val deadline = System.currentTimeMillis() + 10_000 + while (System.currentTimeMillis() < deadline) { + if (predicate(states.toList())) return + Thread.sleep(25) + } + assertTrue("timed out waiting; states=$states", predicate(states.toList())) + } + + @Test + fun `a completed walk reports Done with the tiles and caches them for the next call`() { + val estimator = RegionSizeEstimator(scope, OkHttpClient(), states::add) + + estimator.estimate(archiveUrl, world, 0, 1) + awaitStates { it.lastOrNull() is RegionSizeEstimator.State.Done } + + val done = states.last() as RegionSizeEstimator.State.Done + // z0 has 1 tile and z1 has 4 for the whole world. + assertEquals(5, done.tiles.size) + assertEquals(RegionSizeEstimator.State.Calculating, states.first()) + assertEquals(done.tiles, SliceEstimateCache.get(archiveUrl, world, 0, 1)) + } + + @Test + fun `a failed walk is reported to the plugin logger as well as the UI`() { + val logger = RecordingLogger() + MapsPlugin.pluginContext = object : PluginContext by UnusedPluginContext { + override val logger: PluginLogger = logger + } + val missing = "file://" + File("build/does-not-exist.pmtiles").absolutePath + val estimator = RegionSizeEstimator(scope, OkHttpClient(), states::add) + + estimator.estimate(missing, world, 0, 1) + awaitStates { it.lastOrNull() == RegionSizeEstimator.State.Failed } + + assertEquals(1, logger.warnings.size) + assertTrue(logger.warnings.single().startsWith("Slicer estimate failed (")) + } + + @Test + fun `a newer uncached estimate supersedes the pending one`() { + val estimator = RegionSizeEstimator(scope, OkHttpClient(), states::add) + val europe = Bbox(south = 35.0, west = -10.0, north = 60.0, east = 30.0) + + estimator.estimate(archiveUrl, world, 0, 1) + estimator.estimate(archiveUrl, europe, 0, 0) + awaitStates { it.lastOrNull() is RegionSizeEstimator.State.Done } + Thread.sleep(500) + + // Only the second request may report; the first was cancelled during its debounce. + val done = states.filterIsInstance().single() + assertEquals(1, done.tiles.size) + assertEquals(null, SliceEstimateCache.get(archiveUrl, world, 0, 1)) + } + + @Test + fun `cancel stops a pending estimate and is a no-op before any estimate`() { + val estimator = RegionSizeEstimator(scope, OkHttpClient(), states::add) + estimator.cancel() + assertTrue(states.isEmpty()) + + estimator.estimate(archiveUrl, world, 0, 1) + estimator.cancel() + // Past the 300 ms debounce plus the walk time a live estimate would need. + Thread.sleep(1_000) + + assertEquals(listOf(RegionSizeEstimator.State.Calculating), states.toList()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt new file mode 100644 index 00000000..9a21b8eb --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt @@ -0,0 +1,29 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * [AutoShrinkBbox.computeShrunkBbox]'s defensive collapse arm: a viewport whose + * longitudes both clamp to the same ±180 wall (wrapped/out-of-range longitudes + * from the map SDK) must yield null — never an inverted or zero-width [Bbox] + * (whose init would throw in the caller's face). + */ +class AutoShrinkBboxCollapseTest { + + @Test + fun `viewport beyond the antimeridian wall collapses to null`() { + // Both view longitudes are past -180; after coerceIn both become -180, + // so newE <= newW — the collapse guard must return null. Latitudes are + // healthy, so this isolates the longitude arm of the final check. + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = Bbox(south = 0.0, west = 0.0, north = 10.0, east = 10.0), + viewN = 5.0, + viewS = -5.0, + viewE = -185.0, + viewW = -190.0, + margin = 0.0, + ) + assertNull("collapsed longitude span must yield null, not an invalid Bbox", result) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt new file mode 100644 index 00000000..6b7631d0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt @@ -0,0 +1,84 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Branch-coverage supplement for [AutoShrinkBbox] — covers the decision branches + * the happy-path test (`AutoShrinkBboxTest`) doesn't reach: the negative-margin + * lower clamp, the per-axis "edge outside viewport" triggers, and the defensive + * pole-collapse guard that returns null when latitude clamping degenerates the box. + */ +class AutoShrinkBboxCoverageTest { + + private fun box(s: Double, w: Double, n: Double, e: Double) = + Bbox(south = s, west = w, north = n, east = e) + + @Test + fun `negative margin is clamped up to zero — result equals the full viewport`() { + // margin < 0 clamps to 0.0, so no inset: the shrunk bbox is the viewport itself. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = -0.5, + ) + assertNotNull(result) + assertEquals(-1.0, result!!.south, 1e-9) + assertEquals(1.0, result.north, 1e-9) + assertEquals(-1.0, result.west, 1e-9) + assertEquals(1.0, result.east, 1e-9) + } + + @Test + fun `bbox north edge above viewport triggers shrink`() { + // Only the north edge overflows; the other three are inside. + val bbox = box(s = -0.5, w = -0.5, n = 2.0, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("north-edge overflow must shrink", result) + } + + @Test + fun `bbox south edge below viewport triggers shrink`() { + val bbox = box(s = -2.0, w = -0.5, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("south-edge overflow must shrink", result) + } + + @Test + fun `bbox west edge left of viewport triggers shrink`() { + val bbox = box(s = -0.5, w = -2.0, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("west-edge overflow must shrink", result) + } + + @Test + fun `pole-clamp collapse returns null`() { + // A viewport spanning across the +85 Mercator pole limit with a margin so + // small the inset can't separate the latitude edges after the ±85 clamp: + // newS and newN both clamp to 85.0, so newN <= newS → defensive null. + val bbox = box(s = 80.0, w = -1.0, n = 89.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + // Both viewN and viewS above the +85 clamp ceiling. After the tiny + // inset, newS and newN both coerce to 85.0 → collapse. + viewN = 89.0, viewS = 86.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNull("latitude clamp collapses the box → defensive null", result) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt new file mode 100644 index 00000000..7dc4c4fc --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt @@ -0,0 +1,182 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the auto-shrink math behind the bbox picker's + * "selection tracks viewport" tweak (Bryan, 2026-05-26). + * + * Naming convention: `viewport(N, S, E, W)` reads as the visible map area in + * lat/lon degrees. `bbox(N, S, E, W)` reads as the user's current selection. + * + * Margin is 0.35 throughout (the picker's default — leaves the auto-shrunk + * bbox at ~30% of the viewport so the user has grab-room to pan). + */ +class AutoShrinkBboxTest { + + private val M = 0.35 + + private fun box(s: Double, w: Double, n: Double, e: Double) = Bbox( + south = s, west = w, north = n, east = e, + ) + + // ----- the two cases Bryan explicitly said should NOT trigger ----- + + @Test + fun `zoom-out keeps bbox fully visible — no shrink`() { + // User started with a 1° box centred at (0, 0). They zoomed out and + // the viewport is now 10° on a side. Bbox is comfortably inside. + val bbox = box(s = -0.5, w = -0.5, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, + margin = M, + ) + assertNull("bbox fully inside viewport must not trigger shrink", result) + } + + @Test + fun `pan but bbox still fully visible — no shrink`() { + // User panned the camera a bit. Viewport moved with it, bbox is + // still entirely inside. Should be a no-op. + val bbox = box(s = 0.0, w = 0.0, n = 1.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 3.0, viewS = -1.0, viewE = 3.0, viewW = -1.0, + margin = M, + ) + assertNull("bbox still on-screen must not trigger shrink", result) + } + + // ----- the cases that SHOULD trigger ----- + + @Test + fun `zoom-in past bbox extent — shrinks bbox to viewport minus margin`() { + // Bbox is 10° on a side; user zoomed in so viewport is only 2° on a + // side. Bbox extends way past the viewport on all axes. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNotNull("zoom-in past bbox should shrink", result) + // 35% inset on a 2° viewport = 0.7° inset on each side. + assertEquals(-0.3, result!!.south, 1e-9) + assertEquals(0.3, result.north, 1e-9) + assertEquals(-0.3, result.west, 1e-9) + assertEquals(0.3, result.east, 1e-9) + } + + @Test + fun `pan so one edge crosses viewport — shrinks`() { + // Viewport is 2° wide centred at lon=0. Bbox extends from lon=-0.5 + // to lon=2.5 — its east edge (2.5) is outside viewport east (1.0). + val bbox = box(s = -0.5, w = -0.5, n = 0.5, e = 2.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNotNull("partial offscreen should shrink", result) + // New bbox = viewport inset by 35% on each side. + assertEquals(-0.3, result!!.south, 1e-9) + assertEquals(0.3, result.north, 1e-9) + assertEquals(-0.3, result.west, 1e-9) + assertEquals(0.3, result.east, 1e-9) + } + + @Test + fun `pan a small box past the edge of a wide view — recentres without growing`() { + // A06 device repro, 2026-10-30: a 1669x2223 km box panned past the right edge of a much + // wider view came back as 3131x4873 km, because the inset viewport was bigger than the box. + val bbox = box(s = -0.5, w = 9.5, n = 0.5, e = 10.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 10.0, viewS = -10.0, viewE = 10.0, viewW = -10.0, + margin = M, + ) + assertNotNull("partial offscreen should still recentre", result) + assertEquals("lat span must not grow", 1.0, result!!.north - result.south, 1e-9) + assertEquals("lon span must not grow", 1.0, result.east - result.west, 1e-9) + assertEquals("recentred on the view", 0.0, (result.north + result.south) / 2, 1e-9) + assertEquals("recentred on the view", 0.0, (result.east + result.west) / 2, 1e-9) + } + + @Test + fun `bbox exactly at viewport edge — counts as inside, no shrink`() { + // Boundary-inclusive: bbox edges touch viewport edges exactly. Should + // NOT trigger (no overflow). Matches Bbox.contains semantics. + val bbox = box(s = -1.0, w = -1.0, n = 1.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNull("bbox flush with viewport edges must not shrink", result) + } + + // ----- margin semantics ----- + + @Test + fun `15 percent margin leaves 70 percent of viewport`() { + // 0° to 100° wide viewport → 15° inset on each side → result is + // 15° to 85° = 70° wide = 70% of viewport. + val bbox = box(s = -50.0, w = -50.0, n = 50.0, e = 50.0) // way bigger + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 100.0, viewS = 0.0, viewE = 100.0, viewW = 0.0, + margin = 0.15, + )!! + assertEquals(15.0, result.south, 1e-9) + assertEquals(85.0, result.north, 1e-9) + assertEquals(15.0, result.west, 1e-9) + assertEquals(85.0, result.east, 1e-9) + // Verify percentages. + val resultLatSpan = result.north - result.south + val resultLonSpan = result.east - result.west + assertEquals(70.0, resultLatSpan, 1e-9) + assertEquals(70.0, resultLonSpan, 1e-9) + } + + @Test + fun `margin is clamped at 0_45 to prevent degenerate result`() { + // Margin > 0.45 (which would inset 90%+ → degenerate) gets clamped. + // 0.45 on a 2°-wide viewport = 0.9° inset on each side + // → result span = 2° - 2 × 0.9° = 0.2°. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.9, // would be 90% inset → degenerate without clamping + )!! + assertEquals(0.2, result.north - result.south, 1e-9) + assertEquals(0.2, result.east - result.west, 1e-9) + } + + @Test + fun `degenerate viewport returns null`() { + // E < W or N < S (could happen on uninitialized projection). + val bbox = box(s = -1.0, w = -1.0, n = 1.0, e = 1.0) + // Degenerate N<=S. + assertNull( + AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 0.0, viewS = 0.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + ) + // Degenerate E<=W (anti-meridian crossing or stale viewport). + assertNull( + AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = -1.0, viewW = 1.0, + margin = M, + ) + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt new file mode 100644 index 00000000..31e68962 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt @@ -0,0 +1,209 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [Bbox]. Covers: + * - construction guards (south <= north, west <= east, anti-meridian rejection). + * - [Bbox.contains] — boundary-inclusive containment, edge cases at poles + meridians. + * - [Bbox.widthKm] / [Bbox.heightKm] / [Bbox.areaKm2] math sanity. + * - [Bbox.aroundPoint] — equator vs. high-latitude longitude scaling, latitude clamp. + */ +class BboxTest { + + // ----- construction guards ----- + + @Test(expected = IllegalArgumentException::class) + fun rejectsSouthGreaterThanNorth() { + Bbox(south = 10.0, west = 0.0, north = 5.0, east = 1.0) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsWestGreaterThanEast() { + // Anti-meridian crossing: west = 170, east = -170 — unsupported per init. + Bbox(south = 0.0, west = 170.0, north = 1.0, east = -170.0) + } + + @Test + fun acceptsPointBox() { + // Degenerate: south == north, west == east. Lat / lon clamps in + // aroundPoint can produce these at extreme inputs and we want them + // to round-trip rather than throw. + val pt = Bbox(south = 0.0, west = 0.0, north = 0.0, east = 0.0) + assertEquals(0.0, pt.widthKm(), 0.0001) + assertEquals(0.0, pt.heightKm(), 0.0001) + } + + // ----- contains() ----- + + @Test + fun containsInteriorPoint() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + assertTrue(box.contains(20.0, 30.0)) + assertTrue(box.contains(15.5, 25.5)) + } + + @Test + fun containsBoundaryInclusive() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + // All four corners. + assertTrue(box.contains(10.0, 20.0)) + assertTrue(box.contains(30.0, 20.0)) + assertTrue(box.contains(10.0, 40.0)) + assertTrue(box.contains(30.0, 40.0)) + // Edge midpoints. + assertTrue(box.contains(10.0, 30.0)) + assertTrue(box.contains(30.0, 30.0)) + assertTrue(box.contains(20.0, 20.0)) + assertTrue(box.contains(20.0, 40.0)) + } + + @Test + fun excludesPointsOutsideBox() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + assertFalse(box.contains(9.999, 30.0)) + assertFalse(box.contains(30.001, 30.0)) + assertFalse(box.contains(20.0, 19.999)) + assertFalse(box.contains(20.0, 40.001)) + assertFalse(box.contains(0.0, 0.0)) + } + + @Test + fun containsEdgeCasesAtPoles() { + // Box extending to the latitude clamp (±85). aroundPoint clamps at + // ±85 explicitly; init's bounds are -90..90 nominally. + val polar = Bbox(south = 80.0, west = -10.0, north = 85.0, east = 10.0) + assertTrue(polar.contains(85.0, 0.0)) + assertTrue(polar.contains(80.0, 0.0)) + assertFalse(polar.contains(85.001, 0.0)) + } + + @Test + fun containsEdgeCasesAtMeridian() { + // Box adjacent to the antimeridian — but strictly east of it, since + // crossing the antimeridian is rejected at construction. + val nearEdge = Bbox(south = 0.0, west = 175.0, north = 5.0, east = 180.0) + assertTrue(nearEdge.contains(2.0, 180.0)) + assertTrue(nearEdge.contains(2.0, 175.0)) + assertFalse(nearEdge.contains(2.0, -180.0)) // -180 != 180 in this model. + } + + // ----- width / height / area math ----- + + @Test + fun widthAndHeightForOneDegreeBoxAtEquator() { + // 1° at the equator ≈ 111.32 km in both axes. Tolerance ~1 km + // because haversine uses 6371.0088 km radius. + val box = Bbox(south = 0.0, west = 0.0, north = 1.0, east = 1.0) + assertEquals(111.0, box.widthKm(), 1.0) + assertEquals(111.0, box.heightKm(), 1.0) + // Area ≈ 12,300 km² for a degree square at the equator. + assertEquals(12_321.0, box.areaKm2(), 200.0) + } + + @Test + fun widthShrinksWithLatitudeFromCosine() { + // Same 1° lon span but farther from equator should be narrower in km. + val equator = Bbox(south = 0.0, west = 0.0, north = 1.0, east = 1.0) + val midLat = Bbox(south = 45.0, west = 0.0, north = 46.0, east = 1.0) + assertTrue(midLat.widthKm() < equator.widthKm()) + // cos(45°) ≈ 0.707; 1° at 45° ≈ 78 km. + assertEquals(78.0, midLat.widthKm(), 2.0) + // Height stays ~111 km regardless of latitude. + assertEquals(equator.heightKm(), midLat.heightKm(), 0.5) + } + + // ----- aroundPoint() ----- + + @Test + fun aroundPointBuildsApproximateSquareAtEquator() { + // 10×10 km box around the equator: should be ~0.09° on each side. + val box = Bbox.aroundPoint(0.0, 0.0, 10.0) + assertEquals(10.0, box.widthKm(), 0.5) + assertEquals(10.0, box.heightKm(), 0.5) + } + + @Test + fun aroundPointWidensLongitudeAtHighLatitudes() { + // 10×10 km at 60° latitude. Latitude span ≈ 0.09°; longitude span + // ≈ 0.09° / cos(60°) = 0.18°. + val box = Bbox.aroundPoint(60.0, 0.0, 10.0) + val latSpan = box.north - box.south + val lonSpan = box.east - box.west + // Longitude span should be roughly 2x latitude span at lat=60. + assertTrue( + "Expected lonSpan ≈ 2 × latSpan at lat=60°, got latSpan=$latSpan lonSpan=$lonSpan", + lonSpan > latSpan * 1.8 && lonSpan < latSpan * 2.2 + ) + // Physical width should still be ~10 km. + assertEquals(10.0, box.widthKm(), 0.5) + assertEquals(10.0, box.heightKm(), 0.5) + } + + @Test + fun aroundPointClampsLatitudeAtPoles() { + // Centred near the pole, 1000 km box. Latitude must clamp at ±85 + // per the production code (Web-Mercator-friendly). + val box = Bbox.aroundPoint(89.0, 0.0, 1000.0) + assertTrue(box.south >= -85.0) + assertTrue(box.north <= 85.0) + } + + @Test + fun aroundPointPreventsCosLatBlowup() { + // At 90° latitude cos = 0; the production code floors at 0.01 to + // keep the longitude division finite. The result is constructable + // (no infinity, no NaN) which is the contract under test here. + val box = Bbox.aroundPoint(90.0, 0.0, 10.0) + assertNotNull(box) + assertTrue(box.east.isFinite()) + assertTrue(box.west.isFinite()) + assertTrue(box.north.isFinite()) + assertTrue(box.south.isFinite()) + } + + @Test + fun toBoundsArrayRoundTripsConstructor() { + val box = Bbox(south = -10.0, west = -20.0, north = 30.0, east = 40.0) + val arr = box.toBoundsArray() + assertEquals(4, arr.size) + assertEquals(-10.0, arr[0], 0.0) + assertEquals(-20.0, arr[1], 0.0) + assertEquals(30.0, arr[2], 0.0) + assertEquals(40.0, arr[3], 0.0) + + // Reconstruct. + val reconstructed = Bbox(arr[0], arr[1], arr[2], arr[3]) + assertEquals(box.south, reconstructed.south, 0.0) + assertEquals(box.east, reconstructed.east, 0.0) + } + + // ----- haversine sanity ----- + + @Test + fun haversineKmKnownDistance() { + // SF (37.7749, -122.4194) to NYC (40.7128, -74.0060) ≈ 4129 km. + val km = haversineKm(37.7749, -122.4194, 40.7128, -74.0060) + assertEquals(4129.0, km, 5.0) + } + + @Test + fun haversineKmZeroForSamePoint() { + assertEquals(0.0, haversineKm(45.0, 90.0, 45.0, 90.0), 0.0001) + } + + @Test + fun `boxes with the same edges are equal and print their edges`() { + // The download log line prints the box; a non-data class printed Bbox@1b6d3586. + assertEquals(Bbox(1.0, 2.0, 3.0, 4.0), Bbox(1.0, 2.0, 3.0, 4.0)) + assertEquals("Bbox(south=1.0, west=2.0, north=3.0, east=4.0)", Bbox(1.0, 2.0, 3.0, 4.0).toString()) + } + + // isReasonableRegionSize tests removed 2026-05-26 — zoom auto-cap by cell + // budget replaces the bbox-size gate; whole-world bboxes are now + // downloadable at low zoom levels. +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt new file mode 100644 index 00000000..34f83528 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt @@ -0,0 +1,90 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Exhaustive tests for the estimate-line decision logic extracted from BboxPickerFragment. */ +class EstimateDisplayTest { + + private val allowance = 5L * 1024 * 1024 // 5 MB non-vector headroom + private val cap = 1024L * 1024 * 1024 // 1 GB + + private fun compute( + realBytes: Long?, + failed: Boolean = false, + hasSource: Boolean = true, + noTiles: Boolean = false, + ) = EstimateDisplay.compute(realBytes, failed, hasSource, allowance, cap, noTiles) + + @Test + fun `no source - blank line, Next disabled`() { + val s = compute(realBytes = 123L, hasSource = false) + assertEquals("", s.text) + assertFalse(s.isError) + assertFalse(s.overBudget) + assertFalse(s.nextEnabled) + } + + @Test + fun `no tiles - error line, Next disabled`() { + // An empty tile list still estimates 8 KB of header overhead, which used to read "0.0 MB". + val s = compute(realBytes = 8L * 1024, noTiles = true) + assertEquals(EstimateDisplay.NO_TILES_TEXT, s.text) + assertTrue(s.isError) + assertFalse(s.overBudget) + assertFalse("Next must stay off when the box holds no tiles", s.nextEnabled) + } + + @Test + fun `no tiles is ignored until an estimate has finished`() { + val s = compute(realBytes = null, noTiles = true) + assertEquals("Calculating download size…", s.text) + assertTrue(s.nextEnabled) + } + + @Test + fun `calculating - allows Next`() { + val s = compute(realBytes = null, failed = false) + assertEquals("Calculating download size…", s.text) + assertFalse(s.isError) + assertTrue(s.nextEnabled) + } + + @Test + fun `failed - error text, Next disabled`() { + val s = compute(realBytes = null, failed = true) + assertEquals("Couldn't calculate size — check connection", s.text) + assertTrue(s.isError) + assertFalse(s.nextEnabled) + } + + @Test + fun `under cap - shows MB and enables Next`() { + val tenMb = 10L * 1024 * 1024 + val s = compute(realBytes = tenMb) + assertEquals("10.0 MB download size", s.text) + assertFalse(s.isError) + assertFalse(s.overBudget) + assertTrue(s.nextEnabled) + } + + @Test + fun `over cap including allowance - over-budget, Next disabled`() { + // 1020 MB vector + 5 MB allowance = 1025 MB > 1 GB (1024 MB) + val s = compute(realBytes = 1020L * 1024 * 1024) + assertTrue(s.text.contains("over 1 GB limit")) + assertTrue(s.isError) + assertTrue(s.overBudget) + assertFalse(s.nextEnabled) + } + + @Test + fun `the non-vector allowance is what pushes a near-cap region over`() { + // 1022 MB vector alone is under 1 GB, but +5 MB allowance crosses it. + val s = compute(realBytes = 1022L * 1024 * 1024) + assertTrue(s.overBudget) + assertFalse(s.nextEnabled) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt new file mode 100644 index 00000000..a36234ed --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Verifies the pure style-JSON construction extracted from BboxPickerFragment. */ +class IiabStyleBuilderTest { + + @Test + fun `null tiles url yields a background-only style`() { + val style = IiabStyleBuilder.buildStyle(pmtilesHttpUrl = null, fontsRoot = null) + assertTrue(style.contains("\"background\"")) + assertFalse(style.contains("openmaptiles")) + assertFalse(style.contains("pmtiles://")) + } + + @Test + fun `tiles url is wrapped in the pmtiles scheme and used as the vector source`() { + val style = IiabStyleBuilder.buildStyle( + pmtilesHttpUrl = "http://127.0.0.1:8080/tiles.pmtiles", + fontsRoot = "/data/fonts", + ) + assertTrue(style.contains("\"url\": \"pmtiles://http://127.0.0.1:8080/tiles.pmtiles\"")) + assertTrue(style.contains("\"openmaptiles\"")) + assertTrue(style.contains("place-city-label")) + } + + @Test + fun `fontsRoot present uses a file glyphs url`() { + val style = IiabStyleBuilder.buildStyle("http://h/t.pmtiles", "/data/fonts") + assertTrue(style.contains("\"glyphs\": \"file:///data/fonts/{fontstack}/{range}.pbf\"")) + } + + @Test + fun `fontsRoot null falls back to an unfulfillable asset glyphs url`() { + val style = IiabStyleBuilder.buildStyle("http://h/t.pmtiles", fontsRoot = null) + assertTrue(style.contains("\"glyphs\": \"asset://fonts/{fontstack}/{range}.pbf\"")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt new file mode 100644 index 00000000..bc7b3cb4 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt @@ -0,0 +1,84 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RegionId.slugify] — the function that turns a user-typed region + * name into a cache-safe id. This is the half the old tests didn't cover (only the + * validator was tested). The NFC test builds its strings from code points so the + * composed-vs-decomposed distinction is unambiguous in source; the other accented + * cases use ordinary (precomposed) UTF-8 letters, and the script literals + * (CJK / Arabic / Cyrillic) are safe to embed verbatim. + */ +class RegionIdSlugifyTest { + + @Test + fun keeps_non_latin_letters() { + assertEquals("北京", RegionId.slugify("北京")) + assertEquals("القاهرة", RegionId.slugify("القاهرة")) + assertEquals("москва", RegionId.slugify("Москва")) // Cyrillic lowercased + } + + @Test + fun lowercases_accented_latin() { + assertEquals("córdoba", RegionId.slugify("Córdoba")) + } + + @Test + fun nfc_normalises_so_decomposed_and_composed_match() { + // Built from code points so composed vs decomposed is unambiguous in source. + val composed = "Caf" + Char(0x00E9) // precomposed é + val decomposed = "Cafe" + Char(0x0301) // e + combining acute + val expected = "caf" + Char(0x00E9) // lowercase precomposed é + assertEquals(expected, RegionId.slugify(composed)) + assertEquals(expected, RegionId.slugify(decomposed)) + assertEquals(RegionId.slugify(composed), RegionId.slugify(decomposed)) + } + + @Test + fun collapses_separators_punctuation_and_whitespace_to_hyphen() { + assertEquals("san-francisco", RegionId.slugify("San Francisco!")) + assertEquals("a-b", RegionId.slugify("a / b")) + assertEquals("foo-bar", RegionId.slugify("foo___bar")) + assertEquals("são-paulo", RegionId.slugify("São Paulo")) + } + + @Test + fun trims_leading_and_trailing_hyphens() { + assertEquals("hi", RegionId.slugify(" hi ")) + assertEquals("x", RegionId.slugify("--x--")) + } + + @Test + fun blank_when_no_usable_characters() { + assertEquals("", RegionId.slugify("!!!")) + assertEquals("", RegionId.slugify(" ")) + assertEquals("", RegionId.slugify("///")) + assertEquals("", RegionId.slugify("")) + } + + @Test + fun caps_length() { + val s = RegionId.slugify("a".repeat(100)) + assertEquals(RegionId.MAX_LEN, s.length) + assertTrue(RegionId.isValid(s)) + } + + @Test + fun output_is_always_blank_or_valid() { + val names = listOf( + "San Francisco", "北京 (Beijing)", "القاهرة!", "Café", + "São Paulo", " -- ", "123", "Zürich", "tokyo/東京", + "a".repeat(200), "", "!@#\$%", + ) + for (n in names) { + val s = RegionId.slugify(n) + assertTrue( + "slugify('$n') = '$s' must be blank or pass isValid", + s.isEmpty() || RegionId.isValid(s), + ) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt new file mode 100644 index 00000000..5266b1cf --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt @@ -0,0 +1,190 @@ +package org.appdevforall.maps.domain + +import org.appdevforall.maps.domain.RegionWizardStateMachine.Event +import org.appdevforall.maps.domain.RegionWizardStateMachine.Step +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [RegionWizardStateMachine] — the wizard's step sequencing + + * prefill-propagation rules extracted from `RegionManagerFragment`. Covers the + * full new-download sequence, the redownload-prefill flow, the Step-3 Back + * hop, the zoom-range threading, and the state reset on every list-returning + * event. + */ +class RegionWizardStateMachineTest { + + private val machine = RegionWizardStateMachine() + + private val bbox = Bbox(6.0, 3.0, 7.0, 4.0) + private val estimate = TileEstimate( + tileCount = 100L, + sizeBytesEstimate = 5_000_000L, + zoomMin = 6, + zoomMax = 12, + ) + + // ----- New-download happy path ----- + + @Test + fun `new download walks source - bbox - save - download - list`() { + val source = machine.onEvent(Event.NewDownloadRequested) + assertEquals(Step.SourcePicker(null, null), source) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, "iiab.switnet.org"), + ) as Step.BboxPicker + assertNull(picker.prefillRegionId) + assertNull(picker.prefillRegionName) + assertNull("fresh wizard must not carry a stale bbox", picker.prefillBbox) + assertEquals(SourceKind.INTERNET, picker.sourceKind) + assertEquals("iiab.switnet.org", picker.sourceHost) + + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = null, prefillRegionName = null), + ) as Step.SaveStep + assertSame(bbox, save.bbox) + assertSame(estimate, save.estimate) + assertEquals(SourceKind.INTERNET, save.sourceKind) + assertNull(save.prefillRegionId) + + val download = machine.onEvent( + Event.SaveConfirmed(displayName = "Lagos", regionId = "lagos"), + ) as Step.DownloadProgress + assertEquals("lagos", download.regionId) + assertEquals("Lagos", download.displayName) + assertSame(bbox, download.bbox) + assertEquals(SourceKind.INTERNET, download.sourceKind) + // The picker's auto-capped zoom range is threaded to the downloader. + assertEquals(6, download.zoomMin) + assertEquals(12, download.zoomMax) + + assertEquals(Step.RegionList, machine.onEvent(Event.DownloadDone)) + } + + @Test + fun `save without an estimate falls back to the downloader default zoom range`() { + machine.onEvent(Event.NewDownloadRequested) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + machine.onEvent(Event.BboxPicked(bbox, estimate = null, prefillRegionId = null, prefillRegionName = null)) + val download = machine.onEvent( + Event.SaveConfirmed("Lagos", "lagos"), + ) as Step.DownloadProgress + assertEquals(6, download.zoomMin) + assertEquals(14, download.zoomMax) + } + + // ----- Step 3 Back → Step 2 ----- + + @Test + fun `save back re-opens the bbox picker with the picked bbox as prefill`() { + machine.onEvent(Event.NewDownloadRequested) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, "iiab.switnet.org")) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + val picker = machine.onEvent(Event.SaveBackRequested) as Step.BboxPicker + assertSame("Back must re-open on the same selection", bbox, picker.prefillBbox) + assertEquals(SourceKind.INTERNET, picker.sourceKind) + assertEquals("iiab.switnet.org", picker.sourceHost) + } + + // ----- Redownload (Refresh) prefill flow ----- + + @Test + fun `redownload prefills id, name and bounds through the whole wizard`() { + val source = machine.onEvent( + Event.RedownloadRequested( + regionId = "lagos", + displayName = "Lagos", + bbox = doubleArrayOf(6.0, 3.0, 7.0, 4.0), + ), + ) as Step.SourcePicker + assertEquals("lagos", source.prefillRegionId) + assertEquals("Lagos", source.prefillRegionName) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertEquals("lagos", picker.prefillRegionId) + assertEquals("Lagos", picker.prefillRegionName) + val prefill = picker.prefillBbox + assertTrue("saved bounds must prefill the picker", prefill != null) + assertEquals(6.0, prefill!!.south, 0.0) + assertEquals(3.0, prefill.west, 0.0) + assertEquals(7.0, prefill.north, 0.0) + assertEquals(4.0, prefill.east, 0.0) + + // Step 2 returning null prefill fields must NOT wipe the redownload ids. + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = null, prefillRegionName = null), + ) as Step.SaveStep + assertEquals("lagos", save.prefillRegionId) + assertEquals("Lagos", save.prefillRegionName) + } + + @Test + fun `bbox picker can override the prefill ids when it forwards non-null ones`() { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", null)) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = "lagos-v2", prefillRegionName = "Lagos v2"), + ) as Step.SaveStep + assertEquals("lagos-v2", save.prefillRegionId) + assertEquals("Lagos v2", save.prefillRegionName) + } + + @Test + fun `redownload drops a malformed bounds tuple instead of crashing`() { + // Wrong size. + machine.onEvent(Event.RedownloadRequested("a", "A", doubleArrayOf(1.0, 2.0, 3.0))) + var picker = machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) as Step.BboxPicker + assertNull(picker.prefillBbox) + machine.onEvent(Event.Exit) + + // Inverted bounds (south > north) — Bbox's init{} would throw. + machine.onEvent(Event.RedownloadRequested("b", "B", doubleArrayOf(7.0, 3.0, 6.0, 4.0))) + picker = machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) as Step.BboxPicker + assertNull(picker.prefillBbox) + } + + // ----- Reset on every list-returning event ----- + + @Test + fun `exit resets all wizard state`() { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", doubleArrayOf(6.0, 3.0, 7.0, 4.0))) + machine.onEvent(Event.SourceConfirmed(SourceKind.IIAB_LAN, "iiab.local")) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + assertEquals(Step.RegionList, machine.onEvent(Event.Exit)) + + // A later wizard run must not inherit anything from the abandoned one. + assertEquals(Step.SourcePicker(null, null), machine.onEvent(Event.NewDownloadRequested)) + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertNull(picker.prefillBbox) + assertNull(picker.prefillRegionId) + assertNull(picker.prefillRegionName) + } + + @Test + fun `download done, failed and cancelled all return to the list and reset`() { + for (terminal in listOf(Event.DownloadDone, Event.DownloadFailed, Event.DownloadCancelled)) { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", null)) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + assertEquals(Step.RegionList, machine.onEvent(terminal)) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertNull("$terminal must reset the bbox", picker.prefillBbox) + assertNull("$terminal must reset the prefill id", picker.prefillRegionId) + machine.onEvent(Event.Exit) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt new file mode 100644 index 00000000..8d0e8100 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt @@ -0,0 +1,65 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Coverage tests for [TileEstimate] — a pure value type. Exercises the data-class + * fields, the [TileEstimate.sizeMb] byte→MB conversion, and the + * [TileEstimate.displayString] formatting (tile count · MB · zoom range). + */ +class TileEstimateCoverageTest { + + @Test + fun `fields are carried verbatim`() { + val est = TileEstimate( + tileCount = 1234, + sizeBytesEstimate = 5_242_880, // 5 MB exactly + zoomMin = 6, + zoomMax = 14, + ) + assertEquals(1234L, est.tileCount) + assertEquals(5_242_880L, est.sizeBytesEstimate) + assertEquals(6, est.zoomMin) + assertEquals(14, est.zoomMax) + } + + @Test + fun `sizeMb converts bytes to mebibytes`() { + // 5 MiB exactly. + val est = TileEstimate(0, 5L * 1024 * 1024, 6, 14) + assertEquals(5.0, est.sizeMb(), 1e-9) + } + + @Test + fun `sizeMb of zero bytes is zero`() { + val est = TileEstimate(0, 0, 6, 14) + assertEquals(0.0, est.sizeMb(), 1e-9) + } + + @Test + fun `displayString renders count MB and zoom range`() { + // 2.5 MiB so the %.1f formatting shows a fractional value. + val bytes = (2.5 * 1024 * 1024).toLong() + val est = TileEstimate(42, bytes, 8, 13) + val s = est.displayString() + assertTrue("should include tile count: $s", s.contains("42 tiles")) + // Locale-robust: the %.1f decimal separator varies by default locale + // ("2.5" vs "2,5"); assert the digits + unit + the · separators instead. + assertTrue("should include the MB value and unit: $s", s.contains("2") && s.contains("5 MB")) + assertTrue("should include the middot separators: $s", s.contains("·")) + assertTrue("should include zoom range: $s", s.contains("zoom 8–13")) + } + + @Test + fun `data class equality and copy`() { + val a = TileEstimate(10, 100, 6, 12) + val b = TileEstimate(10, 100, 6, 12) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + val c = a.copy(tileCount = 11) + assertEquals(11L, c.tileCount) + assertEquals(a.sizeBytesEstimate, c.sizeBytesEstimate) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt new file mode 100644 index 00000000..55d857d2 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt @@ -0,0 +1,92 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Branch-coverage supplement for [ZoomCap] — drives the Web-Mercator tile-math + * clamps in [ZoomCap.cellsInBboxAtZoom] (x/y coerced into `[0, n-1]` at the map + * edges) and the [ZoomCap.pickZoomMax] decision branches at the budget boundary + * and at the zMin == MAX_ZOOM degenerate range. + */ +class ZoomCapCoverageTest { + + @Test + fun `world-edge bbox clamps tile x and y into range`() { + // east=180 / north=85.0511 push the raw tile index to exactly n, so the + // coerceIn(0, n-1) upper clamp fires; west=-180 / south=-85.0511 push the + // lower bound to 0. Result is the full tile grid at that zoom (n x n). + val world = Bbox(south = -85.0511, west = -180.0, north = 85.0511, east = 180.0) + val z = 4 + val n = 1L shl z + val cells = ZoomCap.cellsInBboxAtZoom(world, z) + // Full grid (n columns x n rows) after the edge clamps. + assertEquals(n * n, cells) + } + + @Test + fun `single-point-ish bbox yields exactly one cell at low zoom`() { + // A pinpoint bbox collapses x/y min == max → xCount=1, yCount=1. + // Use an INTERIOR point — (0,0) sits exactly on the z6 equator/prime- + // meridian tile boundary, so a bbox there legitimately straddles 2 y-tiles. + val tiny = Bbox(south = 23.0, west = 8.0, north = 23.0001, east = 8.0001) + assertEquals(1L, ZoomCap.cellsInBboxAtZoom(tiny, 6)) + } + + @Test + fun `pickZoomMax returns MAX_ZOOM when zMin equals MAX_ZOOM and under budget`() { + // zMin == MAX_ZOOM makes the for-loop body run exactly once. + val tiny = Bbox(south = 0.0, west = 0.0, north = 0.001, east = 0.001) + val capped = ZoomCap.pickZoomMax(tiny, zMin = ZoomCap.MAX_ZOOM) + assertEquals(ZoomCap.MAX_ZOOM, capped) + } + + @Test + fun `pickZoomMax picks an intermediate cap at a mid budget`() { + // A budget that's exceeded at z=14 but satisfied lower forces the loop to + // iterate down past the first candidate before returning. + val bbox = Bbox(south = -1.0, west = -1.0, north = 1.0, east = 1.0) + val capped = ZoomCap.pickZoomMax(bbox, cellBudget = 2_000) + assertTrue("expected a mid cap in [6,14]; got $capped", capped in 6..14) + // The chosen cap must actually satisfy the budget. + val cells = (6..capped).sumOf { ZoomCap.cellsInBboxAtZoom(bbox, it) } + assertTrue("chosen cap $capped must be under budget; cells=$cells", cells <= 2_000) + } + + @Test + fun `pickZoomMax with zMin above MAX_ZOOM skips the loop and returns zMin`() { + // zMin > MAX_ZOOM makes `MAX_ZOOM downTo zMin` an empty range, so the + // for-loop body never runs and the method falls straight through to the + // `return zMin` tail (the loop-exhaust branch). This is the branch the + // budget-boundary cases can't reach, because they always enter the loop. + val bbox = Bbox(south = 0.0, west = 0.0, north = 0.001, east = 0.001) + val capped = ZoomCap.pickZoomMax(bbox, zMin = ZoomCap.MAX_ZOOM + 1) + assertEquals(ZoomCap.MAX_ZOOM + 1, capped) + } + + @Test + fun `pickZoomMax accepts a cap that exactly equals the budget`() { + // Drive the `totalCells <= cellBudget` comparison at exact equality, so + // the boundary `==` arm (not just strictly-less) returns rather than + // continuing to iterate down. Compute the real cell total at z=zMin and + // pass it verbatim as the budget. + val bbox = Bbox(south = -1.0, west = -1.0, north = 1.0, east = 1.0) + val zMin = 6 + val exactBudget = ZoomCap.cellsInBboxAtZoom(bbox, zMin).toInt() + // At z=zMin the running total is exactly the budget → accept zMin (or + // higher if a larger zMax still fits, but never below zMin). + val capped = ZoomCap.pickZoomMax(bbox, zMin = zMin, cellBudget = exactBudget) + assertTrue("cap must be at least zMin when zMin exactly fits; got $capped", capped >= zMin) + val total = (zMin..capped).sumOf { ZoomCap.cellsInBboxAtZoom(bbox, it) } + assertTrue("chosen total $total must satisfy the exact budget $exactBudget", + total <= exactBudget) + } + + @Test + fun `constants expose the documented defaults`() { + assertEquals(100_000, ZoomCap.DEFAULT_CELL_BUDGET) + assertEquals(6, ZoomCap.MIN_ZOOM) + assertEquals(14, ZoomCap.MAX_ZOOM) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt new file mode 100644 index 00000000..0ac446a7 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt @@ -0,0 +1,77 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-math tests for the bbox-picker's auto-cap heuristic. Each case + * spot-checks one expected zoom-cap for a bbox at a known scale, so + * regressions in either [ZoomCap.pickZoomMax] or [ZoomCap.cellsInBboxAtZoom] + * surface immediately. + */ +class ZoomCapTest { + + /** 1° × 1° city-sized bbox at lat 0 → small enough to stay at z=14. */ + @Test + fun pickZoomMax_smallBbox_keepsStreetDetail() { + val cityBbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + assertEquals(14, ZoomCap.pickZoomMax(cityBbox)) + } + + /** 20° × 20° default no-GPS bbox → caps at "town" detail (z=12). */ + @Test + fun pickZoomMax_default20deg_capsAtTown() { + val defaultBbox = Bbox(south = 5.0, west = -5.0, north = 25.0, east = 15.0) + assertEquals(12, ZoomCap.pickZoomMax(defaultBbox)) + } + + /** Whole-world bbox → caps low so the cell count stays under budget. */ + @Test + fun pickZoomMax_wholeWorld_capsRegionOrLower() { + val worldBbox = Bbox(south = -85.0, west = -180.0, north = 85.0, east = 180.0) + val capped = ZoomCap.pickZoomMax(worldBbox) + assertTrue("whole-world should cap at z=8 or lower; got z=$capped", capped <= 8) + } + + /** Below-budget cap (1 cell budget) → returns zMin. */ + @Test + fun pickZoomMax_tinyBudget_clampsToZMin() { + val anyBbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + assertEquals(ZoomCap.MIN_ZOOM, ZoomCap.pickZoomMax(anyBbox, cellBudget = 1)) + } + + /** Cell count monotonically non-decreases with zoom for a fixed bbox. */ + @Test + fun cellsInBboxAtZoom_monotonicallyIncreasesWithZoom() { + val bbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + var prev = 0L + for (z in 6..14) { + val cells = ZoomCap.cellsInBboxAtZoom(bbox, z) + assertTrue("z=$z cells=$cells should be ≥ z=${z - 1} cells=$prev", cells >= prev) + prev = cells + } + } + + /** Cell count multiplied by ~4 per zoom level for a square bbox at the equator. + * Use higher zooms (z=13 → z=14) so fence-post integer truncation isn't + * dominant — at low zooms the ratio drifts well below 4× because cell + * counts are tiny (e.g. 16 → 36 at z10→z11 = only 2.25×). */ + @Test + fun cellsInBboxAtZoom_approxQuadruplesPerZoom() { + val bbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + val z13 = ZoomCap.cellsInBboxAtZoom(bbox, 13) + val z14 = ZoomCap.cellsInBboxAtZoom(bbox, 14) + val ratio = z14.toDouble() / z13.toDouble() + assertTrue("expected ~4× growth z13→z14, got z13=$z13 z14=$z14 ratio=$ratio", + ratio in 3.5..4.5) + } + + /** Latitude clamping at ±85.0511° — north=90 doesn't blow up `tan(π/2)`. */ + @Test + fun cellsInBboxAtZoom_polarBboxDoesNotBlowUp() { + val polarBbox = Bbox(south = 80.0, west = -10.0, north = 90.0, east = 10.0) + val cells = ZoomCap.cellsInBboxAtZoom(polarBbox, 10) + assertTrue("polar bbox should still produce finite cell count; got $cells", cells >= 0) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt new file mode 100644 index 00000000..b241c8e3 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt @@ -0,0 +1,67 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.tan + +class ZoomFitTest { + + // The saved Lalibela region from the 10-27 device walk, and the Galaxy A06 map view in dp + // (720 x 1600 px at density 1.875). + private val lalibela = Bbox(9.961346470706417, 36.77746496147344, 14.919443564155998, 41.06244902809138) + private val a06WidthDp = 384.0 + private val a06HeightDp = 853.3 + + /** The bbox's on-screen size in dp at [zoom], from MapLibre's 512-dp-wide world, not ZoomFit's math. */ + private fun projectedSizeDp(bbox: Bbox, zoom: Double): Pair { + val worldDp = 512.0 * 2.0.pow(zoom) + fun mercY(lat: Double) = ln(tan(PI / 4 + Math.toRadians(lat) / 2)) + val width = (bbox.east - bbox.west) / 360.0 * worldDp + val height = (mercY(bbox.north) - mercY(bbox.south)) / (2 * PI) * worldDp + return width to height + } + + @Test + fun `a saved region fits inside the requested fraction of the screen on both axes`() { + val zoom = ZoomFit.fitZoom(lalibela, a06WidthDp, a06HeightDp, 0.5) + val (width, height) = projectedSizeDp(lalibela, zoom) + assertTrue("box is $width dp wide on a $a06WidthDp dp screen", width <= a06WidthDp * 0.5 * 1.02) + assertTrue("box is $height dp tall on a $a06HeightDp dp screen", height <= a06HeightDp * 0.5 * 1.02) + // A tight fit: the limiting axis fills the fraction rather than leaving the box tiny. + assertEquals(a06WidthDp * 0.5, width, a06WidthDp * 0.02) + } + + @Test + fun `a box taller than the screen's shape is fitted by its height`() { + val tall = Bbox(0.0, 0.0, 10.0, 1.0) + val zoom = ZoomFit.fitZoom(tall, a06WidthDp, a06WidthDp, 0.5) + val (_, height) = projectedSizeDp(tall, zoom) + assertEquals(a06WidthDp * 0.5, height, a06WidthDp * 0.02) + } + + @Test + fun `a smaller bbox fits at a higher zoom than a larger one`() { + val small = Bbox.aroundPoint(0.0, 0.0, 10.0) + val large = Bbox.aroundPoint(0.0, 0.0, 1000.0) + assertTrue(ZoomFit.fitZoom(small, 360.0, 640.0, 0.5) > ZoomFit.fitZoom(large, 360.0, 640.0, 0.5)) + } + + @Test + fun `zoom is clamped to 0_16`() { + val tiny = Bbox.aroundPoint(0.0, 0.0, 0.001) + val huge = Bbox.aroundPoint(0.0, 0.0, 20000.0) + assertTrue(ZoomFit.fitZoom(tiny, 360.0, 640.0, 0.5) <= 16.0) + assertTrue(ZoomFit.fitZoom(huge, 360.0, 640.0, 0.5) >= 0.0) + } + + @Test + fun `a map view not laid out yet falls back to 360 x 640 dp`() { + val box = Bbox.aroundPoint(0.0, 0.0, 100.0) + val z = ZoomFit.fitZoom(box, viewWidthDp = 0.0, viewHeightDp = 0.0, screenFraction = 0.5) + assertEquals(ZoomFit.fitZoom(box, 360.0, 640.0, 0.5), z, 1e-9) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt new file mode 100644 index 00000000..3a7ed06e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt @@ -0,0 +1,47 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the OSM-wiki zoom-name mapping. Every arm of the `when` is asserted at + * its boundaries so a shifted range (e.g. `in 3..5` becoming `in 3..6`) fails + * a test instead of silently mislabeling the bbox-dims line. + */ +class ZoomLabelTest { + + @Test + fun `every range maps to its OSM label at both boundaries`() { + val expected = listOf( + 0 to "world", 2 to "world", + 3 to "country", 5 to "country", + 6 to "country / state", 7 to "country / state", + 8 to "region", + 9 to "metro area", 10 to "metro area", + 11 to "city", + 12 to "town", + 13 to "village", + 14 to "streets", + 15 to "small roads", 16 to "small roads", + 17 to "buildings", 22 to "buildings", + ) + for ((z, label) in expected) { + assertEquals("zoom $z", label, ZoomLabel.forZoom(z)) + } + } + + @Test + fun `out-of-range zooms fall through to buildings`() { + // Negative zoom can't occur from ZoomCap, but the mapping's else-arm is + // the documented fallback either way. + assertEquals("buildings", ZoomLabel.forZoom(99)) + } + + @Test + fun `negative zoom hits the world arm`() { + // `in 0..2` excludes negatives; they land in else. Pin the actual + // behavior so a future "clamp negatives to world" change is a + // deliberate, test-visible decision. + assertEquals("buildings", ZoomLabel.forZoom(-1)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt new file mode 100644 index 00000000..114c4118 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.res + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +/** + * aapt2 drops an unescaped `"` from a string resource, `"` included, so the device shows the + * text without its quotes. Every literal double quote in a plugin string must be written `\"`. + */ +class StringResourceQuotesTest { + + @Test + fun `every double quote in a string resource is backslash-escaped`() { + val files = + File("src/main/res").listFiles { f -> f.name.startsWith("values") }!!.mapNotNull { + File(it, "strings.xml").takeIf(File::isFile) + } + assertTrue("no strings.xml found under src/main/res", files.isNotEmpty()) + var checked = 0 + val bad = mutableListOf() + for (file in files) { + val strings = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).getElementsByTagName("string") + for (i in 0 until strings.length) { + val element = strings.item(i) as Element + checked++ + // After XML decoding, a quote aapt2 keeps is one preceded by a backslash. + if (Regex("""(? 0) + assertEquals("strings with a quote aapt2 will drop (of $checked checked)", emptyList(), bad) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt new file mode 100644 index 00000000..f9e55aaf --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt @@ -0,0 +1,51 @@ +package org.appdevforall.maps.slicer + +import org.davidmoten.hilbert.HilbertCurve +import org.davidmoten.hilbert.SmallHilbertCurve +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Verifies that davidmoten/hilbert-curve produces the SAME Hilbert index for + * (x, y) as our [Hilbert.zxyToTileId] minus the zoom-base accumulator. + * + * Why this matters: PmtilesRegionSlicer uses davidmoten's + * `SmallHilbertCurve.query()` to compute tight tile-id ranges for a bbox. If + * davidmoten's Hilbert ordering disagrees with ours, the resulting ranges + * silently *miss* tiles (false negatives), and the slicer would return + * incomplete results. + * + * Both implementations claim to follow the standard "convert (x,y) to d" + * Hilbert curve. This test verifies that empirically across the (z, x, y) + * space we care about. + */ +class DavidmotenHilbertCompatTest { + + @Test + fun davidmotenIndex_matches_pmtilesHilbert_xyToD_atAllZooms() { + for (z in 1..18) { + val side = (1L shl z).toInt() + val curve: SmallHilbertCurve = HilbertCurve.small().bits(z).dimensions(2) + // Sample the full 4 corners + center + a handful of interior points. + val samples = listOf( + 0 to 0, + side - 1 to 0, + 0 to side - 1, + side - 1 to side - 1, + side / 2 to side / 2, + side / 3 to (2 * side / 5), + (3 * side / 7) to (side / 11), + ).filter { (x, y) -> x in 0 until side && y in 0 until side } + + for ((x, y) in samples) { + val ours = Hilbert.zxyToTileId(z, x, y) - Hilbert.accumulate(z) + val theirs = curve.index(x.toLong(), y.toLong()) + assertEquals( + "Hilbert mismatch at z=$z (x=$x, y=$y): ours=$ours, davidmoten=$theirs", + ours, + theirs, + ) + } + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt new file mode 100644 index 00000000..b5454255 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt @@ -0,0 +1,70 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Branch coverage for [Hilbert] guard arms not reached by the roundtrip test: + * out-of-range zoom in `accumulate`, out-of-range zoom/coords in `zxyToTileId`, + * negative `tileId` in `tileIdToZxy`, plus all four quadrant rotations of + * `hilbertXyToD` / `hilbertDToXy` (rx/ry combinations) via an explicit z=2 sweep. + */ +class HilbertCoverageTest { + + @Test + fun accumulate_rejects_negative_zoom() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.accumulate(-1) } + } + + @Test + fun accumulate_rejects_zoom_above_26() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.accumulate(27) } + } + + @Test + fun zxyToTileId_rejects_zoom_out_of_range() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(27, 0, 0) } + } + + @Test + fun zxyToTileId_rejects_x_out_of_range() { + // At z=1, n=2, valid x/y in 0..1; x=2 is out of range. + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(1, 2, 0) } + } + + @Test + fun zxyToTileId_rejects_y_out_of_range() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(1, 0, 2) } + } + + @Test + fun tileIdToZxy_rejects_negative() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.tileIdToZxy(-1L) } + } + + @Test + fun z2_full_sweep_roundtrips_all_quadrants() { + // A complete 4x4 sweep drives every rx/ry rotation branch in both the + // forward (xy->d) and reverse (d->xy) Hilbert routines. + for (x in 0..3) { + for (y in 0..3) { + val id = Hilbert.zxyToTileId(2, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals(2, z2) + assertEquals(x, x2) + assertEquals(y, y2) + } + } + } + + @Test + fun tileIdToZxy_finds_zoom_for_higher_levels() { + // Drives the zoom-search while loop past the z==0 case multiple times. + val id = Hilbert.zxyToTileId(5, 17, 9) + val (z, x, y) = Hilbert.tileIdToZxy(id) + assertEquals(5, z) + assertEquals(17, x) + assertEquals(9, y) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertMaxZoomTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertMaxZoomTest.kt new file mode 100644 index 00000000..d5bd0d54 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertMaxZoomTest.kt @@ -0,0 +1,27 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The deepest zoom PMTiles v3 allows must round-trip, and nothing past it may decode. */ +class HilbertMaxZoomTest { + + private val last = (1 shl 26) - 1 + + @Test + fun `zoom 26 tile ids round-trip at the first and last tile`() { + for ((x, y) in listOf(0 to 0, last to last, last to 0)) { + val id = Hilbert.zxyToTileId(26, x, y) + assertEquals(Triple(26, x, y), Hilbert.tileIdToZxy(id)) + } + } + + @Test + fun `an id past the last zoom 26 tile is rejected`() { + val pastEnd = Hilbert.accumulate(26) + (1L shl 52) + val thrown = assertThrows(IllegalArgumentException::class.java) { Hilbert.tileIdToZxy(pastEnd) } + assertTrue(thrown.message!!.endsWith("implies zoom > 26")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt new file mode 100644 index 00000000..c07e0098 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt @@ -0,0 +1,37 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Negative-argument arms of [Hilbert]'s precondition checks. + * `HilbertCoverageTest` covers the too-LARGE side (z=27, x/y = n); these are + * the too-SMALL side of the same `in 0..max` range checks. + */ +class HilbertNegativeArgsTest { + + @Test + fun zxyToTileId_rejects_negative_zoom() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(-1, 0, 0) + } + assertTrue(ex.message!!.contains("out of range")) + } + + @Test + fun zxyToTileId_rejects_negative_x() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(1, -1, 0) + } + assertTrue(ex.message!!.contains("out of range")) + } + + @Test + fun zxyToTileId_rejects_negative_y() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(1, 0, -1) + } + assertTrue(ex.message!!.contains("out of range")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt new file mode 100644 index 00000000..3388547b --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt @@ -0,0 +1,97 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HilbertTest { + + @Test + fun accumulate_zero_is_zero() { + assertEquals(0L, Hilbert.accumulate(0)) + } + + @Test + fun accumulate_z1_is_one() { + // 1 tile at z0 + assertEquals(1L, Hilbert.accumulate(1)) + } + + @Test + fun accumulate_z2_is_five() { + // 1 (z0) + 4 (z1) = 5 + assertEquals(5L, Hilbert.accumulate(2)) + } + + @Test + fun accumulate_z3_is_twenty_one() { + // 1 + 4 + 16 = 21 + assertEquals(21L, Hilbert.accumulate(3)) + } + + /** + * Spot-checks against the PMTiles Python reference impl. The tile_id values + * below come from running `pmtiles.tile.zxy_to_tileid(z, x, y)` in the + * Python pmtiles package; cross-checked because Hilbert variants differ + * slightly between libraries. + */ + @Test + fun zxyToTileId_z0_0_0_is_0() { + assertEquals(0L, Hilbert.zxyToTileId(0, 0, 0)) + } + + @Test + fun zxyToTileId_z1_corners() { + // PMTiles Hilbert at z=1 maps the 4 corners to ids 1..4. + // Expected from reference: (0,0)=1, (0,1)=2, (1,1)=3, (1,0)=4 + assertEquals(1L, Hilbert.zxyToTileId(1, 0, 0)) + assertEquals(2L, Hilbert.zxyToTileId(1, 0, 1)) + assertEquals(3L, Hilbert.zxyToTileId(1, 1, 1)) + assertEquals(4L, Hilbert.zxyToTileId(1, 1, 0)) + } + + @Test + fun zxyToTileId_z2_starts_after_accumulate() { + // All z=2 ids in [5, 5+16) = [5, 21) + for (x in 0..3) { + for (y in 0..3) { + val id = Hilbert.zxyToTileId(2, x, y) + assert(id in 5..20) { "z2 ($x,$y) -> $id, expected in [5,20]" } + } + } + } + + @Test + fun zxy_roundtrips() { + for (z in 0..6) { + val n = 1 shl z + for (x in 0 until n) { + for (y in 0 until n) { + val id = Hilbert.zxyToTileId(z, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals("z mismatch at ($z,$x,$y) -> $id", z, z2) + assertEquals("x mismatch at ($z,$x,$y) -> $id", x, x2) + assertEquals("y mismatch at ($z,$x,$y) -> $id", y, y2) + } + } + } + } + + @Test + fun zxy_roundtrips_z14_random_samples() { + // z=14: 268M tiles — sample a few. + val samples = listOf( + Triple(14, 0, 0), + Triple(14, 1234, 5678), + Triple(14, 16383, 16383), + Triple(14, 8192, 8192), + Triple(14, 100, 16000), + ) + for ((z, x, y) in samples) { + val id = Hilbert.zxyToTileId(z, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals(z, z2) + assertEquals(x, x2) + assertEquals(y, y2) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt new file mode 100644 index 00000000..45751d8d --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt @@ -0,0 +1,112 @@ +package org.appdevforall.maps.slicer + +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Covers [HttpRangeByteCache] — the process-wide in-memory LRU keyed by + * `(url, offset, length)`. Exercises get/put round-trip, the put-of-existing-key + * no-op, LRU eviction at the 8 MiB bound, and clear(). No network needed; the + * cache is a pure in-memory object. + * + * The cache is a process-wide singleton, so each test clears it in @Before and + * @After to stay deterministic regardless of run order. + */ +class HttpRangeByteCacheCoverageTest { + + @Before fun reset() = HttpRangeByteCache.clear() + + @After fun tearDown() = HttpRangeByteCache.clear() + + @Test + fun get_returns_null_when_absent() { + assertNull(HttpRangeByteCache.get("u", 0L, 16)) + } + + @Test + fun put_then_get_round_trips() { + val bytes = byteArrayOf(1, 2, 3, 4) + HttpRangeByteCache.put("u", 10L, 4, bytes) + assertArrayEquals(bytes, HttpRangeByteCache.get("u", 10L, 4)) + } + + @Test + fun key_distinguishes_url_offset_and_length() { + HttpRangeByteCache.put("a", 0L, 4, byteArrayOf(1)) + // Different url, offset, or length → distinct keys (all absent). + assertNull(HttpRangeByteCache.get("b", 0L, 4)) + assertNull(HttpRangeByteCache.get("a", 1L, 4)) + assertNull(HttpRangeByteCache.get("a", 0L, 5)) + } + + @Test + fun put_of_existing_key_is_a_no_op() { + val first = byteArrayOf(9, 9, 9) + HttpRangeByteCache.put("u", 0L, 3, first) + // Second put with the same key must NOT replace the stored value. + HttpRangeByteCache.put("u", 0L, 3, byteArrayOf(0, 0, 0)) + assertArrayEquals(first, HttpRangeByteCache.get("u", 0L, 3)) + } + + @Test + fun callers_editing_their_arrays_do_not_change_the_cache() { + val stored = byteArrayOf(1, 2, 3) + HttpRangeByteCache.put("u", 0L, 3, stored) + stored[0] = 9 + HttpRangeByteCache.get("u", 0L, 3)!![1] = 9 + assertArrayEquals(byteArrayOf(1, 2, 3), HttpRangeByteCache.get("u", 0L, 3)) + } + + @Test + fun re_putting_an_existing_key_marks_it_recently_used() { + val oneMib = 1024 * 1024 + for (i in 0 until 8) HttpRangeByteCache.put("u", i.toLong(), oneMib, ByteArray(oneMib)) + // Entry 0 is the coldest; a repeat put must promote it so entry 1 is evicted instead. + HttpRangeByteCache.put("u", 0L, oneMib, ByteArray(oneMib)) + HttpRangeByteCache.put("u", 8L, oneMib, ByteArray(oneMib)) + assertEquals(oneMib, HttpRangeByteCache.get("u", 0L, oneMib)?.size) + assertNull(HttpRangeByteCache.get("u", 1L, oneMib)) + } + + @Test + fun clear_drops_all_entries() { + HttpRangeByteCache.put("u", 0L, 2, byteArrayOf(1, 2)) + HttpRangeByteCache.clear() + assertNull(HttpRangeByteCache.get("u", 0L, 2)) + } + + @Test + fun eviction_kicks_out_cold_entries_past_8_mib() { + // MAX_BYTES is 8 MiB. Put nine 1 MiB entries: total 9 MiB forces eviction + // of the least-recently-used entries until total <= 8 MiB. + val oneMib = 1024 * 1024 + for (i in 0 until 9) { + HttpRangeByteCache.put("u", i.toLong(), oneMib, ByteArray(oneMib) { i.toByte() }) + } + // The first-inserted (coldest) entry should have been evicted. + assertNull(HttpRangeByteCache.get("u", 0L, oneMib)) + // The most-recently-inserted entry should still be present. + assertEquals(oneMib, HttpRangeByteCache.get("u", 8L, oneMib)?.size) + } + + @Test + fun get_promotes_to_most_recently_used_so_it_survives_eviction() { + val oneMib = 1024 * 1024 + // Seed entry 0, then fill toward the bound while periodically touching 0. + HttpRangeByteCache.put("u", 0L, oneMib, ByteArray(oneMib)) + for (i in 1 until 8) { + HttpRangeByteCache.put("u", i.toLong(), oneMib, ByteArray(oneMib)) + // Touch entry 0 to keep it hot (accessOrder=true promotes it). + HttpRangeByteCache.get("u", 0L, oneMib) + } + // One more insert pushes total past 8 MiB; entry 0, kept hot, should survive + // while a colder mid entry gets evicted. + HttpRangeByteCache.put("u", 8L, oneMib, ByteArray(oneMib)) + assertEquals(oneMib, HttpRangeByteCache.get("u", 0L, oneMib)?.size) + assertNull(HttpRangeByteCache.get("u", 1L, oneMib)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherShortBodyTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherShortBodyTest.kt new file mode 100644 index 00000000..8ab10ca1 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherShortBodyTest.kt @@ -0,0 +1,58 @@ +package org.appdevforall.maps.slicer + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Covers [HttpRangeFetcher] responses the size guards must refuse: a chunked 206 that ends + * short of the range, and a Range-ignoring 200 asked for an offset beyond what it can slice. + */ +class HttpRangeFetcherShortBodyTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + @Test + fun `a chunked 206 shorter than the range is refused and not cached`() { + server.enqueue(MockResponse().setResponseCode(206).setChunkedBody(Buffer().write(ByteArray(3)), 2)) + val url = server.url("/a.pmtiles").toString() + + val thrown = runCatching { HttpRangeFetcher(url, OkHttpClient()).readRange(0L, 8) }.exceptionOrNull() + + assertTrue("got $thrown", thrown is IllegalArgumentException) + assertEquals("Range response returned 3 bytes, expected 8", thrown!!.message) + assertNull(HttpRangeByteCache.get(url, 0L, 8)) + } + + @Test + fun `a 200 fallback cannot serve an offset beyond the int range`() { + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(ByteArray(16)))) + val url = server.url("/b.pmtiles").toString() + val offset = Int.MAX_VALUE.toLong() + 1 + + val thrown = runCatching { HttpRangeFetcher(url, OkHttpClient()).readRange(offset, 4) }.exceptionOrNull() + + assertTrue("got $thrown", thrown is IllegalArgumentException) + assertTrue(thrown!!.message!!.startsWith("200-OK fallback can't satisfy range $offset-")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt new file mode 100644 index 00000000..b9ea8af0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt @@ -0,0 +1,96 @@ +package org.appdevforall.maps.slicer + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [HttpRangeFetcher] size-guard arms `RangeFetcherCoverageTest` leaves open: + * + * - a 206 whose body is SHORTER than the requested range (truncated/buggy + * server) → the size `require` must reject it instead of returning a short + * array that would corrupt the sliced archive downstream; + * - a 200-OK fallback whose full body can't satisfy the requested range + * (range end past EOF) → the bounds `require` must reject instead of + * slicing out of bounds. + */ +class HttpRangeFetcherSizeGuardTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + private fun body(n: Int): Buffer = Buffer().write(ByteArray(n) { it.toByte() }) + + @Test + fun rejects_206_with_truncated_body() { + // Ask for 10 bytes; server 206s with only 5. Must throw, not hand back a + // short buffer (a silent short read would corrupt header/dir parsing). + server.enqueue(MockResponse().setResponseCode(206).setBody(body(5))) + val fetcher = HttpRangeFetcher(server.url("/a.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { + fetcher.readRange(0L, 10) + } + assertTrue(ex.message!!.contains("expected 10")) + } + + @Test + fun rejects_206_whose_declared_length_exceeds_the_range_before_reading_it() { + // Without the up-front check the whole body is buffered and only then compared. + server.enqueue(MockResponse().setResponseCode(206).setBody(body(1 shl 20))) + val fetcher = HttpRangeFetcher(server.url("/c.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 8) } + assertEquals("Range response returned a 1048576-byte body, expected 8", ex.message) + } + + @Test + fun rejects_chunked_206_after_reading_one_byte_past_the_range() { + // Chunked, so there is no Content-Length; the read itself must stop at length + 1. + server.enqueue(MockResponse().setResponseCode(206).setChunkedBody(body(1 shl 20), 4096)) + val fetcher = HttpRangeFetcher(server.url("/d.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 8) } + assertEquals("Range response returned more than 8 bytes, expected 8", ex.message) + } + + @Test + fun accepts_chunked_206_of_exactly_the_range() { + server.enqueue(MockResponse().setResponseCode(206).setChunkedBody(body(8), 3)) + val fetcher = HttpRangeFetcher(server.url("/e.pmtiles").toString(), RangeFetcher.defaultClient()) + + assertArrayEquals(ByteArray(8) { it.toByte() }, fetcher.readRange(0L, 8)) + } + + @Test + fun rejects_200_fallback_when_range_extends_past_eof() { + // Server ignores Range and 200s the whole 8-byte file; the requested range + // [4, 14) runs past EOF → the fallback bounds require must reject. + server.enqueue(MockResponse().setResponseCode(200).setBody(body(8))) + val fetcher = HttpRangeFetcher(server.url("/b.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { + fetcher.readRange(4L, 10) + } + assertTrue(ex.message!!.contains("can't satisfy range")) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt new file mode 100644 index 00000000..e52d4145 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt @@ -0,0 +1,197 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import kotlin.math.pow +import kotlin.system.measureTimeMillis + +/** + * Validation suite for the six representative NGO regions. + * + * **What this test does** (offline / always-on): + * - Computes `tilesInRegion(...)` for each region's bbox against the + * bundled Natural Earth z0-z4 archive. NE is global, so for any region + * we expect exactly the number of (z,x,y) tiles whose XY-rectangle + * overlaps the bbox at zooms 0..4. + * - Sanity-checks via the published-formula expected tile count: for + * each zoom z, `tile_count = (xMax-xMin+1) * (yMax-yMin+1)`. + * - Records slicer latency. + * + * **What this test does NOT do** (offline limitation): hit the real OSM + * archive on `iiab.switnet.org`. That archive is multi-hundred-MB; the + * test suite has to stay self-contained. The catalog estimate column + * ("~100-150 MB", etc.) refers to that real archive, not NE z0-z4. + * + * To validate against the **real catalog estimates**, run the + * `Maps:mapsOnlineSlicerSmoke` JUnit category test manually with network + * access — it's defined in [OnlineNgoRegionValidationTest]. + */ +class NgoRegionValidationTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + /** + * Six representative NGO regions. Bbox is (south, west, north, east). + */ + private val ngoRegions = listOf( + NgoRegion("coxs-bazar", "Cox's Bazar refugee camps, Bangladesh", + Bbox(21.10, 92.10, 21.30, 92.25)), + NgoRegion("kakuma", "Kakuma refugee camp, Kenya (Turkana)", + Bbox(3.65, 34.80, 3.85, 35.00)), + NgoRegion("goma", "Goma + Lake Kivu shore, DRC (North Kivu)", + Bbox(-1.80, 28.85, -1.50, 29.30)), + NgoRegion("port-au-prince","Port-au-Prince metro, Haiti", + Bbox(18.45, -72.45, 18.65, -72.20)), + NgoRegion("kathmandu", "Kathmandu Valley + districts, Nepal", + Bbox(27.55, 85.20, 27.85, 85.55)), + NgoRegion("maiduguri", "Maiduguri + Borno IDP camps, Nigeria", + Bbox(11.75, 13.05, 11.95, 13.30)), + ) + + private data class NgoRegion(val id: String, val displayName: String, val bbox: Bbox) + + @Test + fun slicer_runs_for_all_six_ngo_regions_offline() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + + println() + println("=== NGO region slicer validation (NE z0-z4 stand-in) ===") + println("%-18s %12s %10s %10s %s".format("region", "tiles", "bytes_kb", "latency_ms", "slippy_math")) + println("-".repeat(80)) + + for (region in ngoRegions) { + val expectedZxyCount = expectedSlippyTileCount(region.bbox, zoomMin = 0, zoomMax = 4) + val tiles: List + val latency = measureTimeMillis { + tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + } + val totalBytes = tiles.sumOf { it.byteLength } + println( + "%-18s %12d %10d %10d expected_slippy=%d".format( + region.id, + tiles.size, + totalBytes / 1024, + latency, + expectedZxyCount, + ) + ) + // NE z0-z4 doesn't guarantee a tile entry for every slippy tile + // (sea-only tiles may be absent), so we assert "we don't over- + // collect" — the slicer's tile count <= the expected slippy + // rectangle count, never above. + assert(tiles.size <= expectedZxyCount) { + "${region.id}: slicer returned ${tiles.size} tiles, expected at most $expectedZxyCount" + } + // Sanity: at least *some* tiles in every region (every NGO region + // has land coverage in NE). + assert(tiles.isNotEmpty()) { + "${region.id}: slicer returned zero tiles — bbox / Hilbert math wrong?" + } + // Latency budget on a local file: should be < 1s even worst-case. + assert(latency < 5000) { + "${region.id}: slicer took ${latency}ms — too slow" + } + } + println("-".repeat(80)) + } + + @Test + fun estimate_is_consistent_with_tile_byte_sum() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + for (region in ngoRegions) { + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + val estimate = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val sum = tiles.sumOf { it.byteLength } + // estimateRegionBytes adds a fixed 8KB overhead. + assert(estimate == sum + 8L * 1024L) { + "${region.id}: estimate $estimate != sum $sum + 8KB" + } + } + } + + @Test + fun downloadAndSlice_works_for_coxs_bazar_against_NE() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + val region = ngoRegions.first { it.id == "coxs-bazar" } + + // Read source header (parse from local file) + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val sourceHeader = PmtilesHeader.parse( + java.nio.ByteBuffer.wrap(headerBytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) + ) + + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assert(tiles.isNotEmpty()) { "Cox's Bazar should have at least 1 tile" } + + val tmp = File.createTempFile("coxs-bazar-", ".pmtiles") + tmp.deleteOnExit() + + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = sourceUrl, + sourceHeader = sourceHeader, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + + assert(tmp.exists() && tmp.length() > PmtilesV3.HEADER_BYTES) { + "sliced file should be created and non-trivial" + } + + // Re-slice the output — should yield the same tile count. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assert(resliced.size == tiles.size) { + "re-sliced count ${resliced.size} != original ${tiles.size}" + } + } + + /** + * Slippy-map expected count: `(xMax-xMin+1) × (yMax-yMin+1)` per zoom. + * Independent of any PMTiles content — pure math from the spec. + */ + private fun expectedSlippyTileCount(bbox: Bbox, zoomMin: Int, zoomMax: Int): Int { + var total = 0 + for (z in zoomMin..zoomMax) { + val n = 2.0.pow(z).toInt() + val xMin = Math.floor((bbox.west + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val xMax = Math.floor((bbox.east + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val latRadN = Math.toRadians(bbox.north.coerceIn(-85.0511, 85.0511)) + val latRadS = Math.toRadians(bbox.south.coerceIn(-85.0511, 85.0511)) + val yMin = Math.floor((1 - Math.log(Math.tan(latRadN) + 1.0 / Math.cos(latRadN)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + val yMax = Math.floor((1 - Math.log(Math.tan(latRadS) + 1.0 / Math.cos(latRadS)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + total += (xMax - xMin + 1) * (yMax - yMin + 1) + } + return total + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt new file mode 100644 index 00000000..283c91ae --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt @@ -0,0 +1,91 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Manually-invoked test that slices a real region from + * `iiab.switnet.org` to a file on disk. Used to seed a real + * `tiles.pmtiles` for end-to-end M8+M9 validation of the apply path. + * + * Opt in via: + * `./gradlew :testDebugUnitTest \ + * --tests 'org.appdevforall.maps.slicer.OnDemandRegionSlicingTest' \ + * -DrunOnlineSlicerTests=true \ + * -DsliceRegionId=coxs-bazar-test \ + * -DsliceOutputDir=/tmp/m9-validate/sliced \ + * -DsliceZoomMax=12` + * + * Produces `//tiles.pmtiles`. The bbox is fixed to + * Cox's Bazar's hot-zone (z 6-12 by default for fast iteration). + */ +class OnDemandRegionSlicingTest { + + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + + @Test + fun slice_cox_bazar_to_disk() = runBlocking { + assumeTrue( + "Online slicer tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true", + ) + val regionId = System.getProperty("sliceRegionId") ?: "coxs-bazar-real" + val outputDir = File( + System.getProperty("sliceOutputDir") ?: "/tmp/m9-validate/sliced", + ).apply { mkdirs() } + val zoomMax = (System.getProperty("sliceZoomMax") ?: "12").toInt() + + val regionDir = File(outputDir, regionId).apply { mkdirs() } + val target = File(regionDir, "tiles.pmtiles") + // Cox's Bazar refugee camps, small bbox. + val bbox = Bbox(21.10, 92.10, 21.30, 92.25) + val zoomMin = 6 + + println("Slicing $regionId from $osmVectorUrl → $target (z $zoomMin-$zoomMax)") + // Open a single fetcher; read the header from it ourselves so the + // downloadAndSlice call has the right PmtilesHeader instance. + val fetcher = RangeFetcher.forUrl(osmVectorUrl) + val header = fetcher.use { f -> + val headerBytes = f.readRange(0L, PmtilesV3.HEADER_BYTES) + PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + } + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + ).getOrThrow() + println("Total tiles to fetch: ${tiles.size}") + + val result = PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = osmVectorUrl, + sourceHeader = header, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + targetFile = target, + onProgress = { downloaded, total -> + val pct = if (total > 0) downloaded * 100 / total else 0 + if (downloaded == 0L || downloaded == total) { + println(" progress: $downloaded / $total ($pct%)") + } + }, + ) + result.getOrThrow() + println("DONE: ${target.absolutePath} (${target.length()} bytes)") + // Don't assert size — Cox's Bazar at z 6-12 is much smaller than full + // 6-14 catalog estimate. + assert(target.length() > 1024L) { + "Expected sliced file >1 KB; got ${target.length()}" + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt new file mode 100644 index 00000000..d344afbe --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt @@ -0,0 +1,130 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.system.measureTimeMillis + +/** + * End-to-end throughput verification against the real IIAB OSM PMTiles archive. + * + * Runs an actual `downloadAndSlice` of a small Cox's Bazar slice (z6-z12 ≈ + * a few MB) and asserts: + * - the resulting file is a valid PMTiles v3 archive, + * - re-slicing it returns the same tile count (round-trip correctness), + * - throughput is at least 1 MB/s (Bryan's "few MB/s" target floor). + * + * **Opt-in**: only runs when `-DrunOnlineSlicerTests=true` is passed. CI + * shouldn't hit iiab.switnet.org by default. + * + * If throughput is below the floor: + * - check the network path to iiab.switnet.org (a slow public internet hop + * genuinely caps us); + * - re-check the coalescing constants in PmtilesRegionSlicer + * (COALESCE_GAP_BYTES, MAX_CHUNK_BYTES, PARALLEL_FETCHES); + * - confirm OkHttpClient's dispatcher/connectionPool tuning in + * RegionDownloader hasn't regressed. + */ +class OnlineDownloadThroughputTest { + + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + + @Test + fun coxs_bazar_z6_z14_downloads_at_or_above_throughput_floor() = runBlocking { + assumeTrue( + "Online tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true" + ) + + // Cox's Bazar z6-z14 is the smallest "realistic" region + // (catalog estimate 100-150 MB). Big enough for + // bandwidth to dominate the fixed HTTP-setup cost so the throughput + // number is meaningful. + val bbox = Bbox(21.10, 92.10, 21.30, 92.25) + val zMin = 6 + val zMax = 14 + + // Stage 1: discover tiles + read source header. Not on the throughput + // clock — this is a fixed-cost setup. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + ).getOrThrow() + assertTrue("expected tiles for Cox's Bazar z$zMin-z$zMax", tiles.isNotEmpty()) + val totalTileBytes = tiles.sumOf { it.byteLength } + // Diagnostic: how many fetch chunks does coalescing produce? Big tile + // count + small chunk count means coalescing collapsed a lot of + // adjacent ranges into single requests, which is the win we want. + val uniqueBlobs = tiles + .map { it.byteOffset to it.byteLength } + .distinct() + .sortedBy { it.first } + val chunks = PmtilesRegionSlicer.coalesceBlobs( + uniqueBlobs, + PmtilesRegionSlicer.COALESCE_GAP_BYTES, + ).flatMap { PmtilesRegionSlicer.splitOversizedChunk(it, PmtilesRegionSlicer.MAX_CHUNK_BYTES) } + println("[throughput-test] Cox's Bazar z$zMin-z$zMax: ${tiles.size} tiles, " + + "${uniqueBlobs.size} unique blobs, ${chunks.size} fetch chunks, " + + "${totalTileBytes / 1024L} KB total") + + // Fetch the source header once (downloadAndSlice needs it as input). + val headerBytes = run { + val fetcher = RangeFetcher.forUrl(osmVectorUrl) + try { fetcher.readRange(0L, PmtilesV3.HEADER_BYTES) } finally { fetcher.close() } + } + val sourceHeader = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + + // Stage 2: timed download. + val tmp = File.createTempFile("throughput-test-", ".pmtiles") + tmp.deleteOnExit() + val downloadMs = measureTimeMillis { + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = osmVectorUrl, + sourceHeader = sourceHeader, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + } + val slicedBytes = tmp.length() + val throughputMbPerSec = (slicedBytes.toDouble() / (1024.0 * 1024.0)) / + (downloadMs.toDouble() / 1000.0) + + println("[throughput-test] downloaded ${slicedBytes / 1024L} KB in ${downloadMs}ms = %.2f MB/s" + .format(throughputMbPerSec)) + + // Correctness: round-trip the sliced file. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + ).getOrThrow() + assertTrue( + "round-trip tile count: sliced ${tiles.size}, resliced ${resliced.size}", + resliced.size == tiles.size, + ) + + // Throughput floor: 1 MB/s. Bryan's target is "few MB/s"; 1 MB/s is + // the floor we should easily clear on any reasonable internet path + // to iiab.switnet.org once the parallel slicer is in place. + assertTrue( + "throughput %.2f MB/s is below 1 MB/s floor for Cox's Bazar z6-z12" + .format(throughputMbPerSec), + throughputMbPerSec >= 1.0, + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt new file mode 100644 index 00000000..e865140e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt @@ -0,0 +1,105 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import kotlin.system.measureTimeMillis + +/** + * Validates the slicer against the **real** OSM PMTiles archive at + * `https://iiab.switnet.org/maps/2/`. Compares the slicer's byte-sum + * estimate against the per-region catalog estimates. + * + * **Opt-in**: this test only runs when invoked with + * `-DrunOnlineSlicerTests=true` + * + * Reason: the test hits the public iiab.switnet.org server with several + * range requests per region, which: + * 1. Requires network connectivity (not always available in CI), + * 2. Costs ~100 KB-1 MB per region in directory bytes, + * 3. Could rate-limit if hammered. + * + * For Cox's Bazar (smallest region) the slicer should return ~5-30 MB + * of tile bytes at z 6-14. Kathmandu (largest) ~180-280 MB. Catalog + * estimates have a documented ±50% tolerance. + */ +class OnlineNgoRegionValidationTest { + + /** + * Public IIAB OSM archive (OpenStreetMap / OpenMapTiles vector). + * + * 2026-10-01 = fallback date used by RegionDownloader; if iiab.switnet.org + * has rotated to a newer dated file, this test will return 404 and we'll + * need to refresh the constant. + */ + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + + /** Catalog estimates (low-MB, high-MB), z0-z14 vector. */ + private val ngoRegions = listOf( + OnlineRegion("coxs-bazar", Bbox(21.10, 92.10, 21.30, 92.25), 100, 150), + OnlineRegion("kakuma", Bbox(3.65, 34.80, 3.85, 35.00), 120, 180), + OnlineRegion("goma", Bbox(-1.80, 28.85, -1.50, 29.30), 250, 350), + OnlineRegion("port-au-prince",Bbox(18.45, -72.45, 18.65, -72.20), 200, 300), + OnlineRegion("kathmandu", Bbox(27.55, 85.20, 27.85, 85.55), 180, 280), + OnlineRegion("maiduguri", Bbox(11.75, 13.05, 11.95, 13.30), 150, 250), + ) + + private data class OnlineRegion( + val id: String, + val bbox: Bbox, + val expectedLowMb: Int, + val expectedHighMb: Int, + ) + + @Test + fun slicer_estimates_match_catalog_for_all_six_regions() = runBlocking { + assumeTrue( + "Online slicer tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true" + ) + + println() + println("=== Online NGO region slicer validation (iiab.switnet.org OSM) ===") + println("URL: $osmVectorUrl") + println("%-18s %10s %12s %16s %s".format("region", "tiles", "est_MB", "catalog_MB", "verdict")) + println("-".repeat(80)) + + for (region in ngoRegions) { + val tiles: List + val latencyMs = measureTimeMillis { + tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = region.bbox, + zoomMin = 6, + zoomMax = 14, + ).getOrThrow() + } + val estimateBytes = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val estimateMb = estimateBytes / (1024.0 * 1024.0) + + // ±50% tolerance per the plan ("Flag deltas > 50% in either + // direction"). Catalog has a low-high range already; we widen by + // 50% on each side as the merge of catalog spread and slicer + // estimation error. + val lowBound = region.expectedLowMb * 0.5 + val highBound = region.expectedHighMb * 1.5 + val withinTolerance = estimateMb in lowBound..highBound + val verdict = if (withinTolerance) "OK" else "OUT-OF-BAND" + + println( + "%-18s %10d %12.1f %4d-%-4d MB %s (%dms)".format( + region.id, + tiles.size, + estimateMb, + region.expectedLowMb, + region.expectedHighMb, + verdict, + latencyMs, + ) + ) + } + println("-".repeat(80)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt new file mode 100644 index 00000000..364a5d3e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt @@ -0,0 +1,149 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [PmtilesDirectory] (parse/serialize/compress) and + * [DirectoryEntry.isLeafPointer]. Exercises the offset-delta branches + * (rawOff == 0 at i==0, rawOff == 0 at i>0, and the explicit-offset arm), + * the empty-directory short-circuit, the serialize sort-order guard, and + * the compression `when` arms including the unsupported-codec `else`. + */ +class PmtilesDirectoryCoverageTest { + + private fun rawBuf(bytes: ByteArray): ByteBuffer = + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + + @Test + fun parse_empty_directory_returns_empty_list() { + val out = ByteArrayOutputStream() + Varint.encode(0L, out) // n = 0 + assertTrue(PmtilesDirectory.parse(rawBuf(out.toByteArray())).isEmpty()) + } + + @Test + fun serialize_then_parse_roundtrips_contiguous_entries() { + // Two contiguous entries: entry[1].offset == entry[0].offset + entry[0].length, + // which serialize encodes as the implicit "0" offset, and parse decodes back + // through the `rawOff == 0L && i > 0` arm. + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 200L, offset = 100L), + ) + val bytes = PmtilesDirectory.serialize(entries) + val parsed = PmtilesDirectory.parse(rawBuf(bytes)) + assertEquals(entries, parsed) + } + + @Test + fun serialize_then_parse_roundtrips_noncontiguous_entries() { + // entry[1].offset != entry[0].offset + length -> explicit (offset+1) encoding, + // exercising the `else -> rawOff - 1L` parse arm and the serialize else arm. + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 7L, runLength = 1L, length = 50L, offset = 500L), + ) + val bytes = PmtilesDirectory.serialize(entries) + val parsed = PmtilesDirectory.parse(rawBuf(bytes)) + assertEquals(entries, parsed) + } + + @Test + fun parse_first_entry_raw_zero_offset_decodes_to_zero() { + // Hand-built single-entry directory whose raw offset varint is literally 0 + // at i == 0 -> exercises the parse `rawOff == 0L -> 0L` (i==0) arm directly, + // which serialize never emits (it always writes offset+1 for the first entry). + val out = ByteArrayOutputStream() + Varint.encode(1L, out) // n = 1 + Varint.encode(3L, out) // ids[0] delta = 3 -> tileId 3 + Varint.encode(1L, out) // runs[0] = 1 + Varint.encode(10L, out) // lens[0] = 10 + Varint.encode(0L, out) // raw offset = 0 at i==0 + val parsed = PmtilesDirectory.parse(rawBuf(out.toByteArray())) + assertEquals(1, parsed.size) + assertEquals(3L, parsed.single().tileId) + assertEquals(0L, parsed.single().offset) + } + + @Test + fun serialize_rejects_unsorted_entries() { + val entries = listOf( + DirectoryEntry(tileId = 10L, runLength = 1L, length = 10L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 10L, offset = 10L), + ) + assertThrows(IllegalArgumentException::class.java) { + PmtilesDirectory.serialize(entries) + } + } + + @Test + fun maybeCompress_none_is_identity() { + val raw = byteArrayOf(1, 2, 3, 4) + assertEquals(raw.toList(), PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_NONE).toList()) + } + + @Test + fun maybeCompress_then_decompress_gzip_roundtrips() { + val raw = ByteArray(256) { (it % 7).toByte() } + val gz = PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_GZIP) + val back = PmtilesDirectory.maybeDecompress(gz, PmtilesV3.COMPRESSION_GZIP) + assertEquals(raw.toList(), back.toList()) + } + + @Test + fun maybeDecompress_none_is_identity() { + val raw = byteArrayOf(9, 8, 7) + assertEquals(raw.toList(), PmtilesDirectory.maybeDecompress(raw, PmtilesV3.COMPRESSION_NONE).toList()) + } + + @Test + fun maybeCompress_rejects_unsupported_codec() { + assertThrows(IllegalStateException::class.java) { + PmtilesDirectory.maybeCompress(byteArrayOf(1), PmtilesV3.COMPRESSION_BROTLI) + } + } + + @Test + fun maybeDecompress_rejects_unsupported_codec() { + assertThrows(IllegalStateException::class.java) { + PmtilesDirectory.maybeDecompress(byteArrayOf(1), PmtilesV3.COMPRESSION_ZSTD) + } + } + + @Test + fun parseCompressed_handles_gzip_blob() { + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 200L, offset = 100L), + ) + val raw = PmtilesDirectory.serialize(entries) + val gz = PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_GZIP) + assertEquals(entries, PmtilesDirectory.parseCompressed(gz, PmtilesV3.COMPRESSION_GZIP)) + } + + @Test + fun parseCompressed_handles_uncompressed_blob() { + val entries = listOf( + DirectoryEntry(tileId = 1L, runLength = 1L, length = 10L, offset = 0L), + ) + val raw = PmtilesDirectory.serialize(entries) + assertEquals(entries, PmtilesDirectory.parseCompressed(raw, PmtilesV3.COMPRESSION_NONE)) + } + + @Test + fun directoryEntry_isLeafPointer_true_when_runLength_zero() { + assertTrue(DirectoryEntry(tileId = 0L, runLength = 0L, length = 0L, offset = 0L).isLeafPointer()) + } + + @Test + fun directoryEntry_isLeafPointer_false_when_runLength_nonzero() { + assertFalse(DirectoryEntry(tileId = 0L, runLength = 1L, length = 0L, offset = 0L).isLeafPointer()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt new file mode 100644 index 00000000..263160db --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt @@ -0,0 +1,184 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [PmtilesHeader] and its companion `parse`: + * the `require` guards (too-small buffer, bad magic, bad version) plus the + * accessor branches (`isClustered` true/false, lon/lat conversions). Builds + * 127-byte header buffers by hand rather than relying on the bundled archive. + */ +class PmtilesHeaderCoverageTest { + + /** Construct a synthetic, well-formed 127-byte v3 header buffer. */ + private fun headerBytes( + version: Byte = PmtilesV3.VERSION, + magic: ByteArray = PmtilesV3.MAGIC, + clustered: Byte = 1, + minLonE7: Int = -10_000_000, + minLatE7: Int = 5_000_000, + maxLonE7: Int = 20_000_000, + maxLatE7: Int = 30_000_000, + ): ByteBuffer { + val buf = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(magic) + buf.put(version) + buf.putLong(127L) // rootDirOffset + buf.putLong(10L) // rootDirBytes + buf.putLong(200L) // jsonMetadataOffset + buf.putLong(20L) // jsonMetadataBytes + buf.putLong(300L) // leafDirsOffset + buf.putLong(30L) // leafDirsBytes + buf.putLong(400L) // tileDataOffset + buf.putLong(40L) // tileDataBytes + buf.putLong(50L) // addressedTilesCount + buf.putLong(60L) // tileEntriesCount + buf.putLong(70L) // tileContentsCount + buf.put(clustered) + buf.put(PmtilesV3.COMPRESSION_GZIP) // internalCompression + buf.put(PmtilesV3.COMPRESSION_NONE) // tileCompression + buf.put(PmtilesV3.TYPE_MVT) // tileType + buf.put(0) // minZoom + buf.put(4) // maxZoom + buf.putInt(minLonE7) + buf.putInt(minLatE7) + buf.putInt(maxLonE7) + buf.putInt(maxLatE7) + buf.put(2) // centerZoom + buf.putInt(1_000_000) // centerLonE7 + buf.putInt(2_000_000) // centerLatE7 + require(buf.position() == PmtilesV3.HEADER_BYTES) + buf.flip() + return buf + } + + @Test + fun parses_well_formed_header_and_exposes_fields() { + val header = PmtilesHeader.parse(headerBytes()) + assertEquals(PmtilesV3.VERSION, header.version) + assertEquals(127L, header.rootDirOffset) + assertEquals(400L, header.tileDataOffset) + assertEquals(PmtilesV3.TYPE_MVT, header.tileType) + } + + @Test + fun isClustered_true_when_flag_is_one() { + assertTrue(PmtilesHeader.parse(headerBytes(clustered = 1)).isClustered()) + } + + @Test + fun isClustered_false_when_flag_is_zero() { + assertFalse(PmtilesHeader.parse(headerBytes(clustered = 0)).isClustered()) + } + + @Test + fun lon_lat_conversions_divide_by_1e7() { + val header = PmtilesHeader.parse( + headerBytes( + minLonE7 = -10_000_000, + minLatE7 = 5_000_000, + maxLonE7 = 20_000_000, + maxLatE7 = 30_000_000, + ) + ) + assertEquals(-1.0, header.minLon(), 1e-9) + assertEquals(0.5, header.minLat(), 1e-9) + assertEquals(2.0, header.maxLon(), 1e-9) + assertEquals(3.0, header.maxLat(), 1e-9) + } + + @Test + fun parse_rejects_buffer_too_small() { + val tiny = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES - 1).order(ByteOrder.LITTLE_ENDIAN) + assertThrows(IllegalArgumentException::class.java) { PmtilesHeader.parse(tiny) } + } + + @Test + fun parse_rejects_bad_magic() { + val badMagic = byteArrayOf( + 'X'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), + 'i'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(), 's'.code.toByte(), + ) + assertThrows(IllegalArgumentException::class.java) { + PmtilesHeader.parse(headerBytes(magic = badMagic)) + } + } + + @Test + fun parse_rejects_unsupported_version() { + assertThrows(IllegalArgumentException::class.java) { + PmtilesHeader.parse(headerBytes(version = 2)) + } + } + + @Test + fun toByteArray_roundtrips_through_parse() { + val original = PmtilesHeader.parse(headerBytes()) + val reparsed = PmtilesHeader.parse( + ByteBuffer.wrap(original.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(original, reparsed) + } + + /** + * Build a header buffer with explicit tile-type and internal/tile compression + * bytes so the decode of those fields is exercised for non-default values + * (the default fixture always uses WEBP-less MVT + gzip/none). + */ + private fun headerBytesWithCodes( + internalCompression: Byte, + tileCompression: Byte, + tileType: Byte, + ): ByteBuffer { + val buf = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(PmtilesV3.MAGIC) + buf.put(PmtilesV3.VERSION) + repeat(11) { buf.putLong(0L) } // the 11 long fields — values irrelevant here + buf.put(1) // clustered + buf.put(internalCompression) + buf.put(tileCompression) + buf.put(tileType) + buf.put(0) // minZoom + buf.put(4) // maxZoom + buf.putInt(0) // minLonE7 + buf.putInt(0) // minLatE7 + buf.putInt(0) // maxLonE7 + buf.putInt(0) // maxLatE7 + buf.put(0) // centerZoom + buf.putInt(0) // centerLonE7 + buf.putInt(0) // centerLatE7 + require(buf.position() == PmtilesV3.HEADER_BYTES) + buf.flip() + return buf + } + + @Test + fun parse_decodes_png_tiles_with_brotli_and_none_codes() { + // A non-MVT tile type plus brotli internal-compression / none + // tile-compression — distinct byte values from the WEBP-gzip default, + // confirming the per-byte field decode (offsets 97/98/99) is correct. + val header = PmtilesHeader.parse( + headerBytesWithCodes( + internalCompression = PmtilesV3.COMPRESSION_BROTLI, + tileCompression = PmtilesV3.COMPRESSION_NONE, + tileType = PmtilesV3.TYPE_PNG, + ) + ) + assertEquals(PmtilesV3.TYPE_PNG, header.tileType) + assertEquals(PmtilesV3.COMPRESSION_BROTLI, header.internalCompression) + assertEquals(PmtilesV3.COMPRESSION_NONE, header.tileCompression) + // These three bytes survive a serialize round-trip unchanged. + val reparsed = PmtilesHeader.parse( + ByteBuffer.wrap(header.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(PmtilesV3.TYPE_PNG, reparsed.tileType) + assertEquals(PmtilesV3.COMPRESSION_BROTLI, reparsed.internalCompression) + assertEquals(PmtilesV3.COMPRESSION_NONE, reparsed.tileCompression) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt new file mode 100644 index 00000000..5d9c2656 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt @@ -0,0 +1,47 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class PmtilesHeaderTest { + + private val bundledNe: File = File( + // Path used at test-time — the bundled NE archive ships under + // maps-plugin/src/main/assets/maps/. Tests run from the maps-plugin + // module root. + "src/main/assets/maps/natural-earth-z0-z4.pmtiles" + ) + + @Test + fun parses_bundled_natural_earth_header() { + assertTrue("Bundled NE PMTiles missing at ${bundledNe.absolutePath}", bundledNe.exists()) + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val header = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("version", 3.toByte(), header.version) + assertEquals("root_dir_offset", 127L, header.rootDirOffset) + // Tile type 4 = WEBP per the bundled NE archive. + assertEquals("tile type", PmtilesV3.TYPE_WEBP, header.tileType) + // Internal compression for directories — gzip per planetiler defaults. + assertEquals("internal_compression", PmtilesV3.COMPRESSION_GZIP, header.internalCompression) + assertEquals("min_zoom", 0.toByte(), header.minZoom) + assertEquals("max_zoom", 4.toByte(), header.maxZoom) + } + + @Test + fun roundtrips_serialize_then_parse() { + assertTrue(bundledNe.exists()) + val original = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val header = PmtilesHeader.parse( + ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN) + ) + val reserialized = header.toByteArray() + assertArrayEquals(original, reserialized) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt new file mode 100644 index 00000000..d82a5b34 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt @@ -0,0 +1,143 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the coalesce + split helpers that drive the parallel + * region-download path. Keep these pure-Kotlin so they run without a JVM + * test fixture and stay fast. + * + * Each blob is `(offset, length)`. Tests assert that: + * 1. The coalesced chunks cover exactly the same byte ranges as the input + * blobs (no over-fetching beyond the requested gap budget). + * 2. Every input blob still appears exactly once in some chunk's `blobs`. + * 3. Chunks honor the `maxBytes` cap after splitting. + */ +class PmtilesRegionSlicerCoalesceTest { + + @Test + fun coalesceBlobs_empty_input_returns_empty() { + assertEquals(emptyList(), + PmtilesRegionSlicer.coalesceBlobs(emptyList(), 64)) + } + + @Test + fun coalesceBlobs_single_blob_returns_single_chunk() { + val out = PmtilesRegionSlicer.coalesceBlobs(listOf(100L to 50L), 64) + assertEquals(1, out.size) + assertEquals(100L, out[0].offset) + assertEquals(50L, out[0].length) + assertEquals(listOf(100L to 50L), out[0].blobs) + } + + @Test + fun coalesceBlobs_adjacent_blobs_with_zero_gap_merge() { + // [0..100) [100..200) — zero gap, must merge into [0..200) holding both. + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 100L to 100L), + 64, + ) + assertEquals(1, out.size) + assertEquals(0L, out[0].offset) + assertEquals(200L, out[0].length) + assertEquals(2, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_small_gap_within_threshold_merges() { + // [0..100) [150..250) — gap=50 ≤ maxGap=64, must merge into [0..250). + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 150L to 100L), + 64, + ) + assertEquals(1, out.size) + assertEquals(0L, out[0].offset) + assertEquals(250L, out[0].length) + assertEquals(2, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_gap_above_threshold_splits() { + // [0..100) [200..300) — gap=100 > maxGap=64, must produce 2 chunks. + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 200L to 100L), + 64, + ) + assertEquals(2, out.size) + assertEquals(0L, out[0].offset) + assertEquals(100L, out[0].length) + assertEquals(200L, out[1].offset) + assertEquals(100L, out[1].length) + } + + @Test + fun coalesceBlobs_real_world_clustered_pattern_merges_aggressively() { + // A typical PMTiles cluster: ~20 tiles, each ~5 KB, packed back-to-back + // with occasional 1-2 KB gaps from dedup'd shared blobs. + val blobs = buildList { + var off = 1_000_000L + repeat(20) { + add(off to 5000L) + off += 5000L + 1000L // 1 KB gap between each + } + } + val out = PmtilesRegionSlicer.coalesceBlobs(blobs, 64L * 1024L) + // Gap (1 KB) well below 64 KB threshold — all should merge. + assertEquals("all 20 blobs should merge into a single chunk", 1, out.size) + assertEquals(20, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_preserves_total_blob_byte_count() { + val blobs = listOf( + 10L to 100L, 200L to 50L, // gap=90, merges (≤64? no) + 10_000L to 200L, 10_300L to 100L, // gap=100, merges? no with 64KB + ) + // With maxGap=200: first two merge (gap=90), third+fourth merge (gap=100), + // middle gap between groups = 9750, won't merge. + val out = PmtilesRegionSlicer.coalesceBlobs(blobs, 200L) + val totalBlobBytes = blobs.sumOf { it.second } + val totalOutBytes = out.sumOf { chunk -> chunk.blobs.sumOf { it.second } } + assertEquals("blob-byte sum preserved across coalescing", totalBlobBytes, totalOutBytes) + val totalOutBlobs = out.sumOf { it.blobs.size } + assertEquals("each blob appears exactly once", blobs.size, totalOutBlobs) + } + + @Test + fun splitOversizedChunk_no_op_when_within_bound() { + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 1000L, listOf(0L to 1000L)) + val out = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4096L) + assertEquals(1, out.size) + assertEquals(chunk, out[0]) + } + + @Test + fun splitOversizedChunk_splits_large_chunk_into_bounded_parts() { + // 10 blobs of 1 MB each, packed back-to-back. Total = 10 MB. + // maxBytes = 4 MB → must produce ≥3 parts, each ≤ 4 MB. + val blobs = (0 until 10).map { (it * 1_000_000L) to 1_000_000L } + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 10_000_000L, blobs) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("at least 3 parts for 10MB/4MB cap", parts.size >= 3) + for (p in parts) { + assertTrue("part length ${p.length} must be ≤ maxBytes", p.length <= 4_000_000L) + } + // Every blob should appear in exactly one part. + val allBlobsInParts = parts.flatMap { it.blobs } + assertEquals(blobs.size, allBlobsInParts.size) + assertEquals(blobs.toSet(), allBlobsInParts.toSet()) + } + + @Test + fun splitOversizedChunk_keeps_oversized_single_blob_intact() { + // One 10 MB blob (atypical but possible). Must NOT be silently dropped + // even though it exceeds maxBytes — return it as its own part. + val blob = 0L to 10_000_000L + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 10_000_000L, listOf(blob)) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertEquals(1, parts.size) + assertEquals(blob, parts[0].blobs[0]) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt new file mode 100644 index 00000000..0841fb97 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt @@ -0,0 +1,642 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch-coverage tests for [PmtilesRegionSlicer]'s orchestrator paths that the + * happy-path slicer tests don't reach: + * + * - `tilesInRegion` zoom-clip-to-empty (`zMin > zMax`) and `runCatching` failure. + * - `tilesInRegionImpl` `require(...)` precondition branches. + * - `tileIdRangesForBbox` empty / single-tile / multi-zoom branches. + * - `splitOversizedChunk` mid-stream "close out a non-empty part before a blob + * that would overflow" branch. + * - `downloadAndSlice` empty-tile-list precondition + dedup/run-length path. + * - `walkEntries` leaf-pointer arm — leaf fetch + recursion + per-leaf merge + * AND the non-overlapping-leaf `continue` — via a synthetic TWO-level archive + * (the bundled NE archive has `leafDirsBytes==0`, so its directory is flat and + * those branches are otherwise unreachable offline). + * + * Everything runs wholly offline against the bundled Natural Earth z0-z4 archive + * (same source the existing slicer tests load), pure-Kotlin helper inputs, and a + * hand-built leaf-bearing PMTiles fixture written to a temp file. + */ +class PmtilesRegionSlicerCoverageTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + private val tempFiles = mutableListOf() + + @After + fun cleanup() { + tempFiles.forEach { f -> + f.delete() + File(f.parentFile, f.name + ".partial").delete() + } + tempFiles.clear() + } + + private fun sourceUrl(): String = "file://" + bundledNe.absolutePath + + private fun sourceHeader(): PmtilesHeader { + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + return PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + } + + // ---------- tilesInRegion / tilesInRegionImpl branches ---------- + + @Test + fun tilesInRegion_zooms_above_archive_max_clip_to_empty() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + // NE z0-z4 has maxZoom=4. Requesting zoomMin=10..zoomMax=10 means after + // clipping zMin=10, zMax=min(10,4)=4 → zMin > zMax → emptyList branch. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 10, + zoomMax = 10, + ).getOrThrow() + assertTrue("zooms entirely above archive max produce no tiles", tiles.isEmpty()) + } + + @Test + fun tilesInRegion_bad_url_returns_failure_result() = runBlocking { + assumeTrue(bundledNe.exists()) + // A file:// URL pointing at a nonexistent path makes RangeFetcher throw, + // which runCatching captures → Result.isFailure (the tilesInRegion$2 + // failure lambda). + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file:///definitely/not/a/real/path/nope.pmtiles", + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ) + assertTrue("nonexistent source must yield a failure Result", result.isFailure) + } + + @Test + fun tilesInRegionImpl_rejects_zoomMin_out_of_range() = runBlocking { + assumeTrue(bundledNe.exists()) + // zoomMin=21 violates require(zoomMin in 0..20). runCatching wraps the + // IllegalArgumentException into a failure Result. + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 21, + zoomMax = 21, + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun tilesInRegionImpl_rejects_zoomMax_below_zoomMin() = runBlocking { + assumeTrue(bundledNe.exists()) + // zoomMax(0) < zoomMin(3) violates require(zoomMax in zoomMin..20). + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 0, + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun tilesInRegion_single_zoom_in_archive_range_returns_tiles() = runBlocking { + assumeTrue(bundledNe.exists()) + // z2 only — exercises the zMin..zMax single-zoom associateWith path and + // the non-leaf inline tile-collection branch in walkEntries. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 2, + zoomMax = 2, + ).getOrThrow() + assertTrue("z2 worldwide returns tiles", tiles.isNotEmpty()) + assertTrue("all tiles are z2", tiles.all { it.z == 2 }) + } + + @Test + fun tilesInRegion_tiny_bbox_filters_to_small_subset() = runBlocking { + assumeTrue(bundledNe.exists()) + // A tiny bbox at a single point exercises tileIntersectsBbox's filter + // (most tiles continue-skipped) and tileIdRangesForBbox's single-tile + // ranges across z0..z4. + val tiny = Bbox(0.0, 0.0, 0.001, 0.001) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = tiny, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + // At most one tile per zoom level (z0..z4 = 5). + assertTrue("tiny bbox collects few tiles, got ${tiles.size}", tiles.size in 1..6) + } + + @Test + fun tilesInRegion_wide_thin_latitude_band_rejects_gap_tiles_by_bbox() = runBlocking { + assumeTrue(bundledNe.exists()) + // A full-longitude, narrow-latitude band at z3..z4 is NOT a + // Hilbert-contiguous region: the Hilbert query returns id-ranges that + // weave through OTHER latitude rows, so some candidate tile-ids pass the + // per-zoom id-range pre-filter yet land in rows the band doesn't touch. + // Those candidates reach tileIntersectsBbox and must be rejected by its + // latitude conditions (`tileNorth > bbox.south` / `tileSouth < bbox.north` + // false). Proof the filter ran: every returned tile genuinely intersects + // the band, and the count is a strict subset of the full-world count. + val band = Bbox(south = -1.0, west = -180.0, north = 1.0, east = 180.0) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = band, + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("band intersects the archive → some tiles", tiles.isNotEmpty()) + // Every surviving tile must actually overlap the band in latitude. + for (t in tiles) { + val n = Math.pow(2.0, t.z.toDouble()) + val tileNorth = tileLat(t.y, n) + val tileSouth = tileLat(t.y + 1, n) + assertTrue( + "tile z${t.z}/${t.x}/${t.y} (lat [$tileSouth,$tileNorth]) must overlap band", + tileNorth > band.south && tileSouth < band.north, + ) + } + // A thin band is a strict subset of the whole world at the same zooms. + val whole = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("band drops some tiles vs whole world", tiles.size < whole.size) + } + + @Test + fun tilesInRegion_tall_thin_longitude_band_rejects_gap_tiles_by_bbox() = runBlocking { + assumeTrue(bundledNe.exists()) + // The mirror of the latitude-band case: a full-latitude, narrow-longitude + // strip forces tileIntersectsBbox's LONGITUDE conditions + // (`tileEast > bbox.west` / `tileWest < bbox.east` false) to fire for the + // Hilbert-gap candidates that live in other longitude columns. + val strip = Bbox(south = -84.0, west = -1.0, north = 84.0, east = 1.0) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = strip, + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("strip intersects the archive → some tiles", tiles.isNotEmpty()) + for (t in tiles) { + val n = Math.pow(2.0, t.z.toDouble()) + val tileWest = t.x / n * 360.0 - 180.0 + val tileEast = (t.x + 1) / n * 360.0 - 180.0 + assertTrue( + "tile z${t.z}/${t.x}/${t.y} (lon [$tileWest,$tileEast]) must overlap strip", + tileEast > strip.west && tileWest < strip.east, + ) + } + val whole = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("strip drops some tiles vs whole world", tiles.size < whole.size) + } + + /** Slippy-map tile north/south latitude (degrees) for tile row [y] on an n×n grid. */ + private fun tileLat(y: Int, n: Double): Double { + val latRad = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * y / n))) + return Math.toDegrees(latRad) + } + + // ---------- tileIdRangesForBbox branches ---------- + + @Test + fun tileIdRangesForBbox_single_tile_at_z0() { + // z0 has exactly one tile (0,0). Range list is non-empty and covers + // tile-id 0 (base accumulate(0)=0). + val ranges = TileMath.tileIdRangesForBbox( + Bbox(-85.0, -180.0, 85.0, 180.0), 0 + ) + assertEquals(1, ranges.size) + assertTrue("z0 range covers tile-id 0", 0L in ranges[0]) + } + + @Test + fun tileIdRangesForBbox_full_world_multi_tile_at_z2() { + // z2 full world: xMin=0..xMax=3, yMin..yMax span the whole grid → the + // Hilbert query returns one or more ranges covering all 16 ids. + val ranges = TileMath.tileIdRangesForBbox( + Bbox(-85.0, -180.0, 85.0, 180.0), 2 + ) + assertTrue("z2 full-world produces at least one range", ranges.isNotEmpty()) + val base = Hilbert.accumulate(2) + // Every z2 tile-id (base..base+15) must be inside some returned range. + for (id in base until base + 16) { + assertTrue("tile-id $id must be covered", ranges.any { id in it }) + } + } + + @Test + fun tileIdRangesForBbox_west_edge_clamps_into_grid() { + // West edge of the world at z3 — coerceIn clamps xMin to 0; the result + // is a valid non-empty range list, exercising the coerceIn branches. + val ranges = TileMath.tileIdRangesForBbox( + Bbox(-80.0, -180.0, -70.0, -179.0), 3 + ) + assertTrue("west-edge bbox yields ranges", ranges.isNotEmpty()) + } + + // ---------- splitOversizedChunk mid-stream break branch ---------- + + @Test + fun splitOversizedChunk_closes_part_before_overflowing_blob() { + // A small blob followed by a large blob, both inside a chunk that + // exceeds maxBytes. Processing the small blob first leaves partBlobs + // non-empty; the next (large) blob would push blobEnd-currentStart past + // maxBytes, so the loop must `break` and close the part — exercising the + // `partBlobs.isNotEmpty() && blobEnd - currentStart > maxBytes` branch. + val small = 0L to 1_000L + val large = 1_000L to 5_000_000L + val chunk = PmtilesRegionSlicer.FetchChunk( + offset = 0L, + length = 5_001_000L, + blobs = listOf(small, large), + ) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("must split into >= 2 parts", parts.size >= 2) + // The small blob is alone in the first part (the large one was deferred). + assertEquals(listOf(small), parts[0].blobs) + assertEquals(listOf(large), parts[1].blobs) + // Every blob appears exactly once. + val all = parts.flatMap { it.blobs } + assertEquals(setOf(small, large), all.toSet()) + } + + @Test + fun splitOversizedChunk_multiple_small_blobs_pack_then_break() { + // Five 1.5 MB blobs in a 7.5 MB chunk, maxBytes=4 MB. The first part + // packs blobs until adding the next would overflow, then breaks; the + // remainder forms subsequent parts. Exercises both the pack-and-continue + // and the break-on-overflow arms. + val blobs = (0 until 5).map { (it * 1_500_000L) to 1_500_000L } + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 7_500_000L, blobs) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("expected multiple parts", parts.size >= 2) + for (p in parts) { + assertTrue("part ${p.length} within cap (or single oversized blob)", + p.length <= 4_000_000L || p.blobs.size == 1) + } + assertEquals(blobs.toSet(), parts.flatMap { it.blobs }.toSet()) + } + + // ---------- downloadAndSlice branches ---------- + + @Test + fun downloadAndSlice_empty_tiles_fails_precondition() = runBlocking { + assumeTrue(bundledNe.exists()) + val tmp = File.createTempFile("empty-slice-", ".pmtiles").also { tempFiles += it } + val result = PmtilesRegionSlicer.downloadAndSlice( + tiles = emptyList(), + globalPmtilesUrl = sourceUrl(), + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + targetFile = tmp, + onProgress = { _, _ -> }, + ) + assertTrue("empty tile list must fail the require(...)", result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun downloadAndSlice_overwrites_existing_target_file() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = sourceUrl() + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = src, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + ).getOrThrow() + assertTrue(tiles.isNotEmpty()) + + // Pre-create the target so the `if (targetFile.exists()) delete()` branch + // runs. Also leave a stale `.partial` so its delete branch runs too. + val tmp = File.createTempFile("overwrite-", ".pmtiles").also { tempFiles += it } + tmp.writeText("stale contents that must be replaced") + File(tmp.parentFile, tmp.name + ".partial").writeText("stale partial") + + var lastProgress = -1L to -1L + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + targetFile = tmp, + onProgress = { d, t -> lastProgress = d to t }, + ).getOrThrow() + + assertTrue("output rewritten as a real pmtiles file", + tmp.length() > PmtilesV3.HEADER_BYTES.toLong()) + assertNotNull(lastProgress) + assertTrue("final downloaded reached total", lastProgress.first == lastProgress.second) + + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(3.toByte(), sliced.version) + assertEquals(1.toByte(), sliced.clustered) + } + + @Test + fun downloadAndSlice_single_zoom_run_length_dedup_path() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = sourceUrl() + // z0 single tile — exercises the run-length build loop with a one-tile + // input (runEnd never extends) and the dedup blobLocalOffset path. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = src, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals(1, tiles.size) + + val tmp = File.createTempFile("z0-slice-", ".pmtiles").also { tempFiles += it } + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals(tiles.size, resliced.size) + } + + @Test + fun downloadAndSlice_extends_run_for_contiguous_tiles_sharing_one_blob() = runBlocking { + assumeTrue(bundledNe.exists()) + // The run-length EXTEND arm of the directory builder fires only when two + // CONTIGUOUS tile-ids point at the SAME (byteOffset, byteLength) blob. The + // NE archive's natural tiling rarely produces that pair for a real bbox, so + // construct the TileEntry list by hand: two contiguous tile-ids (10, 11) + // both aliasing NE's real tile-0 blob (absOffset 1215, length 30122). The + // blob bytes genuinely exist in the source file, so the chunk fetch reads + // real data; the builder must collapse the two entries into one run-length-2 + // directory entry (a single content blob, dedup preserved). + val src = sourceUrl() + val sharedOffset = 1215L + val sharedLength = 30122L + val tiles = listOf( + TileEntry(z = 2, x = 0, y = 0, tileId = 10L, byteOffset = sharedOffset, byteLength = sharedLength), + TileEntry(z = 2, x = 1, y = 0, tileId = 11L, byteOffset = sharedOffset, byteLength = sharedLength), + ) + + val tmp = File.createTempFile("run-extend-", ".pmtiles").also { tempFiles += it } + var lastProgress = -1L to -1L + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 2, + zoomMax = 2, + targetFile = tmp, + onProgress = { d, t -> lastProgress = d to t }, + ).getOrThrow() + + // Only ONE unique blob → total bytes = the single blob length (dedup), + // not the doubled sum. That's the dedup+run-extend payoff. + assertEquals("dedup collapses both entries to one blob's bytes", + sharedLength, lastProgress.second) + assertEquals("download reached total", lastProgress.first, lastProgress.second) + + // The written header reports two addressed tiles but one tile-content, + // and a single tile-entry (the collapsed run) — proof the run extended. + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("two addressed tiles", 2L, sliced.addressedTilesCount) + assertEquals("collapsed into ONE run entry", 1L, sliced.tileEntriesCount) + assertEquals("one shared content blob", 1L, sliced.tileContentsCount) + } + + // ---------- estimateRegionBytes empty branch ---------- + + @Test + fun estimateRegionBytes_empty_list_is_just_overhead() { + // No tiles → sum is 0, estimate is the fixed 8 KB overhead. + val estimate = PmtilesRegionSlicer.estimateRegionBytes(emptyList()) + assertEquals(8L * 1024L, estimate) + assertFalse(estimate == 0L) + } + + // ---------- walkEntries leaf-pointer branches (synthetic leaf archive) ---------- + + @Test + fun tilesInRegion_walks_leaf_directory_and_skips_non_overlapping_leaf() = runBlocking { + // The bundled NE archive has leafDirsBytes==0, so its root directory holds + // every entry inline — the leaf-pointer arm of walkEntries (the async leaf + // fetch + recursion) is never exercised by any file:// test against it. + // Build a tiny well-formed v3 archive with a TWO-level directory: + // root: [leaf-pointer @tileId 0] [leaf-pointer @tileId 1000] + // leafA (covers ids [0,1000)): one real z0 tile entry + // leafB (covers ids [1000,MAX)): one z-high tile entry, out of our zoom + // Slicing z0..z0 full-world makes leafA OVERLAP (fetched + recursed) and + // leafB NOT overlap (`if (!anyOverlap) continue`), covering both arms plus + // the parallel leaf-fetch coroutineScope and the per-leaf merge loop. + val archive = buildLeafBearingArchive().also { tempFiles += it.file } + + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + archive.file.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + + // Exactly the single z0 tile from leafA; the leafB entry was zoom-excluded + // AND its leaf was never fetched (non-overlap continue). + assertEquals("leaf walk yields the one z0 tile", 1, tiles.size) + val t = tiles.single() + assertEquals(0, t.z) + assertEquals(0L, t.tileId) + // byteOffset = header.tileDataOffset + entry.offset; entry.offset was 0. + assertEquals(archive.tileDataOffset, t.byteOffset) + assertEquals(LEAF_ARCHIVE_TILE_LEN, t.byteLength) + } + + @Test + fun tilesInRegionImpl_directly_on_open_fetcher_walks_leaves() = runBlocking { + // Exercise the internal `tilesInRegionImpl(fetcher, ...)` entry point + // (the "caller already has a RangeFetcher open" overload) against the + // synthetic leaf archive, so its leaf-walk path is covered through the + // fetcher-passing variant too. + val archive = buildLeafBearingArchive().also { tempFiles += it.file } + RangeFetcher.forUrl("file://" + archive.file.absolutePath).use { fetcher -> + val tiles = PmtilesRegionSlicer.tilesInRegionImpl( + fetcher = fetcher, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ) + assertEquals(1, tiles.size) + assertEquals(0L, tiles.single().tileId) + } + } + + // ---------- synthetic leaf-bearing archive builder ---------- + + /** A synthetic archive plus the offsets a test needs to assert against. */ + private class LeafArchive(val file: File, val tileDataOffset: Long) + + /** + * Write a minimal but spec-valid PMTiles v3 archive that uses a real LEAF + * directory level (which the bundled NE archive does not), so the slicer's + * leaf-pointer / leaf-fetch / recursive-walk branches become reachable + * offline via `file://`. Layout: + * ``` + * [0..127) header + * [127..rootEnd) root dir (gzip): 2 leaf pointers + * [rootEnd..metaEnd) json metadata (empty {} — unused by tilesInRegion) + * [metaEnd..tdOff) leaf section: leafA then leafB (each gzip) + * [tdOff..) tile-data placeholder (never read by tilesInRegion) + * ``` + */ + private fun buildLeafBearingArchive(): LeafArchive { + val comp = PmtilesV3.COMPRESSION_GZIP + + // leafA: covers tile-ids [0,1000); holds the single z0 tile (id 0). + val leafA = PmtilesDirectory.maybeCompress( + PmtilesDirectory.serialize( + listOf( + DirectoryEntry( + tileId = 0L, + runLength = 1L, + length = LEAF_ARCHIVE_TILE_LEN, + offset = 0L, + ) + ) + ), + comp, + ) + // leafB: covers tile-ids [1000,MAX); a lone high-zoom tile we never request. + val leafB = PmtilesDirectory.maybeCompress( + PmtilesDirectory.serialize( + listOf( + DirectoryEntry( + tileId = 1000L, + runLength = 1L, + length = 50L, + offset = LEAF_ARCHIVE_TILE_LEN, + ) + ) + ), + comp, + ) + + // root: two leaf pointers (runLength==0). offset is relative to + // leafDirsOffset; lengths are the on-disk (compressed) leaf sizes. + val rootRaw = PmtilesDirectory.serialize( + listOf( + DirectoryEntry(tileId = 0L, runLength = 0L, length = leafA.size.toLong(), offset = 0L), + DirectoryEntry( + tileId = 1000L, + runLength = 0L, + length = leafB.size.toLong(), + offset = leafA.size.toLong(), + ), + ) + ) + val rootComp = PmtilesDirectory.maybeCompress(rootRaw, comp) + + val metadata = PmtilesDirectory.maybeCompress("{}".toByteArray(), comp) + + val headerBytes = PmtilesV3.HEADER_BYTES.toLong() + val rootDirOffset = headerBytes + val rootDirBytes = rootComp.size.toLong() + val metaOffset = rootDirOffset + rootDirBytes + val metaBytes = metadata.size.toLong() + val leafOffset = metaOffset + metaBytes + val leafBytes = (leafA.size + leafB.size).toLong() + val tileDataOffset = leafOffset + leafBytes + // A couple of placeholder tile-data bytes — tilesInRegion never reads them. + val tileDataBytes = LEAF_ARCHIVE_TILE_LEN + 50L + + val header = PmtilesHeader( + version = PmtilesV3.VERSION, + rootDirOffset = rootDirOffset, + rootDirBytes = rootDirBytes, + jsonMetadataOffset = metaOffset, + jsonMetadataBytes = metaBytes, + leafDirsOffset = leafOffset, + leafDirsBytes = leafBytes, + tileDataOffset = tileDataOffset, + tileDataBytes = tileDataBytes, + addressedTilesCount = 2L, + tileEntriesCount = 2L, + tileContentsCount = 2L, + clustered = 1, + internalCompression = comp, + tileCompression = PmtilesV3.COMPRESSION_NONE, + tileType = PmtilesV3.TYPE_MVT, + minZoom = 0, + maxZoom = 14, + minLonE7 = -1_800_000_000, + minLatE7 = -850_000_000, + maxLonE7 = 1_800_000_000, + maxLatE7 = 850_000_000, + centerZoom = 0, + centerLonE7 = 0, + centerLatE7 = 0, + ) + + val file = File.createTempFile("leaf-archive-", ".pmtiles") + java.io.RandomAccessFile(file, "rw").use { raf -> + raf.setLength(tileDataOffset + tileDataBytes) + raf.write(header.toByteArray()) + raf.write(rootComp) + raf.write(metadata) + raf.write(leafA) + raf.write(leafB) + // tile-data region left zero-filled; never read by tilesInRegion. + } + return LeafArchive(file, tileDataOffset) + } + + private companion object { + private const val LEAF_ARCHIVE_TILE_LEN = 100L + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerEdgeTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerEdgeTest.kt new file mode 100644 index 00000000..99475f5a --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerEdgeTest.kt @@ -0,0 +1,96 @@ +package org.appdevforall.maps.slicer + +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Edge cases of [PmtilesRegionSlicer] against the bundled Natural Earth archive: zoom bounds + * on both ends, slicing a tile list given in any order, and a target that cannot be replaced. + */ +class PmtilesRegionSlicerEdgeTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val archive = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + private val url = "file://" + archive.absolutePath + private val world = Bbox(south = -85.0, west = -180.0, north = 85.0, east = 180.0) + + private fun sourceHeader(): PmtilesHeader = PmtilesHeader.parse( + ByteBuffer.wrap(archive.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + + private fun header(file: File): PmtilesHeader = PmtilesHeader.parse( + ByteBuffer.wrap(file.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + + private suspend fun slice(tiles: List, target: File): Result = + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = url, + sourceHeader = sourceHeader(), + bbox = world, + zoomMin = 1, + zoomMax = 2, + targetFile = target, + onProgress = { _, _ -> }, + ) + + @Test + fun `zoom levels outside 0 to 20 are rejected at both ends`() = runBlocking { + for ((zMin, zMax) in listOf(-1 to 2, 0 to 21)) { + val result = PmtilesRegionSlicer.tilesInRegion(url, world, zMin, zMax) + assertTrue("z$zMin..$zMax: $result", result.exceptionOrNull() is IllegalArgumentException) + } + } + + @Test + fun `a tile list in reverse order slices to the same archive bounds`() = runBlocking { + val tiles = PmtilesRegionSlicer.tilesInRegion(url, world, 1, 2).getOrThrow() + val sorted = File(tmp.root, "sorted.pmtiles") + val reversed = File(tmp.root, "reversed.pmtiles") + + slice(tiles, sorted).getOrThrow() + slice(tiles.reversed(), reversed).getOrThrow() + + val a = header(sorted) + val b = header(reversed) + // The header bbox is the min/max over every tile, so input order must not change it. + assertEquals( + listOf(a.minLonE7, a.minLatE7, a.maxLonE7, a.maxLatE7), + listOf(b.minLonE7, b.minLatE7, b.maxLonE7, b.maxLatE7), + ) + assertEquals(-1_800_000_000, b.minLonE7) + assertEquals(1_800_000_000, b.maxLonE7) + assertEquals( + tiles.size, + PmtilesRegionSlicer.tilesInRegion("file://" + reversed.absolutePath, world, 1, 2).getOrThrow().size, + ) + } + + @Test + fun `slicing fails when the target cannot be replaced`() = runBlocking { + val tiles = PmtilesRegionSlicer.tilesInRegion(url, world, 1, 1).getOrThrow() + // A non-empty directory at the target path survives delete(), so the final rename fails. + val target = tmp.newFolder("occupied.pmtiles") + val keep = File(target, "keep").apply { writeText("x") } + + val result = slice(tiles, target) + + val error = result.exceptionOrNull() + assertTrue("got $error", error is IllegalArgumentException) + assertTrue(error!!.message!!.startsWith("Couldn't rename")) + assertArrayEquals("x".toByteArray(), keep.readBytes()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerGoldenTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerGoldenTest.kt new file mode 100644 index 00000000..6a576620 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerGoldenTest.kt @@ -0,0 +1,131 @@ +package org.appdevforall.maps.slicer + +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.appdevforall.maps.domain.Bbox +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Our slicer against the reference implementation: the same bbox and zooms of the bundled archive + * must produce the tile ids and tile bytes that go-pmtiles `pmtiles extract` produced. The golden + * file's header records the CLI version, command and capture date. + */ +class PmtilesRegionSlicerGoldenTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val source = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + private val archive = source.readBytes() + + // Must match the --bbox/--minzoom/--maxzoom in the golden file's header. + private val bbox = Bbox(south = -12.4, west = 5.3, north = 17.6, east = 48.7) + private val zoomMin = 1 + private val zoomMax = 4 + + /** Serves [archive] by byte range, as the IIAB mirror and LAN boxes do. */ + private val server = MockWebServer().apply { + dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val (start, end) = request.getHeader("Range")!!.removePrefix("bytes=").split('-').map { it.toInt() } + val slice = archive.copyOfRange(start, minOf(end, archive.size - 1) + 1) + return MockResponse().setResponseCode(206).setBody(Buffer().write(slice)) + } + } + } + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + private fun golden(): Map = + javaClass.getResource("/slicer/go-pmtiles-extract-ne-z1-z4.txt")!!.readText() + .lines() + .filter { it.isNotBlank() && !it.startsWith("#") } + .associate { line -> line.split(' ').let { it[0].toLong() to it[1] } } + + /** Tile id -> sha256 of the stored bytes, for every tile addressed by [bytes]. */ + private fun tileHashes(bytes: ByteArray): Map { + val header = PmtilesHeader.parse( + ByteBuffer.wrap(bytes, 0, PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + ) + fun slice(offset: Long, length: Long) = bytes.copyOfRange(offset.toInt(), (offset + length).toInt()) + val out = sortedMapOf() + fun walk(entries: List) { + for (e in entries) { + if (e.isLeafPointer()) { + walk(PmtilesDirectory.parseCompressed( + slice(header.leafDirsOffset + e.offset, e.length), header.internalCompression, + )) + continue + } + val digest = MessageDigest.getInstance("SHA-256") + .digest(slice(header.tileDataOffset + e.offset, e.length)) + .joinToString("") { "%02x".format(it) } + for (k in 0 until e.runLength) out[e.tileId + k] = digest + } + } + walk(PmtilesDirectory.parseCompressed( + slice(header.rootDirOffset, header.rootDirBytes), header.internalCompression, + )) + return out + } + + /** Runs the slicer on [url] the way RegionDownloader does and returns the written archive. */ + private fun sliceFrom(url: String): ByteArray = runBlocking { + val sourceHeader = PmtilesHeader.parse( + ByteBuffer.wrap(archive, 0, PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + ) + val tiles = PmtilesRegionSlicer.tilesInRegion(url, bbox, zoomMin, zoomMax).getOrThrow() + val target = File(tmp.newFolder(), "slice.pmtiles") + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = url, + sourceHeader = sourceHeader, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + targetFile = target, + onProgress = { _, _ -> }, + ).getOrThrow() + target.readBytes() + } + + private fun assertMatchesGolden(sliced: ByteArray) { + val expected = golden() + val actual = tileHashes(sliced) + assertEquals("tile ids", expected.keys.toList(), actual.keys.toList()) + assertEquals("tile bytes", expected, actual) + } + + @Test + fun `slicing over http matches go-pmtiles extract tile for tile`() { + assertMatchesGolden(sliceFrom(server.url("/world.pmtiles").toString())) + } + + @Test + fun `slicing a local file matches go-pmtiles extract tile for tile`() { + assertMatchesGolden(sliceFrom("file://" + source.absolutePath)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt new file mode 100644 index 00000000..720a9de4 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt @@ -0,0 +1,86 @@ +package org.appdevforall.maps.slicer + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * `downloadAndSlice` must stamp the sliced header's bbox with the min/max + * coverage of the tiles actually written — the `tiles.minOf/maxOf` folds over + * lon/lat. Feeds a hand-built tile list whose x/y sequence is deliberately + * NON-monotonic (middle → low → high) so every fold takes both its + * "new extreme" and "keep current" arms, and asserts the resulting header + * bbox equals the union of the tiles' slippy bounds. + * + * Same offline fixture technique as [PmtilesRegionSlicerCoverageTest]: the + * entries alias a real blob of the bundled Natural Earth archive so the chunk + * fetch reads genuine bytes. + */ +class PmtilesRegionSlicerHeaderBboxTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + private val tempFiles = mutableListOf() + + @After + fun cleanup() { + tempFiles.forEach { f -> + f.delete() + File(f.parentFile, f.name + ".partial").delete() + } + tempFiles.clear() + } + + private fun sourceHeader(): PmtilesHeader { + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + return PmtilesHeader.parse(ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN)) + } + + @Test + fun sliced_header_bbox_is_union_of_tile_bounds_regardless_of_tile_order() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = "file://" + bundledNe.absolutePath + // NE's real tile-0 blob (same alias trick as the run-extend test). + val blobOffset = 1215L + val blobLength = 30122L + // z=2 tiles in mid → min → max order for BOTH axes: (1,1), (0,0), (2,2). + val tiles = listOf( + TileEntry(z = 2, x = 1, y = 1, tileId = 9L, byteOffset = blobOffset, byteLength = blobLength), + TileEntry(z = 2, x = 0, y = 0, tileId = 11L, byteOffset = blobOffset, byteLength = blobLength), + TileEntry(z = 2, x = 2, y = 2, tileId = 13L, byteOffset = blobOffset, byteLength = blobLength), + ) + + // Deliberately a NOT-yet-existing target (unlike createTempFile, which + // pre-creates it): first-ever slice into a fresh path must work without + // the delete-old-target step. + val outDir = java.nio.file.Files.createTempDirectory("bbox-order-").toFile() + val tmp = File(outDir, "sliced.pmtiles").also { tempFiles += it } + org.junit.Assert.assertFalse("precondition: target must not pre-exist", tmp.exists()) + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 2, + zoomMax = 2, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + // Union of covered tiles at z=2: x 0..2 → lon [-180, 90); y 0..2 → + // lat (-66.51, 85.05]. E7 storage → compare at 1e-3 tolerance. + assertEquals(TileMath.tileToLon(0, 2), sliced.minLon(), 1e-3) // -180.0 + assertEquals(TileMath.tileToLon(3, 2), sliced.maxLon(), 1e-3) // 90.0 + assertEquals(TileMath.tileToLat(0, 2), sliced.maxLat(), 1e-3) // 85.05 + assertEquals(TileMath.tileToLat(3, 2), sliced.minLat(), 1e-3) // -66.51 + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt new file mode 100644 index 00000000..f5ff0d6b --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt @@ -0,0 +1,152 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * End-to-end slicer tests against the bundled Natural Earth z0-z4 archive. + * + * NE z0-z4 is small (~7 MB, 341 tiles total) and ships in the plugin assets, + * so the slicer can run wholly offline against a real PMTiles v3 source. We + * use `file://` URLs here; production wires up `https://`. + */ +class PmtilesRegionSlicerTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + @Test + fun tilesInRegion_full_world_z0_returns_single_tile() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals("full world at z0 has exactly 1 tile", 1, tiles.size) + assertEquals(0, tiles[0].z) + assertEquals(0, tiles[0].x) + assertEquals(0, tiles[0].y) + } + + @Test + fun tilesInRegion_full_world_z0_z4_returns_all_341() = runBlocking { + assumeTrue(bundledNe.exists()) + // z0..z4 covers 1 + 4 + 16 + 64 + 256 = 341 tiles for full world. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assertTrue( + "expected ~341 tiles for z0..z4 worldwide, got ${tiles.size}", + tiles.size in 300..341 + ) + // Per-zoom sanity. + val byZ = tiles.groupBy { it.z }.mapValues { it.value.size } + assertEquals(1, byZ[0]) + assertEquals(4, byZ[1]) + assertEquals(16, byZ[2]) + // z3 might have ocean tiles missing in the source archive; allow <=64. + assertTrue("z3 count ${byZ[3]} should be <=64", (byZ[3] ?: 0) <= 64) + assertTrue("z4 count ${byZ[4]} should be <=256", (byZ[4] ?: 0) <= 256) + } + + @Test + fun tilesInRegion_small_bbox_returns_subset() = runBlocking { + assumeTrue(bundledNe.exists()) + // Cox's Bazar region — but NE is z0-z4 only, so this returns just a + // single tile at most zoom levels. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(21.10, 92.10, 21.30, 92.25), + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + // Expect: 1 (z0) + 1 (z1) + 1 (z2) + 1 (z3) + 1 (z4) = 5 tiles. + // The exact number depends on whether the source archive carries every + // ocean tile (Cox's Bazar is coastal — could be missing entries). + assertTrue("expected ~5 tiles for a small bbox z0-z4, got ${tiles.size}", tiles.size in 1..10) + } + + @Test + fun estimateRegionBytes_is_nonzero_and_matches_byte_sum() = runBlocking { + assumeTrue(bundledNe.exists()) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + ).getOrThrow() + val estimate = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val byteSum = tiles.sumOf { it.byteLength } + assertEquals("estimate = tile-byte sum + small overhead", estimate, byteSum + 8 * 1024) + } + + @Test + fun downloadAndSlice_writes_valid_pmtiles_v3() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + + // Read the source header to pass into downloadAndSlice. + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val sourceHeader = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + + // Slice z0-z3 worldwide — produces a small archive (~1 MB or less) + // we can write to a temp file and re-parse. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + ).getOrThrow() + assertTrue("expected tiles", tiles.isNotEmpty()) + + val tmp = File.createTempFile("sliced-", ".pmtiles") + tmp.deleteOnExit() + val progress = mutableListOf>() + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = sourceUrl, + sourceHeader = sourceHeader, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + targetFile = tmp, + onProgress = { d, t -> progress += d to t }, + ).getOrThrow() + + assertTrue("sliced file exists", tmp.exists()) + assertTrue("sliced file non-empty", tmp.length() > PmtilesV3.HEADER_BYTES.toLong()) + assertTrue("progress reported", progress.isNotEmpty()) + + // Round-trip: parse the sliced header. + val slicedHeader = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("version", 3.toByte(), slicedHeader.version) + assertEquals("clustered=1", 1.toByte(), slicedHeader.clustered) + assertNotNull(slicedHeader) + + // Re-slice from the sliced file — should yield the same tile count. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + ).getOrThrow() + assertEquals("sliced and re-sliced tile counts match", tiles.size, resliced.size) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt new file mode 100644 index 00000000..92e29161 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt @@ -0,0 +1,44 @@ +package org.appdevforall.maps.slicer + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +/** + * Upper-bound arms of [PmtilesRegionSlicer.tilesInRegionImpl]'s zoom + * preconditions — [PmtilesRegionSlicerCoverageTest] rejects the LOW side + * (negative zoomMin, zoomMax < zoomMin); these reject the HIGH side of the + * same `in ..20` range checks (z=21 would mean a 4-trillion-tile walk). + */ +class PmtilesRegionSlicerZoomBoundsTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + private val world = Bbox(-85.0, -180.0, 85.0, 180.0) + + @Test + fun rejects_zoomMin_above_20() { + assumeTrue(bundledNe.exists()) + FileRangeFetcher(bundledNe).use { fetcher -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + PmtilesRegionSlicer.tilesInRegionImpl(fetcher, world, zoomMin = 21, zoomMax = 21) + } + } + } + } + + @Test + fun rejects_zoomMax_above_20() { + assumeTrue(bundledNe.exists()) + FileRangeFetcher(bundledNe).use { fetcher -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + PmtilesRegionSlicer.tilesInRegionImpl(fetcher, world, zoomMin = 4, zoomMax = 21) + } + } + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt new file mode 100644 index 00000000..c19c0b0c --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt @@ -0,0 +1,41 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Branch coverage for [PmtilesV3]'s `compressionName` and `tileTypeName` + * lookup `when` expressions — every named code plus the `else` (unknown) arm. + */ +class PmtilesV3CoverageTest { + + @Test + fun compressionName_covers_every_known_code() { + assertEquals("none", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_NONE)) + assertEquals("gzip", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_GZIP)) + assertEquals("brotli", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_BROTLI)) + assertEquals("zstd", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_ZSTD)) + } + + @Test + fun compressionName_unknown_falls_through_to_else() { + // COMPRESSION_UNKNOWN (0) and any other byte hit the else arm. + assertEquals("unknown(0)", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_UNKNOWN)) + assertEquals("unknown(9)", PmtilesV3.compressionName(9)) + } + + @Test + fun tileTypeName_covers_every_known_code() { + assertEquals("mvt", PmtilesV3.tileTypeName(PmtilesV3.TYPE_MVT)) + assertEquals("png", PmtilesV3.tileTypeName(PmtilesV3.TYPE_PNG)) + assertEquals("jpeg", PmtilesV3.tileTypeName(PmtilesV3.TYPE_JPEG)) + assertEquals("webp", PmtilesV3.tileTypeName(PmtilesV3.TYPE_WEBP)) + assertEquals("avif", PmtilesV3.tileTypeName(PmtilesV3.TYPE_AVIF)) + } + + @Test + fun tileTypeName_unknown_falls_through_to_else() { + assertEquals("unknown(0)", PmtilesV3.tileTypeName(PmtilesV3.TYPE_UNKNOWN)) + assertEquals("unknown(7)", PmtilesV3.tileTypeName(7)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt new file mode 100644 index 00000000..eb89f8e3 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt @@ -0,0 +1,260 @@ +package org.appdevforall.maps.slicer + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.IOException + +/** + * Covers [RangeFetcher.forUrl] + companion, [HttpRangeFetcher] (via MockWebServer), + * and [FileRangeFetcher] (via a temp file). + * + * The HTTP cases exercise the spec-compliant 206-partial path, the 200-OK + * local-slice fallback, the 200-fallback refusals (unknown-length and + * over-cap), and the non-2xx error. The file cases exercise normal reads and + * the short-read/EOF branch. `HttpRangeByteCache` is cleared per-test so a + * cached value never short-circuits the network call under assertion. + */ +class RangeFetcherCoverageTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + // ---- forUrl scheme routing ------------------------------------------------- + + @Test + fun forUrl_http_and_https_yield_http_fetcher() { + assertTrue(RangeFetcher.forUrl("http://example.com/a.pmtiles") is HttpRangeFetcher) + assertTrue(RangeFetcher.forUrl("https://example.com/a.pmtiles") is HttpRangeFetcher) + } + + @Test + fun forUrl_file_yields_file_fetcher() { + val f = File.createTempFile("range", ".bin").apply { writeBytes(byteArrayOf(1, 2, 3)) } + try { + val fetcher = RangeFetcher.forUrl("file://${f.absolutePath}") + assertTrue(fetcher is FileRangeFetcher) + fetcher.close() + } finally { + f.delete() + } + } + + @Test + fun forUrl_unsupported_scheme_throws() { + val ex = assertThrows(IllegalArgumentException::class.java) { + RangeFetcher.forUrl("ftp://example.com/a.pmtiles") + } + assertTrue(ex.message!!.contains("Unsupported URL scheme")) + } + + @Test + fun defaultClient_is_non_null() { + // Smoke the companion factory so it isn't an uncovered line. + RangeFetcher.defaultClient() + } + + @Test + fun http_fetcher_close_is_the_noop_interface_default() { + // HttpRangeFetcher does not override close(); it inherits the interface's + // `close() = Unit` default. Exercise it (and confirm it's idempotent) so the + // default body is covered and a closed fetcher can still be GC'd safely. + val fetcher = HttpRangeFetcher(server.url("/x").toString(), RangeFetcher.defaultClient()) + fetcher.close() + fetcher.close() + } + + // ---- HttpRangeFetcher: 206 partial ----------------------------------------- + + @Test + fun http_206_returns_range_bytes_and_sends_range_header() { + val payload = ByteArray(8) { (it + 1).toByte() } + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(payload))) + val url = server.url("/a.pmtiles").toString() + + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + val got = fetcher.readRange(4L, 8) + + assertArrayEquals(payload, got) + val recorded = server.takeRequest() + assertEquals("bytes=4-11", recorded.getHeader("Range")) + } + + @Test + fun http_206_second_read_is_served_from_cache() { + val payload = ByteArray(4) { it.toByte() } + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(payload))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + fetcher.readRange(0L, 4) + // Second identical read must hit the cache — no second response enqueued, + // so if it tried the network the request would hang/fail. + val second = fetcher.readRange(0L, 4) + assertArrayEquals(payload, second) + assertEquals(1, server.requestCount) + } + + @Test + fun http_readRange_rejects_negative_offset_and_nonpositive_length() { + val fetcher = HttpRangeFetcher(server.url("/x").toString(), RangeFetcher.defaultClient()) + assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(-1L, 4) } + assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 0) } + } + + // ---- HttpRangeFetcher: 200 fallback ---------------------------------------- + + @Test + fun http_200_with_known_length_slices_locally() { + val whole = ByteArray(16) { it.toByte() } + // Content-Length is set automatically from the body by MockWebServer. + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(whole))) + val url = server.url("/a.pmtiles").toString() + + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + val got = fetcher.readRange(4L, 5) + + assertArrayEquals(whole.copyOfRange(4, 9), got) + } + + @Test + fun http_200_unknown_length_chunked_is_refused() { + // Chunked transfer → contentLength() == -1 → refuse rather than buffer. + server.enqueue( + MockResponse() + .setResponseCode(200) + .setChunkedBody(okio.Buffer().write(ByteArray(8)), 4), + ) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("refusing to buffer")) + } + + @Test + fun http_206_with_wrong_size_body_fails_length_check() { + // A spec-compliant 206 must return exactly `length` bytes; a short body is refused. + val short = ByteArray(3) // requested 8, server returns only 3 + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(short))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 8) } + assertTrue(ex.message!!.contains("Range response returned")) + } + + @Test + fun http_200_over_cap_known_length_is_refused() { + // A 200 whose declared Content-Length exceeds MAX_200_FALLBACK_BYTES + // (32 MiB) must be refused rather than buffered — the `declared > cap` arm + // of the refusal guard. Send a body just over the cap so contentLength() + // is a real, large positive value (distinct from the chunked/-1 case). + val overCap = ByteArray(32 * 1024 * 1024 + 16) + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(overCap))) + val url = server.url("/big.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("refusing to buffer")) + } + + @Test + fun http_200_fallback_cannot_satisfy_out_of_range_request() { + // A 200 (Range-ignored) returns the whole file. If the requested range + // extends past the returned body, the local slice can't be cut — the + // `require(... (offset.toInt() + length) <= whole.size)` guard fails. + val whole = ByteArray(8) { it.toByte() } // 8-byte "whole file", well under the cap + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(whole))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + // Request 10 bytes from offset 4 → 4+10=14 > 8 → unsatisfiable. + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(4L, 10) } + assertTrue(ex.message!!.contains("200-OK fallback can't satisfy range")) + } + + @Test + fun http_non_2xx_throws_io_exception() { + server.enqueue(MockResponse().setResponseCode(404)) + val url = server.url("/missing.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("HTTP 404")) + } + + // ---- FileRangeFetcher ------------------------------------------------------ + + @Test + fun file_reads_requested_range() { + val f = File.createTempFile("range", ".bin") + try { + val data = ByteArray(32) { it.toByte() } + f.writeBytes(data) + val fetcher = FileRangeFetcher(f) + val got = fetcher.readRange(8L, 10) + assertArrayEquals(data.copyOfRange(8, 18), got) + fetcher.close() + } finally { + f.delete() + } + } + + @Test + fun file_read_past_eof_throws() { + val f = File.createTempFile("range", ".bin") + try { + f.writeBytes(ByteArray(4)) + val fetcher = FileRangeFetcher(f) + // Request 8 bytes starting at offset 2 of a 4-byte file → EOF mid-read. + val ex = assertThrows(IOException::class.java) { fetcher.readRange(2L, 8) } + assertTrue(ex.message!!.contains("EOF")) + fetcher.close() + } finally { + f.delete() + } + } + + @Test + fun file_reads_from_many_threads_each_get_their_own_range() { + val f = File.createTempFile("range", ".bin") + try { + val data = ByteArray(64 * 1024) { (it * 31 + 7).toByte() } + f.writeBytes(data) + val fetcher = FileRangeFetcher(f) + val pool = java.util.concurrent.Executors.newFixedThreadPool(8) + try { + val reads = (0 until 2000).map { i -> + val offset = (i * 977L) % (data.size - 512) + pool.submit { fetcher.readRange(offset, 512).contentEquals(data.copyOfRange(offset.toInt(), offset.toInt() + 512)) } + } + assertEquals("reads that returned another range's bytes", 0, reads.count { !it.get() }) + } finally { + pool.shutdown() + fetcher.close() + } + } finally { + f.delete() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt new file mode 100644 index 00000000..478368ca --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt @@ -0,0 +1,85 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +/** + * Covers [SliceEstimateCache] — the process-wide LRU of slicer estimates keyed + * by `(sourceUrl, rounded bbox, zoom range)`. Exercises get/put round-trip, the + * 4-decimal bbox rounding that collapses sub-tile jitter into one key, key + * distinctness across url / zoom, and LRU eviction past the 16-entry bound. + * + * Pure in-memory singleton, so each test clears it in @Before / @After. + */ +class SliceEstimateCacheCoverageTest { + + @Before fun reset() = SliceEstimateCache.clear() + + @After fun tearDown() = SliceEstimateCache.clear() + + private fun entry(z: Int = 0) = TileEntry(z, 0, 0, 0L, 0L, 0L) + + @Test + fun get_returns_null_when_absent() { + assertNull(SliceEstimateCache.get("u", Bbox(0.0, 0.0, 1.0, 1.0), 0, 4)) + } + + @Test + fun put_then_get_round_trips_same_list() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + val value = listOf(entry(1), entry(2)) + SliceEstimateCache.put("u", box, 0, 4, value) + assertSame(value, SliceEstimateCache.get("u", box, 0, 4)) + } + + @Test + fun bbox_rounding_collapses_sub_decimal_jitter_to_one_key() { + val value = listOf(entry()) + // Two bboxes differing only below the 4th decimal (~1 m) must hit the same key. + SliceEstimateCache.put("u", Bbox(10.000001, 20.000002, 30.000003, 40.000004), 0, 4, value) + val jittered = Bbox(10.000009, 20.000008, 30.000007, 40.000006) + assertSame(value, SliceEstimateCache.get("u", jittered, 0, 4)) + } + + @Test + fun bbox_difference_above_rounding_is_a_distinct_key() { + SliceEstimateCache.put("u", Bbox(10.0, 20.0, 30.0, 40.0), 0, 4, listOf(entry())) + // A 0.001-degree shift exceeds the 4-decimal rounding → different key. + assertNull(SliceEstimateCache.get("u", Bbox(10.001, 20.0, 30.0, 40.0), 0, 4)) + } + + @Test + fun url_and_zoom_range_are_part_of_the_key() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + SliceEstimateCache.put("a", box, 0, 4, listOf(entry())) + assertNull(SliceEstimateCache.get("b", box, 0, 4)) + assertNull(SliceEstimateCache.get("a", box, 1, 4)) + assertNull(SliceEstimateCache.get("a", box, 0, 5)) + } + + @Test + fun clear_drops_all_entries() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + SliceEstimateCache.put("u", box, 0, 4, listOf(entry())) + SliceEstimateCache.clear() + assertNull(SliceEstimateCache.get("u", box, 0, 4)) + } + + @Test + fun eviction_drops_eldest_past_sixteen_entries() { + // Insert 17 distinct entries (distinct by latitude); the 16-entry LRU bound + // evicts the eldest (first-inserted) when the 17th lands. + for (i in 0 until 17) { + SliceEstimateCache.put("u", Bbox(i.toDouble(), 0.0, i + 0.5, 1.0), 0, 4, listOf(entry(i))) + } + assertNull(SliceEstimateCache.get("u", Bbox(0.0, 0.0, 0.5, 1.0), 0, 4)) + // The most-recently inserted is still present. + val newest = SliceEstimateCache.get("u", Bbox(16.0, 0.0, 16.5, 1.0), 0, 4) + assertEquals(16, newest?.first()?.z) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt new file mode 100644 index 00000000..4fd7fb89 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt @@ -0,0 +1,60 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [TileMath.tileIntersectsBbox] disjointness arms — one test per failing + * conjunct of `tileEast > west && tileWest < east && tileNorth > south && + * tileSouth < north`, plus the boundary-touch semantics (strict inequalities: + * an edge-touching tile does NOT intersect). + * + * Fixture: bbox = (0°S..20°N, 0°E..20°E); z=4 tiles are 22.5° of longitude + * wide, so x=8 spans lon [0, 22.5), y=7 spans lat (0, 21.94], etc. + */ +class TileMathDisjointBboxTest { + + private val bbox = Bbox(south = 0.0, west = 0.0, north = 20.0, east = 20.0) + private val z = 4 + + @Test + fun overlappingTileIntersects() { + // x=8 → lon 0..22.5, y=7 → lat 0..21.94 — clearly overlaps the box. + assertTrue(TileMath.tileIntersectsBbox(z, x = 8, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelyWestDoesNotIntersect() { + // x=6 → lon -45..-22.5, east edge < bbox.west → first conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 6, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelyEastDoesNotIntersect() { + // x=9 → lon 22.5..45, west edge > bbox.east → second conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 9, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelySouthDoesNotIntersect() { + // y=9 → lat -40.98..-21.94, north edge < bbox.south → third conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 9, bbox = bbox)) + } + + @Test + fun tileEntirelyNorthDoesNotIntersect() { + // y=6 → lat 21.94..40.98, south edge > bbox.north → fourth conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 6, bbox = bbox)) + } + + @Test + fun edgeTouchingTileDoesNotIntersect() { + // y=8's NORTH edge is exactly lat 0 = bbox.south; the comparison is strict + // (`tileNorth > south`), so a tile that only touches the boundary is out. + // This is the semantics the slicer relies on to avoid double-counting + // tiles along a shared edge. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 8, bbox = bbox)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt new file mode 100644 index 00000000..0b3477e0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt @@ -0,0 +1,53 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [Varint] focusing on the guard arms the happy-path + * roundtrip test does not reach: the negative-value encode `require`, the + * ">64 bits" decode `require`, and decoding of a maximal-width varint. + */ +class VarintCoverageTest { + + private fun roundtrip(v: Long): Long { + val out = ByteArrayOutputStream() + Varint.encode(v, out) + return Varint.decode(ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN)) + } + + @Test + fun decodes_single_byte_boundary() { + assertEquals(127L, roundtrip(127L)) + } + + @Test + fun decodes_two_byte_boundary() { + assertEquals(128L, roundtrip(128L)) + } + + @Test + fun decodes_max_long() { + // Long.MAX_VALUE encodes to a 9-byte varint; exercises the multi-byte loop fully. + assertEquals(Long.MAX_VALUE, roundtrip(Long.MAX_VALUE)) + } + + @Test + fun encode_rejects_negative() { + assertThrows(IllegalArgumentException::class.java) { + Varint.encode(-1L, ByteArrayOutputStream()) + } + } + + @Test + fun decode_rejects_overlong_varint() { + // 10 continuation bytes (all MSB set) push shift past 64 -> require fails. + val bytes = ByteArray(10) { 0x80.toByte() } + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertThrows(IllegalArgumentException::class.java) { Varint.decode(buf) } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt new file mode 100644 index 00000000..a199f3ed --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt @@ -0,0 +1,44 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class VarintTest { + + @Test + fun encodes_zero_as_single_byte() { + val out = ByteArrayOutputStream() + Varint.encode(0L, out) + assertArrayEquals(byteArrayOf(0), out.toByteArray()) + } + + @Test + fun encodes_127_as_single_byte() { + val out = ByteArrayOutputStream() + Varint.encode(127L, out) + assertArrayEquals(byteArrayOf(0x7f), out.toByteArray()) + } + + @Test + fun encodes_128_as_two_bytes() { + val out = ByteArrayOutputStream() + Varint.encode(128L, out) + assertArrayEquals(byteArrayOf(0x80.toByte(), 0x01), out.toByteArray()) + } + + @Test + fun roundtrips_random_values() { + val samples = listOf(0L, 1L, 127L, 128L, 16_383L, 16_384L, 1_000_000L, Long.MAX_VALUE / 2) + for (v in samples) { + val out = ByteArrayOutputStream() + Varint.encode(v, out) + val buf = ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + val decoded = Varint.decode(buf) + assertEquals("roundtrip $v", v, decoded) + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt new file mode 100644 index 00000000..666b5323 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt @@ -0,0 +1,410 @@ +package org.appdevforall.maps.templates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Asserts the unified region-map template ships BOTH the Kotlin and Java source + * variants (so `showLanguageOption()` can scaffold either), reads the bundled + * region from the fixed flat layout, and no longer carries the old + * `templates/region/` apply-emitter assets or the demo MainActivity. + * + * Reads template assets straight off disk (src/main/assets/), the same source the + * plugin AssetManager serves at runtime. + */ +class MapTemplateAssetsTest { + + private val templateRoot = File("src/main/assets/templates/region-map") + + private fun asset(rel: String) = File(templateRoot, rel) + + @Test + fun `template ships MapRegionActivity in both kotlin and java`() { + val base = "app/src/main/java/PACKAGE_NAME" + assertTrue("Kotlin MapRegionActivity missing", asset("$base/MapRegionActivity.kt.peb").exists()) + assertTrue("Java MapRegionActivity missing", asset("$base/MapRegionActivity.java.peb").exists()) + } + + @Test + fun `template ships PmtilesHttpServer in both kotlin and java`() { + val base = "app/src/main/java/PACKAGE_NAME" + assertTrue("Kotlin PmtilesHttpServer missing", asset("$base/PmtilesHttpServer.kt.peb").exists()) + assertTrue("Java PmtilesHttpServer missing", asset("$base/PmtilesHttpServer.java.peb").exists()) + } + + @Test + fun `both source variants use the CGT package delimiter`() { + val base = "app/src/main/java/PACKAGE_NAME" + // CGT/Pebble runtime delimiter is `${'$'}{{PACKAGE_NAME}}`, not the plain + // `{{PACKAGE_NAME}}` token the old apply-emitter used. + for (f in listOf( + "MapRegionActivity.kt.peb", + "MapRegionActivity.java.peb", + "PmtilesHttpServer.kt.peb", + "PmtilesHttpServer.java.peb", + )) { + val text = asset("$base/$f").readText() + assertTrue("$f should use the CGT \${{PACKAGE_NAME}} delimiter", text.contains("\${{PACKAGE_NAME}}")) + assertFalse( + "$f should NOT use the bare {{PACKAGE_NAME}} token", + text.contains(Regex("(?= 3) + } + + @Test + fun `both activities substitute GLYPHS_URL off the loopback server`() { + val base = "app/src/main/java/PACKAGE_NAME" + for (f in listOf("MapRegionActivity.kt.peb", "MapRegionActivity.java.peb")) { + val text = asset("$base/$f").readText() + assertTrue("$f must substitute GLYPHS_URL", text.contains("GLYPHS_URL")) + assertTrue("$f must serve fonts off the loopback", text.contains("/maps/fonts/{fontstack}/{range}.pbf")) + } + } + + @Test + fun `both activities center on location else fit the whole bbox`() { + val base = "app/src/main/java/PACKAGE_NAME" + for (f in listOf("MapRegionActivity.kt.peb", "MapRegionActivity.java.peb")) { + val text = asset("$base/$f").readText() + assertTrue("$f must use last-known location", text.contains("lastKnownLatLng")) + assertTrue("$f must fit the bbox bounds", text.contains("readBboxBounds")) + assertTrue("$f must frame bounds via newLatLngBounds", text.contains("newLatLngBounds")) + // The old geometric-center default is gone. + assertFalse("$f should not center on the bbox geometric center", text.contains("readBboxCenter")) + } + } + + @Test + fun `both activities draw the region bbox outline`() { + val base = "app/src/main/java/PACKAGE_NAME" + for (f in listOf("MapRegionActivity.kt.peb", "MapRegionActivity.java.peb")) { + val text = asset("$base/$f").readText() + assertTrue("$f must draw the bbox outline", text.contains("addBboxOutline")) + assertTrue("$f bbox outline must be a LineLayer", text.contains("LineLayer")) + } + } + + @Test + fun `template ships a thumbnail icon for the New-Project grid`() { + // Without a thumbnail the New-Project tile renders blank (CgtTemplateBuilder + // thumbnailFromAssets). 512x512 PNG beside the template dir. + assertTrue("template thumb.png missing", asset("thumb.png").exists()) + } + + @Test + fun `template bundles the offline maplibre maven repo with maplibre vendored`() { + // The project-local repo travels with the generated project so the app + // builds with zero network on an offline device (E13). It must contain + // MapLibre's AAR + a manifest enumerating every vendored file. + val repo = File(templateRoot, "offline-maplibre-repo") + assertTrue("offline-maplibre-repo missing", repo.isDirectory) + val maplibreAar = File( + repo, + "org/maplibre/gl/android-sdk-opengl/13.1.0/android-sdk-opengl-13.1.0.aar", + ) + assertTrue("MapLibre AAR not vendored", maplibreAar.exists()) + val manifest = File(repo, "repo-manifest.txt") + assertTrue("repo-manifest.txt missing", manifest.exists()) + val lines = manifest.readLines().map { it.trim() }.filter { it.isNotEmpty() } + assertTrue("manifest must list the MapLibre AAR", + lines.any { it.endsWith("android-sdk-opengl-13.1.0.aar") }) + // Every manifest path must point at a real file on disk. + for (rel in lines) { + assertTrue("manifest lists missing file: $rel", File(repo, rel).exists()) + } + } + + private val maplibreDir = + File(templateRoot, "offline-maplibre-repo/org/maplibre/gl/android-sdk-opengl/13.1.0") + + @Test + fun `vendored maplibre aar carries the two ABIs Code on the Go runs on`() { + // x86 and x86_64 are removed from the upstream AAR because no phone CoGo runs on uses them. + val abis = mutableSetOf() + java.util.zip.ZipFile(File(maplibreDir, "android-sdk-opengl-13.1.0.aar")).use { zf -> + val e = zf.entries() + while (e.hasMoreElements()) { + val name = e.nextElement().name + Regex("^jni/([^/]+)/").find(name)?.let { abis.add(it.groupValues[1]) } + } + } + assertEquals("AAR native libs", setOf("arm64-v8a", "armeabi-v7a"), abis) + } + + @Test + fun `maplibre module metadata matches the vendored aar`() { + // A re-stripped AAR must update the size and every digest in every file entry, or the + // .module describes a file that is no longer there. + val aar = File(maplibreDir, "android-sdk-opengl-13.1.0.aar") + val module = File(maplibreDir, "android-sdk-opengl-13.1.0.module").readText() + val bytes = aar.readBytes() + val entries = Regex("\"size\": (\\d+)").findAll(module).map { it.groupValues[1] }.toList() + assertTrue("module lists no files", entries.isNotEmpty()) + assertEquals("module size", entries.map { aar.length().toString() }, entries) + val algorithms = mapOf("md5" to "MD5", "sha1" to "SHA-1", "sha256" to "SHA-256", "sha512" to "SHA-512") + for ((key, algorithm) in algorithms) { + val recorded = Regex("\"$key\": \"([^\"]*)\"").findAll(module).map { it.groupValues[1] }.toList() + // This .module carries no sha512; check it only once one is recorded. + if (key == "sha512" && recorded.isEmpty()) continue + val actual = java.security.MessageDigest.getInstance(algorithm) + .digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals("module $key", entries.map { actual }, recorded) + } + } + + @Test + fun `settings declares the project-local offline repo`() { + val settings = asset("settings.gradle.kts.peb").readText() + assertTrue( + "settings must declare the offline-maplibre-repo maven repo", + settings.contains("offline-maplibre-repo"), + ) + } + + @Test + fun `build gradle aligns deps to the bundled offline repo and packages both ARM ABIs`() { + val gradle = asset("app/build.gradle.kts.peb").readText() + // Aligned to the bundled offline repo's versions (E12 enumeration). + assertTrue("material must align to bundled 1.9.0", + gradle.contains("com.google.android.material:material:1.9.0")) + assertTrue("coordinatorlayout must align to bundled 1.1.0", + gradle.contains("androidx.coordinatorlayout:coordinatorlayout:1.1.0")) + assertFalse("must not pin the un-bundled material 1.10.0", + gradle.contains("material:1.10.0")) + assertFalse("must not pin the un-bundled coordinatorlayout 1.2.0", + gradle.contains("coordinatorlayout:1.2.0")) + // The ABI filter matches the native libs the vendored AAR carries. + assertTrue("must package arm64-v8a", gradle.contains("abiFilters.add(\"arm64-v8a\")")) + assertTrue("must package armeabi-v7a", gradle.contains("abiFilters.add(\"armeabi-v7a\")")) + } + + /** Every version string a `com.android.application` plugin request names in [text]. */ + private fun agpVersions(text: String): List = + Regex("""id\("com\.android\.application"\)[^\n]*?version "([^"]+)"""") + .findAll(text) + .map { it.groupValues[1] } + .toList() + + @Test + fun `root and app modules request the host's AGP version, not a hardcoded one`() { + // A hardcoded "8.11.0" in the root broke sync once the host bundle moved to AGP 9.3.1. + val root = agpVersions(asset("build.gradle.kts.peb").readText()) + val app = agpVersions(asset("app/build.gradle.kts.peb").readText()) + assertEquals("root AGP request", listOf("\${{AGP_VERSION}}"), root) + assertEquals("app AGP request", listOf("\${{AGP_VERSION}}"), app) + } + + @Test + fun `root puts the host's Kotlin Gradle plugin on the buildscript classpath`() { + // AGP 9's built-in Kotlin compiles with whatever kotlin-gradle-plugin is on the classpath. + val root = asset("build.gradle.kts.peb").readText() + assertTrue( + "root must declare the Kotlin Gradle plugin classpath", + root.contains("classpath(\"org.jetbrains.kotlin:kotlin-gradle-plugin:\${{KOTLIN_VERSION}}\")"), + ) + } + + @Test + fun `app module uses no construct AGP 9 removed`() { + val gradle = asset("app/build.gradle.kts.peb").readText() + assertFalse("kotlin(\"android\") fails to apply under AGP 9", gradle.contains("kotlin(\"android\")")) + assertFalse("org.jetbrains.kotlin.android fails to apply under AGP 9", gradle.contains("org.jetbrains.kotlin.android")) + assertFalse("kotlinOptions {} was removed in AGP 9", gradle.contains("kotlinOptions")) + } + + @Test + fun `kotlin variant configures built-in Kotlin and keeps the metadata-version skip`() { + val gradle = asset("app/build.gradle.kts.peb").readText() + val kotlinBlock = gradle.substringAfter("kotlin {", missingDelimiterValue = "") + assertTrue("app must configure kotlin { compilerOptions }", kotlinBlock.contains("compilerOptions")) + assertTrue("jvmTarget must follow the host", kotlinBlock.contains("JvmTarget.fromTarget(\"\${{JAVA_TARGET}}\")")) + assertTrue("MapLibre's newer Kotlin metadata needs the skip flag", kotlinBlock.contains("-Xskip-metadata-version-check")) + } + + @Test + fun `core-ktx is raised to a version that ships as an AAR`() { + // appcompat 1.6.1 asks for core-ktx 1.2.0; the host bundle carries only its POM and is + // searched first, so the build fails. 1.9.0 ships as an AAR in the bundle and here. + val gradle = asset("app/build.gradle.kts.peb").readText() + assertTrue("core-ktx must be raised to 1.9.0", gradle.contains("\"androidx.core:core-ktx:1.9.0\"")) + assertTrue( + "core-ktx 1.9.0 AAR must be vendored", + File(templateRoot, "offline-maplibre-repo/androidx/core/core-ktx/1.9.0/core-ktx-1.9.0.aar").exists(), + ) + } + + @Test + fun `generated app docs say the app runs on both 64-bit and 32-bit ARM`() { + val docs = listOf( + asset("README.md.peb"), + File("src/main/assets/docs/index.html"), + ) + for (doc in docs) { + // Collapse whitespace so a re-wrapped line in the HTML does not hide the phrase. + val text = doc.readText().replace(Regex("\\s+"), " ") + assertTrue("${doc.name} must say 64-bit and 32-bit ARM", text.contains("64-bit and 32-bit ARM phones")) + assertTrue("${doc.name} must name arm64-v8a", text.contains("arm64-v8a")) + assertTrue("${doc.name} must name armeabi-v7a", text.contains("armeabi-v7a")) + assertFalse( + "${doc.name} still says 64-bit ARM only", + text.contains(Regex("64-bit ARM[^.]*\\bonly\\b", RegexOption.IGNORE_CASE)), + ) + assertFalse("${doc.name} still says it will not install on 32-bit", text.contains("not install on a 32-bit")) + } + } + + private val repoManifest = + File(templateRoot, "offline-maplibre-repo/repo-manifest.txt").readLines().map { it.trim() }.toSet() + + /** The files a generated Kotlin or Java project resolved from the vendored repo, offline. */ + private val resolvedFromRepo = + javaClass.getResource("/templates/vendored-repo-resolved-files.txt")!!.readText() + .lines().filter { it.isNotBlank() && !it.startsWith("#") } + + @Test + fun `every file a generated project resolves from the vendored repo ships with the template`() { + val missing = resolvedFromRepo.filterNot { + it in repoManifest && File(templateRoot, "offline-maplibre-repo/$it").isFile + } + assertEquals("resolved but not shipped (the manifest decides what is emitted)", emptyList(), missing) + } + + /** Every jar or aar in the vendored repo, whether listed in the manifest or only present on disk. */ + private fun shippedBinaries(): Set { + val repo = File(templateRoot, "offline-maplibre-repo") + val onDisk = repo.walk().filter { it.isFile }.map { it.relativeTo(repo).invariantSeparatorsPath } + return (repoManifest + onDisk).filter { it.endsWith(".jar") || it.endsWith(".aar") }.toSortedSet() + } + + @Test + fun `the vendored repo carries only the binaries a generated project resolves`() { + assertEquals( + "shipped but no generated project resolves it", + emptyList(), + (shippedBinaries() - resolvedFromRepo.toSet()).toList(), + ) + } + + @Test + fun `old MainActivity and region template assets are gone`() { + val base = "app/src/main/java/PACKAGE_NAME" + assertFalse(asset("$base/MainActivity.kt.peb").exists()) + assertFalse(asset("$base/MainActivity.java.peb").exists()) + assertFalse(asset("app/src/main/res/layout/activity_main.xml").exists()) + assertFalse("demo-server style.json should be gone", asset("app/src/main/assets/style.json").exists()) + assertFalse( + "templates/region/ should be deleted", + File("src/main/assets/templates/region").exists(), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt new file mode 100644 index 00000000..97e5fdb0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt @@ -0,0 +1,206 @@ +package org.appdevforall.maps.templates + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.AssetManager +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.services.IdeTemplateService +import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder +import java.io.File +import java.io.FileNotFoundException +import java.util.zip.ZipFile +import org.appdevforall.maps.testing.TestAssetManagers +import org.appdevforall.maps.testing.UnusedPluginContext +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Runs [MapTemplateBuilder.buildAndRegister] through the host's real [CgtTemplateBuilder] against + * the plugin's real template assets, and checks what lands in the `.cgt` and what gets registered. + */ +class MapTemplateBuilderBuildTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val assetsRoot = File("src/main/assets") + + private class AssetContext(private val assets: AssetManager) : ContextWrapper(null) { + override fun getAssets(): AssetManager = assets + } + + private class AssetPluginContext(assets: AssetManager) : PluginContext by UnusedPluginContext { + override val androidContext: Context = AssetContext(assets) + } + + /** Hands out real builders and records what the plugin registers. */ + private class RecordingTemplateService(private val accept: Boolean) : IdeTemplateService { + val builtNames = mutableListOf() + val registered = mutableListOf() + + override fun createTemplateBuilder(templateName: String): CgtTemplateBuilder { + builtNames += templateName + return CgtTemplateBuilder(templateName) + } + + override fun registerTemplate(cgtFile: File): Boolean { + registered += cgtFile + return accept + } + + override fun unregisterTemplate(templateName: String): Boolean = throw UnsupportedOperationException() + override fun isTemplateRegistered(templateName: String): Boolean = throw UnsupportedOperationException() + override fun getRegisteredTemplates(): List = throw UnsupportedOperationException() + override fun reloadTemplates(): Unit = throw UnsupportedOperationException() + } + + private fun zipEntries(file: File): Set = + ZipFile(file).use { zip -> zip.entries().asSequence().map { it.name }.toSet() } + + @Test + fun `builds the template from the real assets and registers the cgt`() { + val svc = RecordingTemplateService(accept = true) + val outputDir = File(tmp.root, "out/templates") + + val count = MapTemplateBuilder.buildAndRegister( + AssetPluginContext(TestAssetManagers.fromDirectory(assetsRoot)), svc, outputDir, + ) + + assertEquals(1, count) + assertEquals(listOf(MapTemplateBuilder.REGION_MAP_TEMPLATE_NAME), svc.builtNames) + val cgt = svc.registered.single() + assertTrue("the .cgt is written under the output dir: $cgt", cgt.isFile && cgt.startsWith(outputDir)) + assertEquals(MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE, cgt.name) + + val entries = zipEntries(cgt) + val root = "OfflineOSMMap/" + // Pebble files keep their .peb suffix inside the .cgt; static files are verbatim. + for (peb in listOf( + "settings.gradle.kts.peb", + "app/build.gradle.kts.peb", + "app/src/main/AndroidManifest.xml.peb", + "app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb", + "app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb", + "app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb", + "app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb", + "app/src/main/res/xml/network_security_config.xml", + "app/src/main/assets/maps/style.json", + "app/src/main/assets/maps/fonts/notosans/OFL.txt", + )) { + assertTrue("missing $peb", "$root$peb" in entries) + } + // Every vendored Maven file listed in the manifest, plus the manifest itself. + val manifest = File(assetsRoot, "templates/region-map/offline-maplibre-repo/repo-manifest.txt") + .readLines().map { it.trim() }.filter { it.isNotEmpty() } + val repoEntries = entries.filter { it.startsWith("${root}offline-maplibre-repo/") }.toSet() + val expectedRepo = manifest.map { "${root}offline-maplibre-repo/$it" }.toSet() + + "${root}offline-maplibre-repo/repo-manifest.txt" + assertEquals(expectedRepo, repoEntries) + + // Static files are copied byte for byte. + val style = ZipFile(cgt).use { zip -> + zip.getInputStream(zip.getEntry("${root}app/src/main/assets/maps/style.json")).readBytes() + } + assertArrayEquals( + File(assetsRoot, "templates/region-map/app/src/main/assets/maps/style.json").readBytes(), + style, + ) + } + + @Test + fun `the metadata declares PACKAGE_NAME, which the template's source paths use`() { + val svc = RecordingTemplateService(accept = true) + MapTemplateBuilder.buildAndRegister( + AssetPluginContext(TestAssetManagers.fromDirectory(assetsRoot)), svc, File(tmp.root, "out"), + ) + val cgt = svc.registered.single() + val metadata = ZipFile(cgt).use { zip -> + zip.getInputStream(zip.getEntry("OfflineOSMMap/template/template.json")).reader().readText() + } + // CoGo's ZipRecipeExecutor warns when a template uses PACKAGE_NAME without declaring it. + assertTrue(zipEntries(cgt).any { "PACKAGE_NAME" in it }) + val required = org.json.JSONObject(metadata).getJSONObject("parameters").getJSONObject("required") + assertTrue("required parameters lack packageName: $required", required.has("packageName")) + assertEquals("PACKAGE_NAME", required.getJSONObject("packageName").getString("identifier")) + } + + @Test + fun `returns zero when the host declines the registration`() { + val svc = RecordingTemplateService(accept = false) + + val count = MapTemplateBuilder.buildAndRegister( + AssetPluginContext(TestAssetManagers.fromDirectory(assetsRoot)), svc, File(tmp.root, "out"), + ) + + assertEquals(0, count) + assertEquals("the built .cgt is still offered to the host", 1, svc.registered.size) + } + + @Test + fun `offline repo entries come from the manifest, skipping blank lines and trimming paths`() { + val repo = "templates/region-map/offline-maplibre-repo" + val synthetic = mapOf( + "$repo/repo-manifest.txt" to " org/x/x.pom \n\n \norg/y/y.jar\n".toByteArray(), + "$repo/org/x/x.pom" to "pom".toByteArray(), + "$repo/org/y/y.jar" to byteArrayOf(1, 2, 3), + ) + val assets = TestAssetManagers.create { path -> + synthetic[path]?.inputStream() ?: File(assetsRoot, path).takeIf { it.isFile }?.inputStream() + } + val svc = RecordingTemplateService(accept = true) + + MapTemplateBuilder.buildAndRegister(AssetPluginContext(assets), svc, File(tmp.root, "out")) + + val repoEntries = zipEntries(svc.registered.single()) + .filter { it.startsWith("OfflineOSMMap/offline-maplibre-repo/") }.toSet() + assertEquals( + setOf( + "OfflineOSMMap/offline-maplibre-repo/org/x/x.pom", + "OfflineOSMMap/offline-maplibre-repo/org/y/y.jar", + "OfflineOSMMap/offline-maplibre-repo/repo-manifest.txt", + ), + repoEntries, + ) + } + + @Test + fun `a missing template asset fails the build instead of registering a partial template`() { + val assets = TestAssetManagers.create { path -> + if (path.endsWith("MapRegionActivity.kt.peb")) null + else File(assetsRoot, path).takeIf { it.isFile }?.inputStream() + } + val svc = RecordingTemplateService(accept = true) + + val thrown = runCatching { + MapTemplateBuilder.buildAndRegister(AssetPluginContext(assets), svc, File(tmp.root, "out")) + }.exceptionOrNull() + + assertTrue("expected FileNotFoundException, got $thrown", thrown is FileNotFoundException) + assertTrue("nothing may be registered", svc.registered.isEmpty()) + } + + @Test + fun `an unreadable repo manifest fails the build`() { + val assets = TestAssetManagers.create { path -> + if (path.endsWith("repo-manifest.txt")) { + object : java.io.InputStream() { + override fun read(): Int = throw java.io.IOException("manifest unreadable") + } + } else { + File(assetsRoot, path).takeIf { it.isFile }?.inputStream() + } + } + val svc = RecordingTemplateService(accept = true) + + val thrown = runCatching { + MapTemplateBuilder.buildAndRegister(AssetPluginContext(assets), svc, File(tmp.root, "out")) + }.exceptionOrNull() + + assertEquals("manifest unreadable", thrown?.message) + assertTrue(svc.registered.isEmpty()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderCoverageTest.kt new file mode 100644 index 00000000..d40fd27e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderCoverageTest.kt @@ -0,0 +1,127 @@ +package org.appdevforall.maps.templates + +import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder +import com.itsaky.androidide.plugins.services.IdeTemplateService +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Coverage for the host-independent parts of [MapTemplateBuilder]: the template + * display name, the `.cgt` filename it derives from that name (stripping every + * non-letter/digit char, mirroring the host's `CgtTemplateBuilder.DIR_NAME_REGEX` + * + appending `.cgt`), and [MapTemplateBuilder.unregister], which delegates to + * the host's [IdeTemplateService.unregisterTemplate] with exactly the derived + * filename. + * + * `unregister` is exercised through a hand-written [FakeTemplateService] (no mock + * framework is available here) that captures the unregister argument and returns + * a configurable result — this is the established way to test host-facing code in + * this module. + * + * `buildAndRegister` / `populateScaffold` / `addOfflineRepo` are intentionally NOT + * exercised here: they reach `ctx.androidContext.assets` (an abstract + * `AssetManager` that can't be constructed on a plain JVM without + * Robolectric/Mockk — both disallowed) via `thumbnailFromAssets`, + * `addTemplateFromAssets`, and `addStaticFromAssets`, and they invoke the host's + * real `CgtTemplateBuilder.build()` zip internals. They are covered by the + * on-device / instrumented path. See `skipped` in the agent result. + */ +class MapTemplateBuilderCoverageTest { + + /** + * Minimal hand-fake of the host's [IdeTemplateService]. Implements only the + * members `MapTemplateBuilder.unregister` touches; the rest throw so an + * accidental call surfaces loudly rather than silently passing. + */ + private class FakeTemplateService( + private val unregisterResult: Boolean, + ) : IdeTemplateService { + var unregisterArg: String? = null + private set + var unregisterCallCount: Int = 0 + private set + + override fun unregisterTemplate(templateName: String): Boolean { + unregisterArg = templateName + unregisterCallCount++ + return unregisterResult + } + + override fun createTemplateBuilder(templateName: String): CgtTemplateBuilder = + throw UnsupportedOperationException("not used by unregister") + + override fun registerTemplate(cgtFile: File): Boolean = + throw UnsupportedOperationException("not used by unregister") + + override fun isTemplateRegistered(templateName: String): Boolean = + throw UnsupportedOperationException("not used by unregister") + + override fun getRegisteredTemplates(): List = + throw UnsupportedOperationException("not used by unregister") + + override fun reloadTemplates(): Unit = + throw UnsupportedOperationException("not used by unregister") + } + + @Test + fun `unregister delegates to the host with the derived cgt filename`() { + val svc = FakeTemplateService(unregisterResult = true) + + val removed = MapTemplateBuilder.unregister(svc) + + assertTrue("unregister should return the host's true result", removed) + assertEquals("called exactly once", 1, svc.unregisterCallCount) + // The argument MUST be the exact derived filename deactivate() relies on — + // a mismatch would orphan the .cgt in the New-Project grid. + assertEquals("OfflineOSMMap.cgt", svc.unregisterArg) + assertEquals(MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE, svc.unregisterArg) + } + + @Test + fun `unregister propagates a false host result (template not present)`() { + val svc = FakeTemplateService(unregisterResult = false) + + val removed = MapTemplateBuilder.unregister(svc) + + assertFalse("a false host result must propagate, not be swallowed", removed) + assertEquals("OfflineOSMMap.cgt", svc.unregisterArg) + } + + @Test + fun `unregister does not call the host before being invoked`() { + // Guards against the delegate being wired at field-init time rather than + // on call — the host must see the unregister only when unregister() runs. + val svc = FakeTemplateService(unregisterResult = true) + assertEquals(0, svc.unregisterCallCount) + assertNull(svc.unregisterArg) + } + + @Test + fun `template name is the human-readable Offline OSM Map`() { + assertEquals("Offline OSM Map", MapTemplateBuilder.REGION_MAP_TEMPLATE_NAME) + } + + @Test + fun `cgt filename strips spaces and appends cgt`() { + // "Offline OSM Map" -> "OfflineOSMMap.cgt" — the exact string deactivate() + // passes to unregisterTemplate(), so it must match the host's derivation. + assertEquals("OfflineOSMMap.cgt", MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE) + } + + @Test + fun `cgt filename derivation preserves only letters and digits from the name`() { + val file = MapTemplateBuilder.REGION_MAP_TEMPLATE_FILE + val stem = file.removeSuffix(".cgt") + assertTrue("derived stem must end in .cgt", file.endsWith(".cgt")) + // Stem has no spaces / punctuation. + assertFalse("stem must not contain spaces", stem.contains(" ")) + assertTrue("stem must be alphanumeric only", stem.all { it.isLetterOrDigit() }) + // Every alphanumeric char of the source name survives, in order. + val expected = MapTemplateBuilder.REGION_MAP_TEMPLATE_NAME.filter { it.isLetterOrDigit() } + assertEquals(expected, stem) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt new file mode 100644 index 00000000..9501ce93 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt @@ -0,0 +1,234 @@ +package org.appdevforall.maps.templates + +import java.io.ByteArrayInputStream +import java.io.EOFException +import java.io.File +import java.io.InputStream +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URLClassLoader +import java.nio.file.Files +import javax.tools.ToolProvider +import org.appdevforall.maps.testing.ShortAssetManager +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test + +/** + * Behaviour of the generated app's loopback tile server. Compiles the shipped Java template + * (`PmtilesHttpServer.java.peb`) against the unit-test classpath and calls its helpers, so the + * test exercises the code students get rather than a copy of it. + */ +class PmtilesHttpServerTemplateTest { + + /** A stream whose `skip` always returns 0 although bytes remain, which the API allows. */ + private class NoSkipStream(bytes: ByteArray) : ByteArrayInputStream(bytes) { + override fun skip(n: Long): Long = 0 + } + + private fun invoke(method: Method, vararg args: Any?): Any? = + try { + method.invoke(null, *args) + } catch (e: InvocationTargetException) { + throw e.targetException + } + + @Test + fun `skipFully reaches the requested offset when skip returns 0 before the end`() { + val input: InputStream = NoSkipStream("0123456789".toByteArray()) + invoke(skipFully, input, 4L) + // A server that stopped at the short skip would stream from offset 0 under a 206 header. + assertEquals('4'.code, input.read()) + } + + @Test + fun `skipFully throws at the end of the stream instead of stopping short`() { + try { + invoke(skipFully, NoSkipStream("01".toByteArray()), 4L) + throw AssertionError("expected EOFException") + } catch (e: EOFException) { + assertTrue(e.message!!.contains("2 bytes left")) + } + } + + @Test + fun `skipFully with a normal stream and a zero offset leaves the position alone`() { + val input = ByteArrayInputStream("abc".toByteArray()) + invoke(skipFully, input, 0L) + assertEquals('a'.code, input.read()) + invoke(skipFully, input, 1L) + assertEquals('c'.code, input.read()) + } + + private fun range(spec: String?, total: Long): List? = + (invoke(resolveRange, spec, total) as LongArray?)?.toList() + + @Test + fun `resolveRange serves the last n bytes for a suffix range`() { + // Ignoring "-500" would answer 200 with the whole file. + assertEquals(listOf(500L, 999L), range("-500", 1000)) + assertEquals("a suffix longer than the file is the whole file", listOf(0L, 999L), range("-5000", 1000)) + assertNull("a zero-length suffix cannot be satisfied", range("-0", 1000)) + } + + @Test + fun `resolveRange handles closed, open and out-of-range starts`() { + assertEquals(listOf(0L, 999L), range(null, 1000)) + assertEquals(listOf(10L, 19L), range("10-19", 1000)) + assertEquals(listOf(10L, 999L), range("10-", 1000)) + assertEquals("an end past the file is clamped", listOf(10L, 999L), range("10-5000", 1000)) + assertNull("a start past the file cannot be satisfied", range("1000-", 1000)) + assertNull("an empty file cannot be served", range(null, 0)) + } + + @Test + fun `resolveRange treats malformed and multi-range values as the whole file`() { + for (spec in listOf("abc", "5-x", "1-2,4-5", "20-10", "")) { + assertEquals("spec '$spec'", listOf(0L, 999L), range(spec, 1000)) + } + } + + @Test + fun `a client that sends nothing is dropped after the read timeout`() { + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(null, "maps") + val handle = serverClass.getDeclaredMethod("handle", Socket::class.java).apply { isAccessible = true } + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { listener -> + Socket(listener.inetAddress, listener.localPort).use { silent -> + val accepted = listener.accept() + val worker = Thread { invokeOn(handle, server, accepted) }.apply { isDaemon = true; start() } + val timeoutMs = serverClass.getDeclaredField("READ_TIMEOUT_MS").apply { isAccessible = true }.getInt(null) + worker.join(timeoutMs + 5_000L) + // Without the timeout the handler blocks in readLine for as long as the client stays. + assertTrue("handler still blocked on a silent client", !worker.isAlive) + silent.soTimeout = 1_000 + assertEquals("the server closes the connection", -1, silent.getInputStream().read()) + } + } + } + + @Test + fun `a range the stream cannot reach answers 500, not a 206 header over the wrong bytes`() { + // The asset claims 1000 bytes but holds 10, and skip() returns 0, so skipFully fails. + val data = File(outDir, "short.pmtiles").apply { writeBytes(ByteArray(10)) } + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(ShortAssetManager.create(data, claimedLength = 1000), "maps") + val handle = serverClass.getDeclaredMethod("handle", Socket::class.java).apply { isAccessible = true } + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { listener -> + Socket(listener.inetAddress, listener.localPort).use { client -> + val accepted = listener.accept() + client.getOutputStream().write("GET /maps/tiles.pmtiles HTTP/1.1\r\nRange: bytes=500-\r\n\r\n".toByteArray()) + invokeOn(handle, server, accepted) + client.soTimeout = 2_000 + val statusLine = client.getInputStream().bufferedReader().readLine() + assertEquals("HTTP/1.1 500 Internal Server Error", statusLine) + } + } + } + + private fun statusFor(request: String): String? { + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(ShortAssetManager.missing(), "maps") + val handle = serverClass.getDeclaredMethod("handle", Socket::class.java).apply { isAccessible = true } + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { listener -> + Socket(listener.inetAddress, listener.localPort).use { client -> + val accepted = listener.accept() + client.getOutputStream().write("GET $request HTTP/1.1\r\n\r\n".toByteArray()) + invokeOn(handle, server, accepted) + client.soTimeout = 2_000 + return client.getInputStream().bufferedReader().readLine() + } + } + } + + @Test + fun `an unbundled glyph range answers an empty 200 so MapLibre still draws the tile`() { + // A 404 here makes MapLibre leave blank every tile whose labels need this range. + assertEquals("HTTP/1.1 200 OK", statusFor("/maps/fonts/notosans/768-1023.pbf")) + } + + @Test + fun `a missing file that is not a glyph range is still a 404`() { + assertEquals("HTTP/1.1 404 Not Found", statusFor("/maps/tiles.pmtiles")) + } + + @Test + fun `a tile request is answered at once while idle connections hold workers`() { + val data = File(outDir, "tiles.pmtiles").apply { writeBytes(ByteArray(100) { it.toByte() }) } + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(ShortAssetManager.create(data, claimedLength = 100), "maps") + val port = serverClass.getMethod("start").invoke(server) as Int + // MapLibre keeps several sockets open without sending on them; each holds a worker until the read timeout. + val idle = List(8) { Socket(InetAddress.getLoopbackAddress(), port) } + try { + Thread.sleep(200) + Socket(InetAddress.getLoopbackAddress(), port).use { client -> + client.soTimeout = 2_000 + client.getOutputStream().write("GET /maps/tiles.pmtiles HTTP/1.1\r\nRange: bytes=10-19\r\n\r\n".toByteArray()) + val statusLine = try { + client.getInputStream().bufferedReader().readLine() + } catch (e: java.net.SocketTimeoutException) { + "no response within 2 s" + } + assertEquals("HTTP/1.1 206 Partial Content", statusLine) + } + } finally { + idle.forEach { it.close() } + serverClass.getMethod("stop").invoke(server) + } + } + + private fun invokeOn(method: Method, target: Any, vararg args: Any?): Any? = + try { + method.invoke(target, *args) + } catch (e: InvocationTargetException) { + throw e.targetException + } + + companion object { + + private lateinit var outDir: File + private lateinit var serverClass: Class<*> + private lateinit var skipFully: Method + private lateinit var resolveRange: Method + + private val template = + File("src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb") + + @BeforeClass + @JvmStatic + fun compileTemplate() { + outDir = Files.createTempDirectory("pmtiles-server-template").toFile() + val src = File(outDir, "gen/test/PmtilesHttpServer.java") + src.parentFile.mkdirs() + src.writeText(template.readText().replace("\${{PACKAGE_NAME}}", "gen.test")) + val compiler = ToolProvider.getSystemJavaCompiler() + ?: error("no system Java compiler; run the tests on a JDK") + val rc = compiler.run( + null, null, System.err, + "-classpath", System.getProperty("java.class.path"), + "-d", outDir.absolutePath, + src.absolutePath, + ) + check(rc == 0) { "PmtilesHttpServer.java.peb did not compile (javac exit $rc)" } + val loader = URLClassLoader(arrayOf(outDir.toURI().toURL()), PmtilesHttpServerTemplateTest::class.java.classLoader) + serverClass = loader.loadClass("gen.test.PmtilesHttpServer") + skipFully = serverClass.getDeclaredMethod("skipFully", InputStream::class.java, Long::class.javaPrimitiveType) + .apply { isAccessible = true } + resolveRange = serverClass.getDeclaredMethod("resolveRange", String::class.java, Long::class.javaPrimitiveType) + .apply { isAccessible = true } + } + + @AfterClass + @JvmStatic + fun cleanup() { + outDir.deleteRecursively() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterCoverageTest.kt new file mode 100644 index 00000000..880e912e --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterCoverageTest.kt @@ -0,0 +1,53 @@ +package org.appdevforall.maps.templates + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Branch-coverage supplement for [ProjectMapEmitter]. Covers the partial-region + * branch (`tiles.pmtiles` present but the optional `basemap`/`meta` missing) where + * the existence filter prunes the copy list but the required-tiles guard still + * passes — the path the happy-path test (`ProjectMapEmitterTest`) skips by always + * providing the full three-file set. + */ +class ProjectMapEmitterCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var projectDir: File + private lateinit var appDir: File + private lateinit var cacheRoot: File + + @org.junit.Before + fun setUp() { + projectDir = tmp.newFolder("project") + appDir = File(projectDir, "app").apply { mkdirs() } + cacheRoot = tmp.newFolder("cache") + // Make this a Maps project. + File(appDir, "src/main/java/com/example/foo").apply { mkdirs() } + .also { File(it, "MapRegionActivity.kt").writeText("// stub") } + } + + private fun mapsDest(name: String) = + File(appDir, "src/main/assets/maps/$name") + + @Test + fun `apply succeeds copying only tiles when basemap and meta are absent`() { + // Region has ONLY tiles.pmtiles — the existence filter drops basemap/meta + // from the copy list, but the required-tiles guard still passes. + val region = File(cacheRoot, "spain").apply { mkdirs() } + File(region, "tiles.pmtiles").writeText("TILES") + + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) + assertTrue("apply should succeed with just tiles.pmtiles: ${result.reason}", result.success) + assertTrue("tiles.pmtiles should be copied", mapsDest("tiles.pmtiles").exists()) + // The absent optional files are simply not written. + assertFalse("basemap.pmtiles must not appear", mapsDest("basemap.pmtiles").exists()) + assertFalse("meta.json must not appear", mapsDest("meta.json").exists()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt new file mode 100644 index 00000000..1a36279c --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt @@ -0,0 +1,104 @@ +package org.appdevforall.maps.templates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * Two [ProjectMapEmitter.apply] arms the main emitter suites leave open: + * + * - a JAVA-language Maps project (`MapRegionActivity.java`, no `.kt`) must be + * recognized by `hasMapRegionActivity` — both template languages are + * first-class (see the team's both-language-paths convention); + * - the maps assets dir resolving OUTSIDE the project (symlinked + * `assets/maps` — e.g. a user "optimizing" storage by linking assets to an + * SD card path) must be refused by the canonical-containment guard, not + * written through. + */ +class ProjectMapEmitterGuardArmsTest { + + @get:Rule + val tmp = TemporaryFolder() + + /** Minimal region dir (with tiles.pmtiles) inside a cache root. */ + private fun seedRegion(cacheRoot: File): File { + val region = File(cacheRoot, "testregion").apply { mkdirs() } + File(region, "tiles.pmtiles").writeBytes(ByteArray(32) { it.toByte() }) + return region + } + + @Test + fun applies_into_a_java_language_maps_project() { + val project = tmp.newFolder("javaproj") + val javaDir = File(project, "app/src/main/java/com/example").apply { mkdirs() } + // A neighbouring non-activity file first in walk order: the detector + // must skip past it (neither name matches) and still find the .java. + File(javaDir, "AndroidManifest.xml").writeText("") + File(javaDir, "MapRegionActivity.java").writeText("public class MapRegionActivity {}") + val cacheRoot = tmp.newFolder("cache") + val region = seedRegion(cacheRoot) + + val result = ProjectMapEmitter.apply(project, region, cacheRoot) + + assertTrue("Java-only Maps project must be accepted: ${result.reason}", result.success) + val copied = File(project, "app/src/main/assets/maps/tiles.pmtiles") + assertTrue("tiles must be copied into the Java project", copied.exists()) + assertEquals(32L, copied.length()) + } + + @Test + fun refuses_when_maps_assets_dir_symlinks_out_of_the_project() { + val project = tmp.newFolder("linkproj") + val ktDir = File(project, "app/src/main/kotlin/com/example").apply { mkdirs() } + File(ktDir, "MapRegionActivity.kt").writeText("class MapRegionActivity") + // assets/ exists, but assets/maps is a symlink escaping the project. + val assets = File(project, "app/src/main/assets").apply { mkdirs() } + val outside = tmp.newFolder("outside-target") + val link = File(assets, "maps") + try { + Files.createSymbolicLink(link.toPath(), outside.toPath()) + } catch (e: Exception) { + assumeTrue("filesystem doesn't support symlinks here", false) + } + val cacheRoot = tmp.newFolder("cache") + val region = seedRegion(cacheRoot) + + val result = ProjectMapEmitter.apply(project, region, cacheRoot) + + assertFalse("symlinked maps dir must be refused", result.success) + assertTrue( + "reason should name the containment failure, got: ${result.reason}", + result.reason!!.contains("maps dir escapes"), + ) + assertFalse( + "nothing may be written through the escaping link", + File(outside, "tiles.pmtiles").exists(), + ) + } + + @Test + fun creates_nothing_outside_the_project_when_assets_symlinks_out() { + val project = tmp.newFolder("assetlinkproj") + val ktDir = File(project, "app/src/main/kotlin/com/example").apply { mkdirs() } + File(ktDir, "MapRegionActivity.kt").writeText("class MapRegionActivity") + val outside = tmp.newFolder("outside-assets") + try { + Files.createSymbolicLink(File(project, "app/src/main/assets").toPath(), outside.toPath()) + } catch (e: Exception) { + assumeTrue("filesystem doesn't support symlinks here", false) + } + val cacheRoot = tmp.newFolder("cache2") + + val result = ProjectMapEmitter.apply(project, seedRegion(cacheRoot), cacheRoot) + + assertFalse(result.success) + // Creating the directory first and checking after left an empty maps/ in the link target. + assertFalse("no maps/ dir may be created through the link", File(outside, "maps").exists()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterTest.kt new file mode 100644 index 00000000..96d02c69 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterTest.kt @@ -0,0 +1,202 @@ +package org.appdevforall.maps.templates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * JVM tests for the data-only [ProjectMapEmitter]. Pure-disk tests — no Android, + * no coroutines. Each test builds a fake project + a fake cached region in temp + * dirs and asserts the flat-overwrite copy + the require-a-Maps-project gate. + */ +class ProjectMapEmitterTest { + + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var projectDir: File + private lateinit var appDir: File + private lateinit var cacheRoot: File + + @org.junit.Before + fun setUp() { + projectDir = tmp.newFolder("project") + appDir = File(projectDir, "app").apply { mkdirs() } + cacheRoot = tmp.newFolder("cache") + } + + /** Drop a MapRegionActivity source so the project counts as a Maps project. */ + private fun addMapRegionActivity(kotlin: Boolean = false) { + val pkgDir = File(appDir, "src/main/java/com/example/foo").apply { mkdirs() } + val name = if (kotlin) "MapRegionActivity.kt" else "MapRegionActivity.java" + File(pkgDir, name).writeText("// stub") + } + + /** Create a cached region dir under the cache root with the given file names. */ + private fun makeRegion(id: String, files: List): File { + val dir = File(cacheRoot, id).apply { mkdirs() } + files.forEach { File(dir, it).writeText("$it-bytes") } + return dir + } + + private val fullRegionFiles = + listOf("tiles.pmtiles", "basemap.pmtiles", "meta.json") + + private fun mapsDest(name: String) = + File(appDir, "src/main/assets/maps/$name") + + // ----- require-a-Maps-project gate ----- + + @Test + fun `apply fails when project has no MapRegionActivity`() { + val region = makeRegion("spain", fullRegionFiles) + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) + assertFalse(result.success) + assertNotNull(result.reason) + assertTrue( + "reason should tell the user to create a Maps project: ${result.reason}", + result.reason!!.contains("Maps project"), + ) + } + + @Test + fun `apply fails cleanly when no app dir`() { + val empty = tmp.newFolder("empty-project") + val region = makeRegion("spain", fullRegionFiles) + val result = ProjectMapEmitter.apply(empty, region, cacheRoot) + assertFalse(result.success) + assertEquals("no app/ dir in $empty", result.reason) + } + + @Test + fun `hasMapRegionActivity detects kotlin and java variants`() { + assertFalse(ProjectMapEmitter.hasMapRegionActivity(appDir)) + addMapRegionActivity(kotlin = true) + assertTrue(ProjectMapEmitter.hasMapRegionActivity(appDir)) + } + + // ----- flat data copy ----- + + @Test + fun `apply copies the four data files flat into assets maps`() { + addMapRegionActivity() + val region = makeRegion("spain", fullRegionFiles) + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) + assertTrue("apply should succeed: ${result.reason}", result.success) + for (name in fullRegionFiles) { + assertTrue("$name should be copied flat", mapsDest(name).exists()) + } + // No per-region subdir, no markers. + assertFalse(File(appDir, "src/main/assets/maps/spain").exists()) + assertFalse(mapsDest("active.txt").exists()) + assertFalse(mapsDest("region-id.txt").exists()) + } + + @Test + fun `apply invokes onChunk during the copy as a cancellation seam`() { + addMapRegionActivity() + val region = makeRegion("spain", fullRegionFiles) + var calls = 0 + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) { calls++ } + assertTrue("apply should succeed: ${result.reason}", result.success) + assertTrue("onChunk should be threaded through to the copy", calls >= 1) + } + + @Test + fun `apply reports failure and cleans up when onChunk throws mid-copy`() { + addMapRegionActivity() + val region = makeRegion("spain", fullRegionFiles) + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) { + throw IllegalStateException("cancelled") + } + assertFalse("apply must report failure when the copy is cancelled", result.success) + // Any files written before the throw are rolled back. + for (name in fullRegionFiles) { + assertFalse("$name should be cleaned up on cancel", mapsDest(name).exists()) + } + } + + @Test + fun `apply overwrites a previously-applied region`() { + addMapRegionActivity() + // First region. + val first = makeRegion("spain", fullRegionFiles) + File(first, "tiles.pmtiles").writeText("SPAIN") + assertTrue(ProjectMapEmitter.apply(projectDir, first, cacheRoot).success) + assertEquals("SPAIN", mapsDest("tiles.pmtiles").readText()) + + // Second region overwrites flat. + val second = makeRegion("dakar", fullRegionFiles) + File(second, "tiles.pmtiles").writeText("DAKAR") + assertTrue(ProjectMapEmitter.apply(projectDir, second, cacheRoot).success) + assertEquals("DAKAR", mapsDest("tiles.pmtiles").readText()) + // Still flat — no spain/ or dakar/ subdir. + assertFalse(File(appDir, "src/main/assets/maps/spain").exists()) + assertFalse(File(appDir, "src/main/assets/maps/dakar").exists()) + } + + @Test + fun `apply fails when region has no tiles pmtiles`() { + addMapRegionActivity() + val region = makeRegion("spain", listOf("basemap.pmtiles", "meta.json")) + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) + assertFalse(result.success) + assertNotNull(result.reason) + } + + // ----- containment guard ----- + + @Test + fun `apply reports a maps dir it cannot create instead of failing the copy`() { + addMapRegionActivity() + val region = makeRegion("lagos", fullRegionFiles) + // A plain file where the maps directory belongs makes mkdirs() fail. + File(appDir, "src/main/assets").mkdirs() + File(appDir, "src/main/assets/maps").writeText("not a directory") + + val result = ProjectMapEmitter.apply(projectDir, region, cacheRoot) + + assertFalse(result.success) + assertTrue(result.reason, result.reason!!.startsWith("couldn't create ")) + } + + @Test + fun `apply refuses a source outside the cache root`() { + addMapRegionActivity() + // Region dir lives OUTSIDE the declared cache root. + val rogue = tmp.newFolder("rogue", "spain") + File(rogue, "tiles.pmtiles").writeText("x") + val result = ProjectMapEmitter.apply(projectDir, rogue, cacheRoot) + assertFalse(result.success) + assertNotNull(result.reason) + } + + @Test + fun `apply does not write code or patch gradle or manifest`() { + addMapRegionActivity() + val region = makeRegion("spain", fullRegionFiles) + ProjectMapEmitter.apply(projectDir, region, cacheRoot) + // No emitted source, no style.json, no manifest, no gradle written by apply. + assertFalse( + "apply must not emit a Java activity", + File(appDir, "src/main/java/com/example/foo/PmtilesHttpServer.java").exists(), + ) + assertFalse( + "apply must not write style.json", + mapsDest("style.json").exists(), + ) + assertFalse( + "apply must not create a manifest", + File(appDir, "src/main/AndroidManifest.xml").exists(), + ) + assertFalse( + "apply must not create build.gradle.kts", + File(appDir, "build.gradle.kts").exists(), + ) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt new file mode 100644 index 00000000..9e10442d --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt @@ -0,0 +1,82 @@ +package org.appdevforall.maps.testing + +import android.content.res.AssetFileDescriptor +import android.content.res.AssetManager +import java.io.File +import java.lang.invoke.MethodHandles +import java.nio.file.Files +import javax.tools.ToolProvider + +/** + * An [AssetManager] whose `openFd` claims a length the file behind it does not have, and whose + * stream's `skip` always returns 0. Drives the template server's failed-skip path on a plain JVM. + * [missing] instead has no assets at all, for the server's not-found paths. + */ +object ShortAssetManager { + + private val SOURCE = """ + package android.content.res; + + class MapsShortStream extends java.io.FileInputStream { + MapsShortStream(java.io.File f) throws java.io.FileNotFoundException { super(f); } + @Override public long skip(long n) { return 0; } + } + + class MapsShortFd extends AssetFileDescriptor { + private final java.io.File file; + private final long claimed; + MapsShortFd(java.io.File file, long claimed) { super(null, 0, claimed); this.file = file; this.claimed = claimed; } + @Override public long getLength() { return claimed; } + @Override public java.io.FileInputStream createInputStream() throws java.io.IOException { return new MapsShortStream(file); } + @Override public void close() {} + } + + public class MapsShortAssetManager extends AssetManager { + private final java.io.File file; + private final long claimed; + public MapsShortAssetManager(java.io.File file, long claimed) { this.file = file; this.claimed = claimed; } + @Override public AssetFileDescriptor openFd(String path) throws java.io.IOException { + if (file == null) throw new java.io.FileNotFoundException(path); + return new MapsShortFd(file, claimed); + } + } + """.trimIndent() + + private val managerClass: Class<*> by lazy { compileAndDefine() } + + /** An [AssetManager] serving [file] from every `openFd`, reporting [claimedLength] bytes. */ + fun create(file: File, claimedLength: Long): AssetManager = + managerClass.getConstructor(File::class.java, Long::class.javaPrimitiveType) + .newInstance(file, claimedLength) as AssetManager + + /** An [AssetManager] whose `openFd` throws for every path, as for an asset the APK lacks. */ + fun missing(): AssetManager = + managerClass.getConstructor(File::class.java, Long::class.javaPrimitiveType) + .newInstance(null, 0L) as AssetManager + + private fun compileAndDefine(): Class<*> { + val compiler = checkNotNull(ToolProvider.getSystemJavaCompiler()) { "needs a JDK compiler" } + val androidJar = File(AssetManager::class.java.protectionDomain.codeSource.location.toURI()) + val work = Files.createTempDirectory("maps-short-assets").toFile() + try { + val src = File(work, "android/content/res/MapsShortAssetManager.java") + src.parentFile.mkdirs() + src.writeText(SOURCE) + val exit = compiler.run(null, null, null, "-classpath", androidJar.absolutePath, "-d", work.absolutePath, src.absolutePath) + check(exit == 0) { "javac failed ($exit) compiling MapsShortAssetManager" } + // Same reflective lookup as TestAssetManagers: the classes must live in android.content.res. + val lookup = MethodHandles::class.java + .getMethod("privateLookupIn", Class::class.java, MethodHandles.Lookup::class.java) + .invoke(null, AssetFileDescriptor::class.java, MethodHandles.lookup()) + val define = MethodHandles.Lookup::class.java.getMethod("defineClass", ByteArray::class.java) + lateinit var manager: Class<*> + for (name in listOf("MapsShortStream", "MapsShortFd", "MapsShortAssetManager")) { + val c = define.invoke(lookup, File(work, "android/content/res/$name.class").readBytes()) as Class<*> + if (name == "MapsShortAssetManager") manager = c + } + return manager + } finally { + work.deleteRecursively() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/TestAssetManagers.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/TestAssetManagers.kt new file mode 100644 index 00000000..5429cff5 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/TestAssetManagers.kt @@ -0,0 +1,85 @@ +package org.appdevforall.maps.testing + +import android.content.res.AssetManager +import java.io.File +import java.io.InputStream +import java.lang.invoke.MethodHandles +import java.nio.file.Files +import java.util.function.Function +import javax.tools.ToolProvider + +/** + * Builds [AssetManager] instances whose `open(path)` is served by a Kotlin function, so code + * that reads plugin assets through `Context.getAssets()` can run on a plain JVM. + * + * `AssetManager` is final in the compile-time android.jar but not in the mockable runtime + * jar, so the subclass is compiled at test runtime against the runtime jar and defined in + * `android.content.res` (its constructor is package-private). + */ +object TestAssetManagers { + + private const val CLASS_NAME = "android.content.res.MapsTestAssetManager" + + private val SOURCE = """ + package android.content.res; + + public class MapsTestAssetManager extends AssetManager { + private final java.util.function.Function opener; + + public MapsTestAssetManager(java.util.function.Function opener) { + this.opener = opener; + } + + @Override + public java.io.InputStream open(String path) throws java.io.IOException { + java.io.InputStream in = opener.apply(path); + if (in == null) throw new java.io.FileNotFoundException(path); + return in; + } + } + """.trimIndent() + + private val generatedClass: Class<*> by lazy { compileAndDefine() } + + /** An [AssetManager] whose `open(path)` returns [opener]'s stream, or throws when it returns null. */ + fun create(opener: (String) -> InputStream?): AssetManager { + val ctor = generatedClass.getConstructor(Function::class.java) + return ctor.newInstance(Function { opener(it) }) as AssetManager + } + + /** An [AssetManager] that serves `open(path)` from files under [root]. */ + fun fromDirectory(root: File): AssetManager = create { path -> + File(root, path).takeIf { it.isFile }?.inputStream() + } + + private fun compileAndDefine(): Class<*> { + val compiler = checkNotNull(ToolProvider.getSystemJavaCompiler()) { + "TestAssetManagers needs a JDK (javax.tools compiler) to run the unit tests" + } + val runtimeAndroidJar = File(AssetManager::class.java.protectionDomain.codeSource.location.toURI()) + val work = Files.createTempDirectory("maps-test-assets").toFile() + try { + val src = File(work, "android/content/res/MapsTestAssetManager.java") + src.parentFile.mkdirs() + src.writeText(SOURCE) + val exit = compiler.run( + null, null, null, + "-classpath", runtimeAndroidJar.absolutePath, + "-d", work.absolutePath, + src.absolutePath, + ) + check(exit == 0) { "javac failed ($exit) compiling $CLASS_NAME" } + val bytes = File(work, "android/content/res/MapsTestAssetManager.class").readBytes() + // Called reflectively: the unit-test compile classpath puts android.jar's older + // java.lang.invoke ahead of the JDK's, and it lacks these two methods. + val lookup = MethodHandles::class.java + .getMethod("privateLookupIn", Class::class.java, MethodHandles.Lookup::class.java) + .invoke(null, AssetManager::class.java, MethodHandles.lookup()) + return MethodHandles.Lookup::class.java + .getMethod("defineClass", ByteArray::class.java) + .invoke(lookup, bytes) as Class<*> + } finally { + work.deleteRecursively() + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/UnusedPluginContext.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/UnusedPluginContext.kt new file mode 100644 index 00000000..084e3db6 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/UnusedPluginContext.kt @@ -0,0 +1,52 @@ +package org.appdevforall.maps.testing + +import android.content.Context +import android.content.SharedPreferences +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ResourceManager +import com.itsaky.androidide.plugins.ServiceRegistry +import java.io.File + +/** + * A [PluginContext] whose every member throws. Test fakes delegate to it (`PluginContext by + * UnusedPluginContext`) and override only what the code under test reads, so a new host API + * member does not break every fake. + */ +object UnusedPluginContext : PluginContext { + private fun unused(): Nothing = throw UnsupportedOperationException("not used by this test") + + override val androidContext: Context get() = unused() + override val services: ServiceRegistry get() = unused() + override val eventBus: Any get() = unused() + override val logger: PluginLogger get() = unused() + override val resources: ResourceManager get() = unused() + override val pluginId: String get() = unused() + + override fun getPluginService(pluginId: String, serviceClass: Class): T? = unused() + + override fun isPluginActive(pluginId: String): Boolean = unused() + + override fun getPluginVersion(pluginId: String): String? = unused() + + override fun registerService(serviceClass: Class, serviceImpl: T) = unused() + + override fun unregisterService(serviceClass: Class) = unused() + + override fun getProvidedServices(): List = unused() + + override fun getPluginDataDir(): File = unused() + + override fun getAppFilesDir(): File = unused() + + override fun getPluginFilesDir(): File = unused() + + override fun getAppSharedPreferences(name: String): SharedPreferences = unused() + + override fun getPluginSharedPreferences(name: String): SharedPreferences = unused() + + override fun addPluginLifecycleListener(listener: PluginLifecycleListener) = unused() + + override fun removePluginLifecycleListener(listener: PluginLifecycleListener) = unused() +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt new file mode 100644 index 00000000..cef192d0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt @@ -0,0 +1,71 @@ +package org.appdevforall.maps.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for [AutoShrinkScheduler], with a fake clock standing in for the coroutine delay. */ +class AutoShrinkSchedulerTest { + + private class FakeTimer { + private class Pending(val dueAt: Long, val action: () -> Unit, var cancelled: Boolean = false) + + private val pending = mutableListOf() + var now = 0L + private set + + fun postDelayed(delayMs: Long, action: () -> Unit): () -> Unit { + val p = Pending(now + delayMs, action) + pending += p + return { p.cancelled = true } + } + + fun advanceBy(ms: Long) { + now += ms + val due = pending.filter { !it.cancelled && it.dueAt <= now } + pending.removeAll(due) + due.forEach { it.action() } + } + } + + private val timer = FakeTimer() + private var shrinks = 0 + private val scheduler = + AutoShrinkScheduler(delayMs = 250L, postDelayed = timer::postDelayed, shrink = { shrinks++ }) + + @Test + fun `idle shrinks once the delay has passed`() { + scheduler.onCameraIdle() + timer.advanceBy(249L) + assertEquals("shrink ran before the delay", 0, shrinks) + timer.advanceBy(1L) + assertEquals("shrink count after the delay", 1, shrinks) + } + + @Test + fun `a second idle restarts the wait so only the last one shrinks`() { + scheduler.onCameraIdle() + timer.advanceBy(200L) + scheduler.onCameraIdle() + timer.advanceBy(200L) + assertEquals("shrink ran on the superseded idle", 0, shrinks) + timer.advanceBy(50L) + assertEquals("shrink count after the last idle", 1, shrinks) + } + + @Test + fun `a gesture starting during the wait cancels the pending shrink`() { + scheduler.onCameraIdle() + timer.advanceBy(100L) + scheduler.onCameraMoveStarted() + timer.advanceBy(1_000L) + assertEquals("shrink ran during the user's next gesture", 0, shrinks) + } + + @Test + fun `cancel drops the pending shrink`() { + scheduler.onCameraIdle() + scheduler.cancel() + timer.advanceBy(1_000L) + assertEquals("shrink ran after cancel", 0, shrinks) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt new file mode 100644 index 00000000..20bcf44b --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt @@ -0,0 +1,348 @@ +package org.appdevforall.maps.ui + +import org.appdevforall.maps.data.RegionSizeEstimator +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.slicer.TileEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [BboxSelectionModel] — the bbox/estimate state transitions + * extracted from `BboxPickerFragment`. Pure JVM: no Robolectric, no Android. + */ +class BboxSelectionModelTest { + + private val initialBbox = Bbox.aroundPoint(0.0, 0.0, 200.0) + + private fun newModel() = BboxSelectionModel(initialBbox) + + private fun tile(bytes: Long) = + TileEntry(z = 10, x = 1, y = 1, tileId = 0L, byteOffset = 0L, byteLength = bytes) + + // ----- Initial state ----- + + @Test + fun `initial state - constructor bbox, defaults, no fix, no estimate`() { + val m = newModel() + assertSame(initialBbox, m.currentBbox) + assertEquals(BboxPickerArgs.DEFAULT_ZOOM_MIN, m.zoomMin) + assertEquals(BboxPickerArgs.DEFAULT_ZOOM_MAX, m.zoomMax) + assertEquals(0.0, m.anchorLat, 0.0) + assertEquals(0.0, m.anchorLon, 0.0) + assertFalse(m.haveRealGpsFix) + assertNull(m.realByteEstimate) + assertFalse(m.realEstimateFailed) + assertEquals(SourceKind.UNKNOWN, m.sourceKind) + assertNull(m.sourceHost) + } + + // ----- applyLaunchArgs ----- + + @Test + fun `applyLaunchArgs seeds source context and zoom range`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.IIAB_LAN, + sourceHost = "iiab.local", + zoomMin = 4, + zoomMax = 12, + prefillBbox = null, + ) + assertEquals(SourceKind.IIAB_LAN, m.sourceKind) + assertEquals("iiab.local", m.sourceHost) + assertEquals(4, m.zoomMin) + assertEquals(12, m.zoomMax) + // No prefill → the constructor bbox stays. + assertSame(initialBbox, m.currentBbox) + } + + @Test + fun `applyLaunchArgs with a prefill bbox adopts it and re-centers the anchor`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(6.0, 3.0, 7.0, 4.0), // S, W, N, E + ) + assertEquals(6.0, m.currentBbox.south, 0.0) + assertEquals(3.0, m.currentBbox.west, 0.0) + assertEquals(7.0, m.currentBbox.north, 0.0) + assertEquals(4.0, m.currentBbox.east, 0.0) + assertEquals(6.5, m.anchorLat, 1e-9) + assertEquals(3.5, m.anchorLon, 1e-9) + // A saved-bounds prefill is not a GPS fix. + assertFalse(m.haveRealGpsFix) + } + + @Test + fun `applyLaunchArgs with an invalid prefill bbox keeps the default selection`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(7.0, 3.0, 6.0, 4.0), // south > north → invalid + ) + // The malformed bounds are dropped; the constructor default survives. + assertSame(initialBbox, m.currentBbox) + } + + @Test + fun `applyLaunchArgs ignores a wrong-size prefill array`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(1.0, 2.0, 3.0), // size 3 + ) + assertSame(initialBbox, m.currentBbox) + assertEquals(0.0, m.anchorLat, 0.0) + } + + // ----- applyBboxChange ----- + + @Test + fun `applyBboxChange adopts the bbox and invalidates the estimate`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + assertNotNull(m.realByteEstimate) + + val newBbox = Bbox.aroundPoint(10.0, 10.0, 50.0) + m.applyBboxChange(newBbox) + + assertSame(newBbox, m.currentBbox) + assertNull("bbox change must invalidate the stale estimate", m.realByteEstimate) + assertFalse(m.realEstimateFailed) + } + + @Test + fun `applyBboxChange re-caps zoomMax - a small bbox allows deeper zoom than a huge one`() { + val small = newModel().apply { applyBboxChange(Bbox.aroundPoint(10.0, 10.0, 10.0)) } + val huge = newModel().apply { applyBboxChange(Bbox(-60.0, -170.0, 60.0, 170.0)) } + assertTrue( + "10 km box (zMax=${small.zoomMax}) must out-zoom a near-world box (zMax=${huge.zoomMax})", + small.zoomMax > huge.zoomMax, + ) + assertTrue(huge.zoomMax >= huge.zoomMin) + } + + @Test + fun `applyBboxChange clears a failed estimate state`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Failed) + assertTrue(m.realEstimateFailed) + m.applyBboxChange(Bbox.aroundPoint(5.0, 5.0, 50.0)) + assertFalse(m.realEstimateFailed) + } + + // ----- onEstimateState ----- + + @Test + fun `estimate Done sums tile bytes plus overhead`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L), tile(500L)))) + // PmtilesRegionSlicer.estimateRegionBytes = Σ byteLength + 8 KB overhead. + assertEquals(1000L + 500L + 8L * 1024L, m.realByteEstimate) + assertFalse(m.realEstimateFailed) + assertFalse(m.realEstimateHasNoTiles) + } + + @Test + fun `an empty estimate is flagged until the bbox changes`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(emptyList())) + assertTrue("an empty tile list must be flagged", m.realEstimateHasNoTiles) + m.applyBboxChange(Bbox.aroundPoint(5.0, 5.0, 50.0)) + assertFalse("a bbox change must clear the flag", m.realEstimateHasNoTiles) + } + + @Test + fun `estimate Failed flags the failure but keeps the last bytes`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + val lastBytes = m.realByteEstimate + m.onEstimateState(RegionSizeEstimator.State.Failed) + assertTrue(m.realEstimateFailed) + assertEquals("Failed must not wipe the last known bytes", lastBytes, m.realByteEstimate) + } + + @Test + fun `estimate Calculating clears a stale failure flag`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Failed) + m.onEstimateState(RegionSizeEstimator.State.Calculating) + assertFalse(m.realEstimateFailed) + } + + // ----- hasSliceableSource ----- + + @Test + fun `hasSliceableSource - internet always, LAN only with a host, unknown never`() { + val m = newModel() + assertFalse("UNKNOWN source is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.INTERNET, null, 6, 14, null) + assertTrue(m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, null, 6, 14, null) + assertFalse("LAN without a host is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, " ", 6, 14, null) + assertFalse("LAN with a blank host is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, "iiab.local", 6, 14, null) + assertTrue(m.hasSliceableSource()) + } + + // ----- maybeShrinkToFit ----- + + @Test + fun `maybeShrinkToFit no-ops when the bbox is fully inside the viewport`() { + val m = newModel() + val bbox = Bbox(-1.0, -1.0, 1.0, 1.0) + m.applyBboxChange(bbox) + m.noteUserCameraGesture() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + val result = m.maybeShrinkToFit( + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, margin = 0.35, + ) + + assertNull(result) + assertSame("a no-op must not touch the selection", bbox, m.currentBbox) + assertNotNull("a no-op must not invalidate the estimate", m.realByteEstimate) + } + + @Test + fun `maybeShrinkToFit shrinks an out-of-viewport bbox and invalidates the estimate`() { + val m = newModel() + m.applyBboxChange(Bbox(0.0, 0.0, 10.0, 10.0)) + m.noteUserCameraGesture() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + val result = m.maybeShrinkToFit( + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, margin = 0.35, + ) + + assertNotNull("bbox pokes out of the viewport → must shrink", result) + assertSame(result, m.currentBbox) + // Shrunk box sits inside the viewport (inset by the margin). + assertTrue(m.currentBbox.north <= 5.0 && m.currentBbox.south >= -5.0) + assertTrue(m.currentBbox.east <= 5.0 && m.currentBbox.west >= -5.0) + assertNull("a real shrink is a bbox change → estimate invalidated", m.realByteEstimate) + } + + // ----- Auto-shrink vs prefilled (Refresh) selection — ADFA-2436 regression ----- + + /** + * On-device regression repro (A56, region Refresh): the wizard opened Step 2 + * prefilled with the region's saved bounds — meta.json bbox + * `[22.94, -25.62, 33.08, -16.19]` (south, west, north, east; ~926×1128 km) — + * and a camera-idle-driven auto-shrink check ran against a transient viewport + * of ~327×533 km while the fullscreen dialog was still settling. The shrink + * collapsed the saved selection to 30 % of that viewport ≈ **98×160 km**, a + * ~10× linear loss of the user's saved extent, before the user touched + * anything. + * + * The contract this pins: a selection the user has NOT driven the camera over + * (no pan/zoom gesture yet) is authoritative — auto-shrink must never fire on + * it, regardless of what viewport the camera-idle happens to report. + */ + @Test + fun `auto-shrink must not fire on a prefilled selection before any user camera gesture`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + // The exact saved bounds from the on-device repro (s, w, n, e). + prefillBbox = doubleArrayOf(22.94, -25.62, 33.08, -16.19), + ) + val saved = m.currentBbox + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + // Transient viewport at the idle+debounce mark: ~327×533 km centred on + // the saved box (lat span 4.79°, lon span 3.33°) — smaller than the + // selection, so the naive check sees "edges outside" and shrinks. + val result = m.maybeShrinkToFit( + viewN = 30.41, viewS = 25.62, viewE = -19.24, viewW = -22.57, margin = 0.35, + ) + + assertNull( + "a prefilled selection the user hasn't touched must never be auto-shrunk " + + "(on-device this collapsed 926×1128 km to ~98×160 km)", + result, + ) + assertSame("the saved bounds must survive untouched", saved, m.currentBbox) + assertNotNull("the estimate for the saved bounds must not be invalidated", m.realByteEstimate) + } + + @Test + fun `auto-shrink resumes for a prefilled selection after a real user camera gesture`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(22.94, -25.62, 33.08, -16.19), + ) + + // The user pans/zooms (Fragment reports REASON_API_GESTURE) — from here + // on the selection tracks the viewport, Google-Maps-style. + m.noteUserCameraGesture() + + val result = m.maybeShrinkToFit( + viewN = 30.41, viewS = 25.62, viewE = -19.24, viewW = -22.57, margin = 0.35, + ) + + assertNotNull("after a user gesture the selection must track the viewport", result) + assertSame(result, m.currentBbox) + assertTrue(m.currentBbox.north <= 30.41 && m.currentBbox.south >= 25.62) + assertTrue(m.currentBbox.east <= -19.24 && m.currentBbox.west >= -22.57) + } + + @Test + fun `default (non-prefill) selection is also protected until the first user gesture`() { + val m = newModel() + // No prefill: the constructor default (200×200 km at 0,0) is programmatic + // placement too — a spurious early camera-idle must not collapse it either. + val result = m.maybeShrinkToFit( + viewN = 0.5, viewS = -0.5, viewE = 0.5, viewW = -0.5, margin = 0.35, + ) + assertNull(result) + assertSame(initialBbox, m.currentBbox) + } + + // ----- Anchor / GPS fix ----- + + @Test + fun `updateAnchor moves the anchor without claiming a GPS fix`() { + val m = newModel() + m.updateAnchor(12.5, -3.25) + assertEquals(12.5, m.anchorLat, 0.0) + assertEquals(-3.25, m.anchorLon, 0.0) + assertFalse("a camera-idle anchor is NOT a GPS fix", m.haveRealGpsFix) + } + + @Test + fun `applyLocationFix moves the anchor and records the real fix`() { + val m = newModel() + m.applyLocationFix(6.52, 3.37) + assertEquals(6.52, m.anchorLat, 0.0) + assertEquals(3.37, m.anchorLon, 0.0) + assertTrue(m.haveRealGpsFix) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/NoPlayServicesTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/NoPlayServicesTest.kt new file mode 100644 index 00000000..14fa0eb8 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/NoPlayServicesTest.kt @@ -0,0 +1,20 @@ +package org.appdevforall.maps.ui + +import org.junit.Assert.fail +import org.junit.Test + +/** + * The plugin must load on devices without Google services, so no Play Services class may be on + * its classpath. The unit-test runtime classpath carries every `implementation` dependency. + */ +class NoPlayServicesTest { + + @Test + fun `play services location is not a dependency`() { + try { + Class.forName("com.google.android.gms.location.LocationServices") + fail("com.google.android.gms.location.LocationServices is on the classpath") + } catch (expected: ClassNotFoundException) { + } + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt new file mode 100644 index 00000000..d56aaa0a --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt @@ -0,0 +1,63 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Branch-coverage supplement for [AtomicFiles] — covers the no-parent-dir error + * branches of both [AtomicFiles.writeText] and [AtomicFiles.copy], and the + * "a stale `.tmp` already exists, delete it first" branch in each. The + * happy-path / cancellation behaviour is covered by `AtomicFilesTest`. + */ +class AtomicFilesCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `writeText throws when destination has no parent dir`() { + // A bare relative filename has no parent component → parentFile == null. + val noParent = File("atomic-files-no-parent.txt") + assertThrows(IllegalStateException::class.java) { + AtomicFiles.writeText(noParent, "x") + } + } + + @Test + fun `copy throws when destination has no parent dir`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val noParent = File("atomic-files-copy-no-parent.bin") + assertThrows(IllegalStateException::class.java) { + AtomicFiles.copy(src, noParent) + } + } + + @Test + fun `writeText deletes a stale tmp file before writing`() { + val dest = File(tmp.root, "out.txt") + // Pre-seed the sibling .tmp that a prior crashed write would have left. + val stale = File(tmp.root, "out.txt.tmp").apply { writeText("STALE") } + assertTrue(stale.exists()) + AtomicFiles.writeText(dest, "fresh") + assertEquals("fresh", dest.readText()) + // The stale tmp was deleted, then re-created+renamed, so none lingers. + assertFalse(File(tmp.root, "out.txt.tmp").exists()) + } + + @Test + fun `copy deletes a stale tmp file before writing`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "dest.bin") + val stale = File(tmp.root, "dest.bin.tmp").apply { writeText("STALE") } + assertTrue(stale.exists()) + AtomicFiles.copy(src, dest) + assertEquals("payload", dest.readText()) + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt new file mode 100644 index 00000000..92207206 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt @@ -0,0 +1,116 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [AtomicFiles] — the shared write / copy / overwrite / mkdir + * behaviour relied on by RegionDownloader, RegionInstaller, and ProjectMapEmitter. + */ +class AtomicFilesTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `writeText creates the file with the given content`() { + val dest = File(tmp.root, "out.txt") + AtomicFiles.writeText(dest, "hello") + assertTrue(dest.exists()) + assertEquals("hello", dest.readText()) + } + + @Test + fun `writeText overwrites an existing file`() { + val dest = File(tmp.root, "out.txt") + dest.writeText("old") + AtomicFiles.writeText(dest, "new") + assertEquals("new", dest.readText()) + } + + @Test + fun `writeText creates missing parent directories`() { + val dest = File(tmp.root, "a/b/c/deep.txt") + assertFalse(dest.parentFile!!.exists()) + AtomicFiles.writeText(dest, "deep") + assertTrue(dest.exists()) + assertEquals("deep", dest.readText()) + } + + @Test + fun `writeText leaves no tmp file behind on success`() { + val dest = File(tmp.root, "out.txt") + AtomicFiles.writeText(dest, "x") + assertFalse(File(tmp.root, "out.txt.tmp").exists()) + } + + @Test + fun `copy duplicates source content to a new destination`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "nested/dest.bin") + AtomicFiles.copy(src, dest) + assertTrue(dest.exists()) + assertEquals("payload", dest.readText()) + // Source is untouched. + assertEquals("payload", src.readText()) + } + + @Test + fun `copy overwrites an existing destination`() { + val src = File(tmp.root, "src.bin").apply { writeText("fresh") } + val dest = File(tmp.root, "dest.bin").apply { writeText("stale") } + AtomicFiles.copy(src, dest) + assertEquals("fresh", dest.readText()) + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + } + + @Test + fun `copy invokes onChunk at least once per copy`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "dest.bin") + var calls = 0 + AtomicFiles.copy(src, dest) { calls++ } + assertTrue("onChunk should be invoked during the copy", calls >= 1) + assertEquals("payload", dest.readText()) + } + + @Test + fun `copy invokes onChunk multiple times for a multi-chunk source`() { + // Source larger than the 512 KB copy buffer forces more than one chunk, + // so onChunk (the cancellation checkpoint) fires multiple times. + val src = File(tmp.root, "big.bin").apply { writeBytes(ByteArray(1_500_000) { 7 }) } + val dest = File(tmp.root, "big-dest.bin") + var calls = 0 + AtomicFiles.copy(src, dest) { calls++ } + assertTrue("expected multiple chunk checkpoints, got $calls", calls >= 3) + assertEquals(1_500_000L, dest.length()) + } + + @Test + fun `copy that is cancelled via onChunk leaves no temp and does not touch destination`() { + val src = File(tmp.root, "big.bin").apply { writeBytes(ByteArray(1_500_000) { 1 }) } + val dest = File(tmp.root, "dest.bin").apply { writeText("ORIGINAL") } + var calls = 0 + val boom = try { + // Throw on the second checkpoint to simulate mid-copy cancellation. + AtomicFiles.copy(src, dest) { + calls++ + if (calls == 2) throw IllegalStateException("cancelled") + } + null + } catch (e: IllegalStateException) { + e + } + assertTrue("the throwing onChunk should propagate", boom != null) + // The partial temp must be cleaned up... + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + // ...and the pre-existing destination must be left intact (atomic move + // never ran). + assertEquals("ORIGINAL", dest.readText()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt new file mode 100644 index 00000000..6677f5ed --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt @@ -0,0 +1,50 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [formatByteSize]. The Region Manager cache footer and + * each region-row size label flow through this helper; a mis-formatted + * "0.0 MB" for a small-but-nonzero file was a real bug, so the unit-cap + * behaviour is load-bearing. + */ +class ByteSizeTest { + + @Test + fun smallByteCountsUseBytes() { + assertEquals("0 B", formatByteSize(0)) + assertEquals("8 B", formatByteSize(8)) + assertEquals("1023 B", formatByteSize(1023)) + } + + @Test + fun kilobyteRange() { + assertEquals("1.0 KB", formatByteSize(1024)) + assertEquals("9.8 KB", formatByteSize(10_000)) + // Just under 1 MB. + assertEquals("1023.9 KB", formatByteSize(1024L * 1024L - 100)) + } + + @Test + fun megabyteRange() { + assertEquals("1.0 MB", formatByteSize(1024L * 1024L)) + assertEquals("12.4 MB", formatByteSize(13_000_000)) + // Just under 1 GB. + assertEquals("1023.9 MB", formatByteSize(1024L * 1024L * 1024L - 100_000)) + } + + @Test + fun gigabyteRange() { + assertEquals("1.00 GB", formatByteSize(1024L * 1024L * 1024L)) + assertEquals("12.40 GB", formatByteSize(13_314_572_288L)) + } + + @Test + fun b8RegressionStubsDoNotRoundToZeroMb() { + // The B8 symptom: a 6-byte stub being labelled "0.0 MB". This + // helper must surface the actual byte count instead. + val stubBytes = 6L + assertEquals("6 B", formatByteSize(stubBytes)) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/RunCatchingCancellableTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/RunCatchingCancellableTest.kt new file mode 100644 index 00000000..463e2704 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/util/RunCatchingCancellableTest.kt @@ -0,0 +1,58 @@ +package org.appdevforall.maps.util + +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.data.RegionCache +import org.appdevforall.maps.data.RegionInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunCatchingCancellableTest { + + @Test + fun `a value and an ordinary failure come back as a Result`() { + assertEquals(Result.success(3), runCatchingCancellable { 3 }) + val failure = runCatchingCancellable { throw IOException("disk") } + assertEquals("disk", failure.exceptionOrNull()?.message) + } + + @Test + fun `a cancellation is rethrown, not returned`() { + val thrown = runCatching { runCatchingCancellable { throw CancellationException("stop") } } + .exceptionOrNull() + assertTrue("got $thrown", thrown is CancellationException) + } + + /** Runs [block] in a job that is already cancelled and returns what it produced. */ + private fun inCancelledJob(block: suspend () -> T): Result { + var outcome: Result? = null + runBlocking { + launch { + coroutineContext.job.cancel() + outcome = runCatching { block() } + }.join() + } + return outcome!! + } + + @Test + fun `listing the cache in a cancelled job cancels instead of reporting no regions`() { + val outcome = inCancelledJob { RegionCache.list() } + assertTrue("got $outcome", outcome.exceptionOrNull() is CancellationException) + } + + @Test + fun `deleting a region in a cancelled job cancels instead of reporting failure`() { + val info = RegionInfo( + regionId = "dhaka", displayName = "Dhaka", sizeBytes = 0L, downloadedAt = null, + lastUsedAt = null, source = "internet", directory = File("dhaka"), + ) + val outcome = inCancelledJob { RegionCache.delete(info) } + assertTrue("got $outcome", outcome.exceptionOrNull() is CancellationException) + } +} diff --git a/plugins/Maps/src/test/resources/iiab/maps-catalog-2026-10-14.json b/plugins/Maps/src/test/resources/iiab/maps-catalog-2026-10-14.json new file mode 100644 index 00000000..5d343cc5 --- /dev/null +++ b/plugins/Maps/src/test/resources/iiab/maps-catalog-2026-10-14.json @@ -0,0 +1,48 @@ +{ + "README": [ + "Catalog of the latest available IIAB Maps data:", + "https://github.com/iiab/iiab/blob/master/roles/maps/maps-catalog.json", + "ASSUME ALL OTHER COPIES of maps-catalog.json (INCLUDING THE ONE ON YOUR IIAB) ARE STALE AND OUT OF DATE!", + "Catalog Guide: https://github.com/iiab/iiab/blob/master/roles/maps/MAPS_CATALOG_DETAILS.md", + "Full Documentation: https://github.com/iiab/iiab/blob/master/roles/maps/README.md", + "Raw file listing: https://iiab.switnet.org/maps/2/" + ], + "data": { + "vector": { + "1-ci": "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z01.pmtiles", + "nat-z8": "https://iiab.switnet.org/maps/2/naturalearth-openmaptiles.2025-12-10.z00-z08.pmtiles", + "11": "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z11.pmtiles", + "14": "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + }, + "satellite": { + "4-ci": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z04.pmtiles", + "7": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z07.pmtiles", + "7-iiab": "https://iiab.io/content/iiab-sentinel2.2026-07-25.z00-z07.pmtiles", + "9": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z09.pmtiles", + "9-iiab": "https://iiab.io/content/iiab-sentinel2.2026-07-25.z00-z09.pmtiles", + "11": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z11.pmtiles", + "12": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z12.pmtiles", + "13": "https://iiab.switnet.org/maps/2/s2maps-sentinel2-2023.2025-12-10.z00-z13.pmtiles", + "13-iiab": "https://iiab.io/content/iiab-sentinel2.2026-07-25.z00-z13.pmtiles" + }, + "terrain": { + "0-none": "https://iiab.switnet.org/maps/2/terrarium-none.pmtiles", + "7": "https://iiab.switnet.org/maps/2/terrarium.2025-12-10.z00-z07.pmtiles", + "8": "https://iiab.switnet.org/maps/2/terrarium.2025-12-10.z00-z08.pmtiles", + "9": "https://iiab.switnet.org/maps/2/terrarium.2025-12-10.z00-z09.pmtiles", + "10": "https://iiab.switnet.org/maps/2/terrarium.2025-12-10.z00-z10.pmtiles" + }, + "naturalearth6": { + "4-ci": "https://iiab.switnet.org/maps/2/naturalearth6-NE2_HR_SR_W_DR-WEBP.2025-12-10.z00-z04.pmtiles", + "6": "https://iiab.switnet.org/maps/2/naturalearth6-NE2_HR_SR_W_DR-WEBP.2025-12-10.z00-z06.pmtiles" + }, + "static_search": { + "pop-1k-cities": "https://iiab.switnet.org/maps/2/static-search.2026-04-22.pop-1k-cities.tar.gz", + "pop-100k-cities": "https://iiab.switnet.org/maps/2/static-search.2026-04-22.pop-100k-cities.tar.gz" + }, + "nominatim": { + "basic": "https://iiab.switnet.org/maps/2/nominatim.2025-12-10.basic.sqlite", + "full": "https://iiab.switnet.org/maps/2/nominatim.2025-12-10.full.sqlite" + } + } +} \ No newline at end of file diff --git a/plugins/Maps/src/test/resources/slicer/go-pmtiles-extract-ne-z1-z4.txt b/plugins/Maps/src/test/resources/slicer/go-pmtiles-extract-ne-z1-z4.txt new file mode 100644 index 00000000..ea047685 --- /dev/null +++ b/plugins/Maps/src/test/resources/slicer/go-pmtiles-extract-ne-z1-z4.txt @@ -0,0 +1,20 @@ +# Tiles that go-pmtiles `pmtiles extract` writes for one bbox of the bundled Natural Earth archive. +# CLI: go-pmtiles 1.31.2, Homebrew bottle pmtiles--1.31.2 (arm64; the binary itself reports "dev"). +# Command: pmtiles extract natural-earth-z0-z4.pmtiles out.pmtiles --bbox=5.3,-12.4,48.7,17.6 --minzoom=1 --maxzoom=4 +# Input: src/main/assets/maps/natural-earth-z0-z4.pmtiles, sha256 2625e2a58728849d12ab9f444cd979e657f65a75024d678ec2d686fd877e8944 +# Captured: 2026-10-27 +# Columns: PMTiles tile id, sha256 of the stored tile bytes. +3 7a11536ba3114e60ff236874a4057bc57551bf17d86d40cdf2ca07dfbc182784 +4 951b405b257d62bd55990fa846c68c976d1160f764c9b02057bd74e368e3c53f +13 26e99416ce5fd734fa5945ab11f3915c1d627fa242064e3c2ac9ce8672661bde +18 ef9e48e5216d1637c952a57a039a686d20bdd5e0dadba2a4522f5d9cfeff4815 +53 87865119346d1253e8f48e221735ac6370055e4657a2d3ceedb726bee137a03f +54 0b6e8db8f460dd2bda59064d91adfd153c47be03a36ecfd01e3e08ae095d2b57 +73 15023da1871e4abc61fbed05fa805c55466e93ec573aa7fb7a6712053c68ff9c +74 dfa3e20f47e4eca7d79f527714d169cc4fd0358bfa2d52c762a9278819cbc133 +213 a8b471142326cfddcc7e70eed4a4a01d5684a7593ab7f3a2c5fbcd0dd535103f +216 0ea0604cbeeb05b5d49bc55ade4e83a8a43d1332f118c6e79d0fd940fde240e4 +217 5e77e6b186f824ecdf5c53656c3b2c7c447b3d400b1ab3fbc570b5d7f6d51925 +296 b494cf079c6e7afdc402151c92141436b35c4b49d35404a10a4f76e1ca515626 +297 4c1ca6069885407c93a4914fbc96399b2562447259d1e6f7501bee8e6170dec4 +298 fd845618f8fb62d844999e44686df322774c2f54b856520738e1b2743a28fb97 diff --git a/plugins/Maps/src/test/resources/templates/vendored-repo-resolved-files.txt b/plugins/Maps/src/test/resources/templates/vendored-repo-resolved-files.txt new file mode 100644 index 00000000..c6b56af2 --- /dev/null +++ b/plugins/Maps/src/test/resources/templates/vendored-repo-resolved-files.txt @@ -0,0 +1,48 @@ +# Every file a generated Offline OSM Map project resolves from offline-maplibre-repo. +# Captured: 2026-10-27, from the template at 78ed1dd rendered for Kotlin and for Java +# (AGP 9.3.1, Kotlin 2.3.21, compileSdk 36, the versions Code on the Go supplies). +# Method: Gradle 9.6.1 --offline with an empty GRADLE_USER_HOME, repositories = CoGo's bundled +# localMvnRepository.zip (CodeOnTheGo adfa-4931-restack-1018, sha1 a46f061601958668413adf7a654cd23335f2ec59) +# plus this repo, in both orders; every resolvable :app configuration, plus assembleDebug. +# Both languages resolve the same files. Re-capture when the template's dependencies change. +# Re-captured 2026-10-27 at 1c028e8 with every other version dir removed: same 38 files, same +# full resolved set (1161 entries per run), assembleDebug green in all four runs. +# A shipped jar or aar missing from this list fails MapTemplateAssetsTest. +androidx/activity/activity/1.8.1/activity-1.8.1.aar +androidx/annotation/annotation-experimental/1.4.0/annotation-experimental-1.4.0.aar +androidx/annotation/annotation-jvm/1.8.2/annotation-jvm-1.8.2.jar +androidx/arch/core/core-common/2.2.0/core-common-2.2.0.jar +androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0.aar +androidx/collection/collection/1.1.0/collection-1.1.0.jar +androidx/concurrent/concurrent-futures/1.1.0/concurrent-futures-1.1.0.jar +androidx/constraintlayout/constraintlayout-solver/2.0.1/constraintlayout-solver-2.0.1.jar +androidx/constraintlayout/constraintlayout/2.0.1/constraintlayout-2.0.1.aar +androidx/core/core-ktx/1.13.1/core-ktx-1.13.1.aar +androidx/core/core-ktx/1.9.0/core-ktx-1.9.0.aar +androidx/core/core/1.13.1/core-1.13.1.aar +androidx/fragment/fragment/1.8.9/fragment-1.8.9.aar +androidx/lifecycle/lifecycle-common/2.6.2/lifecycle-common-2.6.2.jar +androidx/lifecycle/lifecycle-livedata-core/2.6.2/lifecycle-livedata-core-2.6.2.aar +androidx/lifecycle/lifecycle-livedata/2.6.2/lifecycle-livedata-2.6.2.aar +androidx/lifecycle/lifecycle-process/2.6.2/lifecycle-process-2.6.2.aar +androidx/lifecycle/lifecycle-runtime/2.6.2/lifecycle-runtime-2.6.2.aar +androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.2/lifecycle-viewmodel-savedstate-2.6.2.aar +androidx/lifecycle/lifecycle-viewmodel/2.6.2/lifecycle-viewmodel-2.6.2.aar +androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1.aar +androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1.aar +androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.aar +com/google/code/gson/gson/2.10.1/gson-2.10.1.jar +com/jakewharton/timber/timber/5.0.1/timber-5.0.1.aar +com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar +com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar +org/jetbrains/annotations/13.0/annotations-13.0.jar +org/jetbrains/annotations/20.1.0/annotations-20.1.0.jar +org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.jar +org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.10/kotlin-stdlib-jdk7-1.9.10.jar +org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.jar +org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.10/kotlin-stdlib-jdk8-1.9.10.jar +org/jetbrains/kotlin/kotlin-stdlib/2.2.10/kotlin-stdlib-2.2.10.jar +org/maplibre/gl/android-sdk-geojson/6.0.1/android-sdk-geojson-6.0.1.jar +org/maplibre/gl/android-sdk-opengl/13.1.0/android-sdk-opengl-13.1.0.aar +org/maplibre/gl/android-sdk-turf/6.0.1/android-sdk-turf-6.0.1.jar +org/maplibre/gl/maplibre-android-gestures/0.0.4/maplibre-android-gestures-0.0.4.aar