diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1e5a3f7..9c46c02 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -158,7 +158,7 @@ Run unit tests: ### Test Coverage -The project enforces a **minimum test coverage of 20%** for all code. Coverage is measured with the Android Gradle Plugin's built-in JaCoCo support. +The project enforces a **minimum test coverage of 75%** for all code. Coverage is measured with the Android Gradle Plugin's built-in JaCoCo support. Generate coverage report: @@ -216,7 +216,7 @@ The CI pipeline runs automatically on all pull requests and includes: 2. **Copyright Check** - Verifies MIT license headers 3. **Static Analysis** - Runs Android Lint 4. **Unit Tests** - Runs all JVM unit tests -5. **Test Coverage** - Ensures minimum 20% code coverage +5. **Test Coverage** - Ensures minimum 75% code coverage 6. **Build Library** - Builds the OpenMapView AAR 7. **Build Examples** - Builds all example applications diff --git a/docs/GITHUB_WORKFLOWS.md b/docs/GITHUB_WORKFLOWS.md index 680b42f..5c6c824 100644 --- a/docs/GITHUB_WORKFLOWS.md +++ b/docs/GITHUB_WORKFLOWS.md @@ -116,7 +116,7 @@ instrumented-test (Phone + Automotive) runs in parallel 3. **lint** - Runs Android Lint 4. **test** - Runs unit tests (depends on format + copyright + lint) 5. **docs** - Builds API documentation with Dokka (depends on format + copyright + lint) -6. **coverage** - Checks test coverage meets 20% minimum (depends on format + copyright + lint) +6. **coverage** - Checks test coverage meets 75% minimum (depends on format + copyright + lint) 7. **build-library** - Builds library AAR (depends on all above, runs in parallel with build-examples) 8. **build-examples** - Builds example APKs (depends on all above, runs in parallel with build-library) 9. **instrumented-test** - Runs instrumentation tests on phone and automotive emulators (PR only) diff --git a/docs/TESTING_UNIT.md b/docs/TESTING_UNIT.md index 1271e7b..46a2d65 100644 --- a/docs/TESTING_UNIT.md +++ b/docs/TESTING_UNIT.md @@ -50,30 +50,42 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin ## Test Structure -### Current Test Coverage (260+ tests across 21 test classes) +### Current Test Coverage (456 tests across 35 test classes) | Test Class | Tests | Description | |------------|-------|-------------| | **AttributionOverlayTest** | 4 | Attribution rendering and touch detection | -| **BitmapDescriptorFactoryTest** | 18 | Marker icon generation with colors and custom images | -| **CameraPositionTest** | 6 | Camera position validation and equality | -| **CameraUpdateFactoryTest** | 8 | Camera update creation (zoom, pan, position) | +| **BitmapDescriptorFactoryTest** | 15 | Marker icon generation with colors and custom images | +| **CameraPositionTest** | 7 | Camera position validation and equality | +| **CameraUpdateFactoryTest** | 11 | Camera update creation (zoom, pan, position) | +| **CircleTest** | 4 | Circle defaults, radius validation, identity equality | | **DiskTileCacheTest** | 8 | Persistent disk cache with DiskLruCache, per-source namespaces | | **GeoJsonParserTest** | 14 | GeoJSON parsing (Point, LineString, Polygon, Multi*, Features) | -| **IntentUtilsTest** | 4 | External app integration (geo: URI, OSM fallback) | +| **GeodesicUtilsTest** | 17 | Great-circle interpolation for geodesic shapes | +| **GroundOverlayTest** | 10 | Ground overlay position/bounds validation and equality | +| **IntentUtilsTest** | 7 | External app integration (geo: URI, OSM fallback) | | **LatLngBoundsTest** | 13 | Lat/lng bounds calculations and contains checks | -| **MapControllerTest** | 63 | Zoom, pan, marker/polyline/polygon/circle management, touch detection, padding | -| **MapFeaturesTest** | 4 | Feature query methods (traffic, indoor, buildings, myLocation) | -| **MapTypeTest** | 9 | Map type switching and validation | -| **MarkerTest** | 24 | Marker creation, equality, properties, info windows, dragging | -| **PolygonTest** | 16 | Polygon creation, validation, holes, and styling | -| **PolylineTest** | 16 | Polyline creation, validation, and styling | +| **LatLngTest** | 11 | Latitude clamping and longitude wrapping | +| **MapControllerOverlaysTest** | 13 | Circles, ground and tile overlays, their touch detection, info windows, drawing all shape types | +| **MapControllerTest** | 103 | Zoom, pan, marker/polyline/polygon management, touch detection, padding, camera bounds, edge effects | +| **MapFeaturesTest** | 5 | Feature query methods (traffic, indoor, buildings, myLocation) | +| **MapProjectionTest** | 8 | Projection screen/LatLng conversion, pan offset, padding, visible region | +| **MapTypeTest** | 18 | Map type switching and validation | +| **MarkerIconFactoryTest** | 3 | Default marker icon generation and caching | +| **MarkerTest** | 23 | Marker creation, equality, properties, info windows, dragging | +| **OptionsBuildersTest** | 13 | Google-style *Options builders for markers, shapes and overlays | +| **PolygonTest** | 20 | Polygon creation, validation, holes, and styling | +| **PolylineTest** | 31 | Polyline creation, validation, and styling | +| **PredefinedTileProvidersTest** | 1 | URL templates of the predefined overlay tile providers | | **ProjectionTest** | 12 | Web Mercator projection calculations | +| **SnapshotTest** | 8 | Map snapshot API and callbacks | | **TileCacheTest** | 9 | Memory LRU cache behavior, clearing, namespaces, disk writes | | **TileDownloaderTest** | 5 | HTTP tile downloading with mocked Ktor client, HTTP errors, cancellation | +| **TileOverlayTest** | 5 | Tile overlay defaults, transparency validation, copy helpers | | **TileRetryTrackerTest** | 4 | Exponential backoff for failed tiles | | **TileSourceTest** | 7 | Tile source URL generation and attribution | -| **UiSettingsTest** | 19 | UI settings (gestures, zoom controls, not-implemented features) | +| **TileTest** | 6 | Tile content equality, validation and toString | +| **UiSettingsTest** | 25 | UI settings (gestures, zoom controls, not-implemented features) | | **UrlTileProviderTest** | 2 | Overlay tile downloading, HTTP 404 handling | | **ViewportCalculatorTest** | 10 | Visible tile calculation | | **VisibleRegionTest** | 4 | Visible region bounds and corners | @@ -424,7 +436,8 @@ The test suite currently covers: - Memory cache (LRU) behavior - Disk cache (persistent storage) - Viewport calculation -- MapController rendering logic +- MapController rendering logic for all shape types, ground and tile overlays +- Options builders and the public Projection class - Touch gesture handling (marker and attribution hit detection) - Zoom level validation and bounds - Network tile downloading (with mocking) diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index 06087b4..9dfda21 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -1357,253 +1357,6 @@ class MapController( } } - private fun drawPolylines( - canvas: Canvas, - centerPixelX: Float, - centerPixelY: Float, - ) { - val paint = Paint() - paint.style = Paint.Style.STROKE - paint.isAntiAlias = true - - for (polyline in polylines) { - if (!polyline.visible || polyline.points.size < 2) continue - - // Expand points with geodesic interpolation if needed - val points = - if (polyline.geodesic) { - GeodesicUtils.expandWithGeodesicPoints(polyline.points) - } else { - polyline.points - } - - // Handle spans (multi-color segments) or single color - if (polyline.spans.isNotEmpty()) { - drawPolylineWithSpans(canvas, polyline, points, centerPixelX, centerPixelY) - } else { - paint.color = polyline.strokeColor.toArgb() - paint.strokeWidth = polyline.strokeWidth - paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() - paint.strokeCap = polyline.startCap.toAndroidCap() // Use startCap for full path - paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() - - val path = android.graphics.Path() - var isFirst = true - - for (point in points) { - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) - val screenX = pixelX - centerPixelX + viewWidth / 2 - panOffsetX - val screenY = pixelY - centerPixelY + viewHeight / 2 - panOffsetY - - if (isFirst) { - path.moveTo(screenX, screenY) - isFirst = false - } else { - path.lineTo(screenX, screenY) - } - } - - canvas.drawPath(path, paint) - } - } - } - - private fun drawPolylineWithSpans( - canvas: Canvas, - polyline: Polyline, - points: List, - centerPixelX: Float, - centerPixelY: Float, - ) { - val paint = Paint() - paint.style = Paint.Style.STROKE - paint.isAntiAlias = true - paint.strokeWidth = polyline.strokeWidth - paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() - paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() - - var segmentIndex = 0 - val totalSegments = points.size - 1 - - for (span in polyline.spans) { - if (segmentIndex >= totalSegments) break - - paint.color = span.color.toArgb() - - for (i in 0 until span.segments) { - if (segmentIndex >= totalSegments) break - - val isFirstSegment = segmentIndex == 0 - val isLastSegment = segmentIndex == totalSegments - 1 - - // Apply appropriate cap - paint.strokeCap = - when { - isFirstSegment -> polyline.startCap.toAndroidCap() - isLastSegment -> polyline.endCap.toAndroidCap() - else -> Paint.Cap.BUTT - } - - val from = points[segmentIndex] - val to = points[segmentIndex + 1] - - val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) - val fromScreenX = fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX - val fromScreenY = fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY - - val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) - val toScreenX = toPixelX - centerPixelX + viewWidth / 2 - panOffsetX - val toScreenY = toPixelY - centerPixelY + viewHeight / 2 - panOffsetY - - canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) - segmentIndex++ - } - } - - // Draw remaining segments with default color - if (segmentIndex < totalSegments) { - paint.color = polyline.strokeColor.toArgb() - - while (segmentIndex < totalSegments) { - val isLastSegment = segmentIndex == totalSegments - 1 - paint.strokeCap = if (isLastSegment) polyline.endCap.toAndroidCap() else Paint.Cap.BUTT - - val from = points[segmentIndex] - val to = points[segmentIndex + 1] - - val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) - val fromScreenX = fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX - val fromScreenY = fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY - - val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) - val toScreenX = toPixelX - centerPixelX + viewWidth / 2 - panOffsetX - val toScreenY = toPixelY - centerPixelY + viewHeight / 2 - panOffsetY - - canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) - segmentIndex++ - } - } - } - - private fun drawPolygons( - canvas: Canvas, - centerPixelX: Float, - centerPixelY: Float, - ) { - val fillPaint = Paint() - fillPaint.style = Paint.Style.FILL - fillPaint.isAntiAlias = true - - val strokePaint = Paint() - strokePaint.style = Paint.Style.STROKE - strokePaint.isAntiAlias = true - - for (polygon in polygons) { - if (!polygon.visible || polygon.points.size < 3) continue - - // Expand points with geodesic interpolation if needed - val points = - if (polygon.geodesic) { - GeodesicUtils.expandWithGeodesicPoints(polygon.points) - } else { - polygon.points - } - - fillPaint.color = polygon.fillColor.toArgb() - strokePaint.color = polygon.strokeColor.toArgb() - strokePaint.strokeWidth = polygon.strokeWidth - strokePaint.pathEffect = polygon.strokePattern?.asAndroidPathEffect() - strokePaint.strokeCap = polygon.strokeCap.toAndroidCap() - strokePaint.strokeJoin = polygon.strokeJoin.toAndroidJoin() - - val path = android.graphics.Path() - path.fillType = android.graphics.Path.FillType.EVEN_ODD - - // Draw main polygon outline - var isFirst = true - for (point in points) { - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) - val screenX = pixelX - centerPixelX + viewWidth / 2 - panOffsetX - val screenY = pixelY - centerPixelY + viewHeight / 2 - panOffsetY - - if (isFirst) { - path.moveTo(screenX, screenY) - isFirst = false - } else { - path.lineTo(screenX, screenY) - } - } - path.close() - - // Draw holes - for (hole in polygon.holes) { - if (hole.size < 3) continue - // Expand hole points with geodesic interpolation if needed - val holePoints = - if (polygon.geodesic) { - GeodesicUtils.expandWithGeodesicPoints(hole) - } else { - hole - } - isFirst = true - for (point in holePoints) { - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) - val screenX = pixelX - centerPixelX + viewWidth / 2 - panOffsetX - val screenY = pixelY - centerPixelY + viewHeight / 2 - panOffsetY - - if (isFirst) { - path.moveTo(screenX, screenY) - isFirst = false - } else { - path.lineTo(screenX, screenY) - } - } - path.close() - } - - // Draw fill first, then stroke - canvas.drawPath(path, fillPaint) - canvas.drawPath(path, strokePaint) - } - } - - private fun drawCircles( - canvas: Canvas, - centerPixelX: Float, - centerPixelY: Float, - ) { - val fillPaint = Paint() - fillPaint.style = Paint.Style.FILL - fillPaint.isAntiAlias = true - - val strokePaint = Paint() - strokePaint.style = Paint.Style.STROKE - strokePaint.isAntiAlias = true - - for (circle in circles) { - if (!circle.visible || circle.radius <= 0) continue - - fillPaint.color = circle.fillColor.toArgb() - strokePaint.color = circle.strokeColor.toArgb() - strokePaint.strokeWidth = circle.strokeWidth - strokePaint.pathEffect = circle.strokePattern?.asAndroidPathEffect() - strokePaint.strokeCap = circle.strokeCap.toAndroidCap() - strokePaint.strokeJoin = circle.strokeJoin.toAndroidJoin() - - // Convert center to screen coordinates - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(circle.center, zoom.toInt()) - val screenX = pixelX - centerPixelX + viewWidth / 2 - panOffsetX - val screenY = pixelY - centerPixelY + viewHeight / 2 - panOffsetY - - // Convert radius from meters to pixels - val radiusInPixels = metersToPixels(circle.radius, circle.center.latitude, zoom) - - // Draw fill first, then stroke - canvas.drawCircle(screenX, screenY, radiusInPixels, fillPaint) - canvas.drawCircle(screenX, screenY, radiusInPixels, strokePaint) - } - } - /** * Converts a distance in meters to screen pixels at a given latitude and zoom level. * Uses the Mercator projection formula. @@ -1622,44 +1375,6 @@ class MapController( return (meters / metersPerPixel).toFloat() } - private fun drawMarkers( - canvas: Canvas, - centerPixelX: Float, - centerPixelY: Float, - ) { - for (marker in markers) { - // Skip invisible markers - if (!marker.visible) continue - - // Convert marker position to pixel coordinates - val (markerPixelX, markerPixelY) = ProjectionUtils.latLngToPixel(marker.position, zoom.toInt()) - - // Calculate screen position - val screenX = markerPixelX - centerPixelX + viewWidth / 2 - panOffsetX - val screenY = markerPixelY - centerPixelY + viewHeight / 2 - panOffsetY - - // Get marker icon - val icon = loadBitmap(marker.icon) - - // Apply anchor point - val anchorX = icon.width * marker.anchor.first - val anchorY = icon.height * marker.anchor.second - - // Create paint with alpha if needed - val paint = - if (marker.alpha < 1.0f) { - Paint().apply { - alpha = (marker.alpha * 255).toInt() - } - } else { - null - } - - // Draw the marker - canvas.drawBitmap(icon, screenX - anchorX, screenY - anchorY, paint) - } - } - private fun drawGroundOverlay( canvas: Canvas, centerPixelX: Float, diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/CircleTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/CircleTest.kt new file mode 100644 index 0000000..7735039 --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/CircleTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CircleTest { + private val center = LatLng(51.4661, 7.2491) + + @Test + fun testDefaults() { + val circle = Circle(center = center, radius = 100f) + + assertEquals(center, circle.center) + assertEquals(100f, circle.radius) + assertEquals(Color.Black, circle.strokeColor) + assertEquals(10f, circle.strokeWidth) + assertNull(circle.strokePattern) + assertEquals(StrokeCap.Round, circle.strokeCap) + assertTrue(circle.visible) + assertFalse(circle.clickable) + assertEquals(0f, circle.zIndex) + assertNull(circle.tag) + } + + @Test(expected = IllegalArgumentException::class) + fun testZeroRadiusThrows() { + Circle(center = center, radius = 0f) + } + + @Test(expected = IllegalArgumentException::class) + fun testNegativeRadiusThrows() { + Circle(center = center, radius = -5f) + } + + @Test + fun testEqualityIsByIdentity() { + val circle1 = Circle(center = center, radius = 100f) + val circle2 = Circle(center = center, radius = 100f) + + assertEquals(circle1, circle1) + assertEquals(circle1.hashCode(), circle1.hashCode()) + assertNotEquals(circle1, circle2) + assertNotEquals(circle1, "not a circle") + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/GroundOverlayTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/GroundOverlayTest.kt new file mode 100644 index 0000000..94d0514 --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/GroundOverlayTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GroundOverlayTest { + private val image = BitmapDescriptorFactory.defaultMarker() + private val position = LatLng(51.4661, 7.2491) + private val bounds = LatLngBounds(LatLng(51.0, 7.0), LatLng(52.0, 8.0)) + + @Test + fun testPositionDefaults() { + val overlay = GroundOverlay(image = image, position = position, width = 500f) + + assertEquals(position, overlay.position) + assertEquals(500f, overlay.width) + assertNull(overlay.height) + assertNull(overlay.bounds) + assertEquals(Pair(0.5f, 0.5f), overlay.anchor) + assertEquals(0f, overlay.bearing) + assertEquals(0f, overlay.transparency) + assertTrue(overlay.visible) + assertFalse(overlay.clickable) + } + + @Test + fun testBounds() { + val overlay = GroundOverlay(image = image, bounds = bounds) + + assertEquals(bounds, overlay.bounds) + assertNull(overlay.position) + } + + @Test(expected = IllegalArgumentException::class) + fun testNeitherPositionNorBoundsThrows() { + GroundOverlay(image = image) + } + + @Test(expected = IllegalArgumentException::class) + fun testPositionWithoutWidthThrows() { + GroundOverlay(image = image, position = position) + } + + @Test(expected = IllegalArgumentException::class) + fun testPositionAndBoundsThrows() { + GroundOverlay(image = image, position = position, width = 500f, bounds = bounds) + } + + @Test(expected = IllegalArgumentException::class) + fun testTransparencyOutOfRangeThrows() { + GroundOverlay(image = image, bounds = bounds, transparency = 1.5f) + } + + @Test(expected = IllegalArgumentException::class) + fun testAnchorOutOfRangeThrows() { + GroundOverlay(image = image, bounds = bounds, anchor = Pair(0.5f, -0.1f)) + } + + @Test(expected = IllegalArgumentException::class) + fun testZeroWidthThrows() { + GroundOverlay(image = image, position = position, width = 0f) + } + + @Test(expected = IllegalArgumentException::class) + fun testZeroHeightThrows() { + GroundOverlay(image = image, position = position, width = 500f, height = 0f) + } + + @Test + fun testEqualityIsByIdentity() { + val overlay1 = GroundOverlay(image = image, bounds = bounds) + val overlay2 = GroundOverlay(image = image, bounds = bounds) + + assertEquals(overlay1, overlay1) + assertEquals(overlay1.hashCode(), overlay1.hashCode()) + assertNotEquals(overlay1, overlay2) + assertNotEquals(overlay1, "not an overlay") + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/MapControllerOverlaysTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/MapControllerOverlaysTest.kt new file mode 100644 index 0000000..e928427 --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/MapControllerOverlaysTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import io.mockk.mockk +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Tests for circles, ground overlays, tile overlays and info windows in [MapController], + * and for drawing all shape types onto a canvas. + */ +@RunWith(RobolectricTestRunner::class) +class MapControllerOverlaysTest { + private lateinit var controller: MapController + + private val center = LatLng(51.5, 0.0) + private val bounds = LatLngBounds(LatLng(51.49, -0.01), LatLng(51.51, 0.01)) + + // The map center is drawn in the middle of the 800x600 view + private val centerX = 400f + private val centerY = 300f + + @Before + fun setUp() { + controller = MapController(RuntimeEnvironment.getApplication()) + controller.setViewSize(800, 600) + controller.setCenter(center) + controller.setZoom(10.0f) + } + + @After + fun tearDown() { + controller.onDestroy() + } + + @Test + fun testAddRemoveAndClearCircles() { + val circle1 = controller.addCircle(Circle(center = center, radius = 100f)) + val circle2 = controller.addCircle(Circle(center = center, radius = 200f)) + assertEquals(listOf(circle1, circle2), controller.getCircles()) + + assertTrue(controller.removeCircle(circle1)) + assertFalse(controller.removeCircle(circle1)) + assertEquals(listOf(circle2), controller.getCircles()) + + controller.clearCircles() + assertTrue(controller.getCircles().isEmpty()) + } + + @Test + fun testHandleCircleTouch() { + val circle = controller.addCircle(Circle(center = center, radius = 500f, clickable = true)) + + assertSame(circle, controller.handleCircleTouch(centerX, centerY)) + assertNull(controller.handleCircleTouch(10f, 10f)) + } + + @Test + fun testHandleCircleTouch_IgnoresNonClickable() { + controller.addCircle(Circle(center = center, radius = 500f)) + + assertNull(controller.handleCircleTouch(centerX, centerY)) + } + + @Test + fun testHandleCircleTouch_ReturnsTopmost() { + controller.addCircle(Circle(center = center, radius = 500f, clickable = true)) + val top = controller.addCircle(Circle(center = center, radius = 500f, clickable = true)) + + assertSame(top, controller.handleCircleTouch(centerX, centerY)) + } + + @Test + fun testAddRemoveAndClearGroundOverlays() { + val overlay1 = controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds)) + val overlay2 = controller.addGroundOverlay(GroundOverlay(image = image(), position = center, width = 1000f)) + assertEquals(listOf(overlay1, overlay2), controller.getGroundOverlays()) + + assertTrue(controller.removeGroundOverlay(overlay1)) + assertFalse(controller.removeGroundOverlay(overlay1)) + assertEquals(listOf(overlay2), controller.getGroundOverlays()) + + controller.clearGroundOverlays() + assertTrue(controller.getGroundOverlays().isEmpty()) + } + + @Test + fun testHandleGroundOverlayTouch_Bounds() { + val overlay = controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds, clickable = true)) + + assertSame(overlay, controller.handleGroundOverlayTouch(centerX, centerY)) + assertNull(controller.handleGroundOverlayTouch(10f, 10f)) + } + + @Test + fun testHandleGroundOverlayTouch_RotatedBounds() { + val overlay = + controller.addGroundOverlay( + GroundOverlay(image = image(), bounds = bounds, bearing = 45f, clickable = true), + ) + + assertSame(overlay, controller.handleGroundOverlayTouch(centerX, centerY)) + assertNull(controller.handleGroundOverlayTouch(10f, 10f)) + } + + @Test + fun testHandleGroundOverlayTouch_Position() { + val overlay = + controller.addGroundOverlay( + GroundOverlay(image = image(), position = center, width = 1000f, height = 1000f, clickable = true), + ) + + assertSame(overlay, controller.handleGroundOverlayTouch(centerX, centerY)) + assertNull(controller.handleGroundOverlayTouch(10f, 10f)) + } + + @Test + fun testHandleGroundOverlayTouch_RotatedPosition() { + val overlay = + controller.addGroundOverlay( + GroundOverlay(image = image(), position = center, width = 1000f, bearing = 30f, clickable = true), + ) + + assertSame(overlay, controller.handleGroundOverlayTouch(centerX, centerY)) + } + + @Test + fun testHandleGroundOverlayTouch_IgnoresNonClickable() { + controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds)) + + assertNull(controller.handleGroundOverlayTouch(centerX, centerY)) + } + + @Test + fun testAddRemoveAndClearTileOverlays() { + val overlay1 = controller.addTileOverlay(TileOverlay(tileProvider = { _, _, _ -> null })) + val overlay2 = controller.addTileOverlay(TileOverlay(tileProvider = { _, _, _ -> null })) + assertEquals(listOf(overlay1, overlay2), controller.getTileOverlays()) + + assertTrue(controller.removeTileOverlay(overlay1)) + assertFalse(controller.removeTileOverlay(overlay1)) + assertEquals(listOf(overlay2), controller.getTileOverlays()) + + controller.clearTileOverlays() + assertTrue(controller.getTileOverlays().isEmpty()) + } + + @Test + fun testHandleInfoWindowTouch() { + // A 20x40 icon anchored at its bottom center, so the info window starts 50 pixels above the marker + val marker = + controller.addMarker( + Marker(position = center, title = "Title", snippet = "Snippet", icon = image()), + ) + + assertNull(controller.handleInfoWindowTouch(centerX, centerY - 100f)) + + marker.showInfoWindow() + assertSame(marker, controller.handleInfoWindowTouch(centerX, centerY - 100f)) + assertNull(controller.handleInfoWindowTouch(centerX, centerY + 100f)) + } + + @Test + fun testDraw_AllShapeTypes() { + val canvas = mockk(relaxed = true) + val square = + listOf(LatLng(51.49, -0.01), LatLng(51.51, -0.01), LatLng(51.51, 0.01), LatLng(51.49, 0.01)) + val hole = listOf(LatLng(51.495, -0.005), LatLng(51.505, -0.005), LatLng(51.505, 0.005)) + val dashed = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + + controller.addPolygon(Polygon(points = square, holes = listOf(hole), strokePattern = dashed)) + controller.addPolygon(Polygon(points = square, geodesic = true, zIndex = 1f)) + controller.addPolygon(Polygon(points = square, visible = false)) + controller.addPolyline(Polyline(points = square, strokePattern = dashed)) + controller.addPolyline(Polyline(points = square, geodesic = true, zIndex = 2f)) + controller.addPolyline( + Polyline( + points = square, + spans = listOf(StyleSpan(Color.Red, segments = 2), StyleSpan(Color.Blue)), + zIndex = 2f, + ), + ) + controller.addPolyline(Polyline(points = square, visible = false)) + controller.addCircle(Circle(center = center, radius = 500f, strokePattern = dashed)) + controller.addCircle(Circle(center = center, radius = 500f, visible = false)) + controller.addMarker(Marker(position = center, alpha = 0.5f)) + controller.addMarker(Marker(position = center, visible = false)) + controller + .addMarker(Marker(position = center, title = "Title", snippet = "Snippet", zIndex = 3f)) + .showInfoWindow() + controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds, transparency = 0.5f)) + controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds, bearing = 45f)) + controller.addGroundOverlay(GroundOverlay(image = image(), position = center, width = 1000f)) + controller.addGroundOverlay(GroundOverlay(image = image(), position = center, width = 1000f, bearing = 90f)) + controller.addGroundOverlay(GroundOverlay(image = image(), bounds = bounds, visible = false)) + controller.addTileOverlay(TileOverlay(tileProvider = { _, _, _ -> null }, zIndex = -1f)) + controller.addTileOverlay(TileOverlay(tileProvider = { _, _, _ -> null }, zIndex = 1f, transparency = 0.5f)) + controller.addTileOverlay(TileOverlay(tileProvider = { _, _, _ -> null }, visible = false)) + + controller.draw(canvas) + + verify(atLeast = 1) { canvas.drawPath(any(), any()) } + verify(atLeast = 1) { canvas.drawCircle(any(), any(), any(), any()) } + verify(atLeast = 1) { canvas.drawBitmap(any(), any(), any(), any()) } + verify(atLeast = 1) { canvas.drawRoundRect(any(), any(), any(), any()) } + } + + private fun image(): BitmapDescriptor = BitmapDescriptorFactory.fromBitmap(Bitmap.createBitmap(20, 40, Bitmap.Config.ARGB_8888)) +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/MapProjectionTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/MapProjectionTest.kt new file mode 100644 index 0000000..53ba1af --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/MapProjectionTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import android.graphics.Point +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Tests for the public [Projection] class (screen <-> LatLng conversion). + * ProjectionTest covers the underlying ProjectionUtils math. + */ +@RunWith(RobolectricTestRunner::class) +class MapProjectionTest { + companion object { + private const val DELTA = 0.5 + } + + private val center = LatLng(0.0, 0.0) + + @Test + fun testCenterMapsToViewCenter() { + val projection = Projection(center, 2f, 512, 512, 0f, 0f) + + assertEquals(Point(256, 256), projection.toScreenLocation(center)) + + val latLng = projection.fromScreenLocation(Point(256, 256)) + assertEquals(0.0, latLng.latitude, DELTA) + assertEquals(0.0, latLng.longitude, DELTA) + } + + @Test + fun testRoundTrip() { + val projection = Projection(LatLng(51.4661, 7.2491), 12f, 1080, 1920, 0f, 0f) + val location = LatLng(51.47, 7.26) + + val point = projection.toScreenLocation(location) + val result = projection.fromScreenLocation(point) + + assertEquals(location.latitude, result.latitude, 0.001) + assertEquals(location.longitude, result.longitude, 0.001) + } + + @Test + fun testEastIsRightAndNorthIsUp() { + val projection = Projection(center, 2f, 512, 512, 0f, 0f) + + val east = projection.toScreenLocation(LatLng(0.0, 45.0)) + val north = projection.toScreenLocation(LatLng(45.0, 0.0)) + + assertTrue(east.x > 256) + assertEquals(256, east.y) + assertTrue(north.y < 256) + assertEquals(256, north.x) + } + + @Test + fun testPanOffsetMovesTheMap() { + val projection = Projection(center, 2f, 512, 512, 100f, 50f) + + assertEquals(Point(156, 206), projection.toScreenLocation(center)) + } + + @Test + fun testPaddingShiftsLogicalCenter() { + val projection = + Projection(center, 2f, 512, 512, 0f, 0f, paddingLeft = 0, paddingTop = 100, paddingRight = 0, paddingBottom = 0) + + // The logical center moves down by half the top padding + assertEquals(Point(256, 306), projection.toScreenLocation(center)) + } + + @Test + fun testVisibleRegion() { + // At zoom 2 the world is 1024 pixels wide, so a 512 pixel view shows half of it + val region = Projection(center, 2f, 512, 512, 0f, 0f).getVisibleRegion() + + assertEquals(-90.0, region.farLeft.longitude, DELTA) + assertEquals(90.0, region.farRight.longitude, DELTA) + assertEquals(region.farLeft.latitude, region.farRight.latitude, DELTA) + assertEquals(region.nearLeft.latitude, region.nearRight.latitude, DELTA) + assertTrue(region.farLeft.latitude > region.nearLeft.latitude) + assertTrue(region.latLngBounds.contains(center)) + } + + @Test + fun testVisibleRegionWithPadding() { + val region = + Projection(center, 2f, 512, 512, 0f, 0f, paddingLeft = 128, paddingTop = 0, paddingRight = 0, paddingBottom = 0) + .getVisibleRegion() + + // Only 384 of 512 pixels remain, centered on the camera target: 135 instead of 180 degrees + assertEquals(-67.5, region.farLeft.longitude, DELTA) + assertEquals(67.5, region.farRight.longitude, DELTA) + } + + @Test + fun testControllerCreatesMatchingProjection() { + val controller = MapController(RuntimeEnvironment.getApplication()) + try { + controller.setViewSize(512, 512) + controller.setCenter(center) + controller.setZoom(2f) + + assertEquals(Point(256, 256), controller.createProjection().toScreenLocation(center)) + } finally { + controller.onDestroy() + } + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/OptionsBuildersTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/OptionsBuildersTest.kt new file mode 100644 index 0000000..ff875c4 --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/OptionsBuildersTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for the Google-style *Options builders: every setter must reach the built object. + */ +@RunWith(RobolectricTestRunner::class) +class OptionsBuildersTest { + private val point1 = LatLng(51.0, 7.0) + private val point2 = LatLng(52.0, 8.0) + private val point3 = LatLng(51.5, 8.5) + private val pattern = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + + @Test + fun testMarkerOptions() { + val icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE) + val marker = + MarkerOptions() + .position(point1) + .title("Title") + .snippet("Snippet") + .icon(icon) + .anchor(0.25f, 0.75f) + .visible(false) + .alpha(0.5f) + .draggable(true) + .zIndex(2f) + .tag("tag") + .build() + + assertEquals(point1, marker.position) + assertEquals("Title", marker.title) + assertEquals("Snippet", marker.snippet) + assertEquals(icon, marker.icon) + assertEquals(Pair(0.25f, 0.75f), marker.anchor) + assertFalse(marker.visible) + assertEquals(0.5f, marker.alpha) + assertTrue(marker.draggable) + assertEquals(2f, marker.zIndex) + assertEquals("tag", marker.tag) + } + + @Test(expected = IllegalArgumentException::class) + fun testMarkerOptionsWithoutPositionThrows() { + MarkerOptions().title("No position").build() + } + + @Test + fun testPolylineOptions() { + val spans = listOf(StyleSpan(Color.Red, segments = 2)) + val polyline = + PolylineOptions() + .add(point1) + .addAll(listOf(point2, point3)) + .color(Color.Blue) + .width(4f) + .strokePattern(pattern) + .startCap(StrokeCap.Round) + .endCap(StrokeCap.Square) + .strokeJoin(StrokeJoin.Bevel) + .geodesic(true) + .spans(spans) + .visible(false) + .clickable(true) + .zIndex(3f) + .tag(42) + .build() + + assertEquals(listOf(point1, point2, point3), polyline.points) + assertEquals(Color.Blue, polyline.strokeColor) + assertEquals(4f, polyline.strokeWidth) + assertSame(pattern, polyline.strokePattern) + assertEquals(StrokeCap.Round, polyline.startCap) + assertEquals(StrokeCap.Square, polyline.endCap) + assertEquals(StrokeJoin.Bevel, polyline.strokeJoin) + assertTrue(polyline.geodesic) + assertEquals(spans, polyline.spans) + assertFalse(polyline.visible) + assertTrue(polyline.clickable) + assertEquals(3f, polyline.zIndex) + assertEquals(42, polyline.tag) + } + + @Test + fun testPolygonOptions() { + val hole = listOf(LatLng(51.4, 7.4), LatLng(51.5, 7.5), LatLng(51.4, 7.6)) + val polygon = + PolygonOptions() + .add(point1) + .addAll(listOf(point2, point3)) + .addHole(hole) + .strokeColor(Color.Red) + .strokeWidth(2f) + .strokePattern(pattern) + .strokeCap(StrokeCap.Square) + .strokeJoin(StrokeJoin.Miter) + .fillColor(Color.Green) + .geodesic(true) + .visible(false) + .clickable(true) + .zIndex(1f) + .tag("polygon") + .build() + + assertEquals(listOf(point1, point2, point3), polygon.points) + assertEquals(listOf(hole), polygon.holes) + assertEquals(Color.Red, polygon.strokeColor) + assertEquals(2f, polygon.strokeWidth) + assertSame(pattern, polygon.strokePattern) + assertEquals(StrokeCap.Square, polygon.strokeCap) + assertEquals(StrokeJoin.Miter, polygon.strokeJoin) + assertEquals(Color.Green, polygon.fillColor) + assertTrue(polygon.geodesic) + assertFalse(polygon.visible) + assertTrue(polygon.clickable) + assertEquals(1f, polygon.zIndex) + assertEquals("polygon", polygon.tag) + } + + @Test + fun testCircleOptions() { + val circle = + CircleOptions() + .center(point1) + .radius(250f) + .strokeColor(Color.Red) + .strokeWidth(3f) + .strokePattern(pattern) + .strokeCap(StrokeCap.Butt) + .strokeJoin(StrokeJoin.Bevel) + .fillColor(Color.Yellow) + .visible(false) + .clickable(true) + .zIndex(5f) + .tag("circle") + .build() + + assertEquals(point1, circle.center) + assertEquals(250f, circle.radius) + assertEquals(Color.Red, circle.strokeColor) + assertEquals(3f, circle.strokeWidth) + assertSame(pattern, circle.strokePattern) + assertEquals(StrokeCap.Butt, circle.strokeCap) + assertEquals(StrokeJoin.Bevel, circle.strokeJoin) + assertEquals(Color.Yellow, circle.fillColor) + assertFalse(circle.visible) + assertTrue(circle.clickable) + assertEquals(5f, circle.zIndex) + assertEquals("circle", circle.tag) + } + + @Test(expected = IllegalArgumentException::class) + fun testCircleOptionsWithoutCenterThrows() { + CircleOptions().radius(100f).build() + } + + @Test(expected = IllegalArgumentException::class) + fun testCircleOptionsWithoutRadiusThrows() { + CircleOptions().center(point1).build() + } + + @Test + fun testGroundOverlayOptionsWithPosition() { + val image = BitmapDescriptorFactory.defaultMarker() + val overlay = + GroundOverlayOptions() + .image(image) + .position(point1, 1000f, 500f) + .anchor(0f, 1f) + .bearing(45f) + .transparency(0.3f) + .visible(false) + .zIndex(2f) + .clickable(true) + .tag("overlay") + .build() + + assertEquals(image, overlay.image) + assertEquals(point1, overlay.position) + assertEquals(1000f, overlay.width) + assertEquals(500f, overlay.height) + assertNull(overlay.bounds) + assertEquals(Pair(0f, 1f), overlay.anchor) + assertEquals(45f, overlay.bearing) + assertEquals(0.3f, overlay.transparency) + assertFalse(overlay.visible) + assertEquals(2f, overlay.zIndex) + assertTrue(overlay.clickable) + assertEquals("overlay", overlay.tag) + } + + @Test + fun testGroundOverlayOptionsBoundsReplacePosition() { + val bounds = LatLngBounds(point1, point2) + val overlay = + GroundOverlayOptions() + .image(BitmapDescriptorFactory.defaultMarker()) + .position(point1, 1000f) + .positionFromBounds(bounds) + .build() + + assertEquals(bounds, overlay.bounds) + assertNull(overlay.position) + assertNull(overlay.width) + assertNull(overlay.height) + } + + @Test + fun testGroundOverlayOptionsPositionReplacesBounds() { + val overlay = + GroundOverlayOptions() + .image(BitmapDescriptorFactory.defaultMarker()) + .positionFromBounds(LatLngBounds(point1, point2)) + .position(point3, 800f) + .build() + + assertEquals(point3, overlay.position) + assertEquals(800f, overlay.width) + assertNull(overlay.bounds) + } + + @Test(expected = IllegalArgumentException::class) + fun testGroundOverlayOptionsWithoutImageThrows() { + GroundOverlayOptions().position(point1, 1000f).build() + } + + @Test + fun testTileOverlayOptions() { + val provider = TileProvider { _, _, _ -> null } + val overlay = + TileOverlayOptions() + .tileProvider(provider) + .transparency(0.4f) + .zIndex(-1f) + .visible(false) + .fadeIn(true) + .tag("tiles") + .build() + + assertSame(provider, overlay.tileProvider) + assertEquals(0.4f, overlay.transparency) + assertEquals(-1f, overlay.zIndex) + assertFalse(overlay.visible) + assertTrue(overlay.fadeIn) + assertEquals("tiles", overlay.tag) + } + + @Test(expected = IllegalArgumentException::class) + fun testTileOverlayOptionsWithoutProviderThrows() { + TileOverlayOptions().zIndex(1f).build() + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/PredefinedTileProvidersTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/PredefinedTileProvidersTest.kt new file mode 100644 index 0000000..684224c --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/PredefinedTileProvidersTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PredefinedTileProvidersTest { + @Test + fun testProvidersRequestExpectedUrls() = + runTest { + val expected = + mapOf( + PredefinedTileProviders.openSeaMap() to "https://tiles.openseamap.org/seamark/3/1/2.png", + PredefinedTileProviders.openRailwayMap() to "https://a.tiles.openrailwaymap.org/standard/3/1/2.png", + PredefinedTileProviders.openFireMap() to "http://openfiremap.org/hytiles/3/1/2.png", + PredefinedTileProviders.openSnowMap() to "https://tiles.opensnowmap.org/pistes/3/1/2.png", + PredefinedTileProviders.waymarkedTrailsHiking() to "https://tile.waymarkedtrails.org/hiking/3/1/2.png", + PredefinedTileProviders.waymarkedTrailsCycling() to "https://tile.waymarkedtrails.org/cycling/3/1/2.png", + PredefinedTileProviders.waymarkedTrailsMtb() to "https://tile.waymarkedtrails.org/mtb/3/1/2.png", + PredefinedTileProviders.waymarkedTrailsSkating() to "https://tile.waymarkedtrails.org/skating/3/1/2.png", + PredefinedTileProviders.stamenTonerHybrid() to + "https://stamen-tiles.a.ssl.fastly.net/toner-hybrid/3/1/2.png", + PredefinedTileProviders.stamenTerrainLines() to + "https://stamen-tiles.a.ssl.fastly.net/terrain-lines/3/1/2.png", + PredefinedTileProviders.custom("https://example.com/{z}/{x}/{y}.png") to "https://example.com/3/1/2.png", + ) + + for ((provider, url) in expected) { + assertEquals(url, requestedUrl(provider as UrlTileProvider)) + } + } + + // Swaps in a mock HTTP client, so no real tile server is contacted + private suspend fun requestedUrl(provider: UrlTileProvider): String? { + var requested: String? = null + val engine = + MockEngine { request -> + requested = request.url.toString() + respond(ByteReadChannel(byteArrayOf(1)), HttpStatusCode.OK) + } + val reflectionField = UrlTileProvider::class.java.getDeclaredField("client") + reflectionField.isAccessible = true + reflectionField.set(provider, HttpClient(engine)) + + provider.getTile(1, 2, 3) + provider.close() + return requested + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/TileOverlayTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/TileOverlayTest.kt new file mode 100644 index 0000000..1aa5b2c --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/TileOverlayTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class TileOverlayTest { + private val provider = TileProvider { _, _, _ -> null } + + @Test + fun testDefaults() { + val overlay = TileOverlay(tileProvider = provider) + + assertSame(provider, overlay.tileProvider) + assertEquals(0f, overlay.transparency) + assertEquals(0f, overlay.zIndex) + assertTrue(overlay.visible) + assertFalse(overlay.fadeIn) + } + + @Test(expected = IllegalArgumentException::class) + fun testTransparencyAboveOneThrows() { + TileOverlay(tileProvider = provider, transparency = 1.1f) + } + + @Test(expected = IllegalArgumentException::class) + fun testNegativeTransparencyThrows() { + TileOverlay(tileProvider = provider, transparency = -0.1f) + } + + @Test + fun testWithMethodsReturnModifiedCopies() { + val overlay = TileOverlay(tileProvider = provider) + + assertEquals(0.5f, overlay.withTransparency(0.5f).transparency) + assertFalse(overlay.withVisibility(false).visible) + assertEquals(3f, overlay.withZIndex(3f).zIndex) + // The original is unchanged + assertEquals(0f, overlay.transparency) + assertTrue(overlay.visible) + assertEquals(0f, overlay.zIndex) + } + + @Test + fun testToString() { + val text = TileOverlay(tileProvider = provider, transparency = 0.25f).toString() + + assertTrue(text.startsWith("TileOverlay(id=tileOverlay_")) + assertTrue(text.contains("transparency=0.25")) + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/TileTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/TileTest.kt new file mode 100644 index 0000000..0f5200f --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/TileTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class TileTest { + @Test + fun testEqualTilesWithSameContent() { + val tile1 = Tile(256, 256, byteArrayOf(1, 2, 3)) + val tile2 = Tile(256, 256, byteArrayOf(1, 2, 3)) + + assertEquals(tile1, tile1) + assertEquals(tile1, tile2) + assertEquals(tile1.hashCode(), tile2.hashCode()) + } + + @Test + fun testDifferentTiles() { + val tile = Tile(256, 256, byteArrayOf(1, 2, 3)) + + assertNotEquals(tile, Tile(512, 256, byteArrayOf(1, 2, 3))) + assertNotEquals(tile, Tile(256, 512, byteArrayOf(1, 2, 3))) + assertNotEquals(tile, Tile(256, 256, byteArrayOf(1, 2, 4))) + assertNotEquals(tile, null) + assertNotEquals(tile, "not a tile") + } + + @Test(expected = IllegalArgumentException::class) + fun testZeroWidthThrows() { + Tile(0, 256, byteArrayOf(1)) + } + + @Test(expected = IllegalArgumentException::class) + fun testZeroHeightThrows() { + Tile(256, 0, byteArrayOf(1)) + } + + @Test(expected = IllegalArgumentException::class) + fun testEmptyDataThrows() { + Tile(256, 256, byteArrayOf()) + } + + @Test + fun testToString() { + assertEquals("Tile(width=256, height=256, dataSize=3 bytes)", Tile(256, 256, byteArrayOf(1, 2, 3)).toString()) + } +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index d639662..fee9395 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Check test coverage meets minimum threshold (20%) +# Check test coverage meets minimum threshold (75%) # Returns 0 if coverage is adequate, 1 if tests fail or coverage is below threshold set -euo pipefail -COVERAGE_THRESHOLD=20 +COVERAGE_THRESHOLD=75 REPORT_DIR="openmapview/build/reports/coverage/test/debug" COVERAGE_FILE="$REPORT_DIR/report.xml"