diff --git a/api/android-for-maptiler.api b/api/android-for-maptiler.api index fbed037..cf68f87 100644 --- a/api/android-for-maptiler.api +++ b/api/android-for-maptiler.api @@ -170,6 +170,7 @@ public final class com.mapconductor.maptiler.MapTilerOverlayStacking { public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYLINE_TILE(); public final void add(com.maptiler.maptilersdk.map.style.MTStyle, com.maptiler.maptilersdk.map.style.layer.MTLayer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(com.maptiler.maptilersdk.map.style.MTStyle, java.lang.String); public final void onIdle(com.maptiler.maptilersdk.map.style.MTStyle); @@ -200,6 +201,23 @@ public final class com.mapconductor.maptiler.MapTilerViewStateImplKt { } public interface com.mapconductor.maptiler.MapTilerViewStateInterface extends com.mapconductor.core.map.MapViewStateInterface { } +public final class com.mapconductor.maptiler.PageLayerWait { + public static final com.mapconductor.maptiler.PageLayerWait INSTANCE; + public static final java.lang.String MAP_EXPR; + public static final long TIMEOUT_MS; + public static final long INTERVAL_MS; + public final java.lang.String script(java.util.List); + public final int parse(java.lang.String); + public final java.lang.Object await(java.util.List, long, long, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.coroutines.Continuation); +} +public final class com.mapconductor.maptiler.PageLayerWaitKt { + public static final java.lang.Object awaitPageLayers(com.maptiler.maptilersdk.map.MTMapViewController, java.util.List, kotlin.coroutines.Continuation); +} +final class com.mapconductor.maptiler.PageScript implements com.maptiler.maptilersdk.bridge.MTCommand { + public com.mapconductor.maptiler.PageScript(java.lang.String); + public boolean isPrimitiveReturnType(); + public java.lang.String toJS(); +} public final class com.mapconductor.maptiler.circle.MapTilerCircleController extends com.mapconductor.core.circle.CircleController { public com.mapconductor.maptiler.circle.MapTilerCircleController(com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer, com.mapconductor.core.circle.CircleManagerInterface); public com.mapconductor.maptiler.circle.MapTilerCircleController(com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer, com.mapconductor.core.circle.CircleManagerInterface, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -313,6 +331,10 @@ public final class com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer { } final class com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer$Companion { } +public final class com.mapconductor.maptiler.marker.MarkerIdIndex { + public com.mapconductor.maptiler.marker.MarkerIdIndex(); + public final synchronized boolean contains(java.util.List, java.lang.String); +} public final class com.mapconductor.maptiler.polygon.MapTilerPolygonController extends com.mapconductor.core.polygon.PolygonController { public com.mapconductor.maptiler.polygon.MapTilerPolygonController(com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer, com.mapconductor.core.polygon.PolygonManagerInterface); public com.mapconductor.maptiler.polygon.MapTilerPolygonController(com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer, com.mapconductor.core.polygon.PolygonManagerInterface, int, kotlin.jvm.internal.DefaultConstructorMarker); diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt index 156bb05..07b6e96 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt @@ -22,8 +22,11 @@ import com.mapconductor.core.polygon.PolygonCapableInterface import com.mapconductor.core.polygon.PolygonTiler import com.mapconductor.core.polygon.polygonTileRasterLayerState import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID import com.mapconductor.core.polyline.PolylineCapableInterface import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState import com.mapconductor.core.raster.RasterLayerCapableInterface import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.maptiler.circle.MapTilerCircleController @@ -31,6 +34,7 @@ import com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer import com.mapconductor.maptiler.groundimage.MapTilerGroundImageController import com.mapconductor.maptiler.groundimage.MapTilerGroundImageOverlayRenderer import com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer +import com.mapconductor.maptiler.marker.MarkerIdIndex import com.mapconductor.maptiler.polygon.MapTilerPolygonController import com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer import com.mapconductor.maptiler.polyline.MapTilerPolylineController @@ -206,8 +210,11 @@ class MapTilerMapViewController( } override fun hasMarker(state: MarkerState): Boolean = - _markers.value.any { it.id == state.id } || - (markerTileRenderer?.markers?.any { it.id == state.id } ?: false) + overlayMarkerIds.contains(_markers.value, state.id) || + (markerTileRenderer?.markers?.let { tiledMarkerIds.contains(it, state.id) } ?: false) + + private val tiledMarkerIds = MarkerIdIndex() + private val overlayMarkerIds = MarkerIdIndex() // --- Marker clustering (android-marker-clustering) 連携 --- @@ -332,6 +339,18 @@ class MapTilerMapViewController( } } } + + // Many polylines too: the same raster layer path, in the polyline band. + polylineController.tiler = + PolylineTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polylineTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYLINE_TILE_LAYER_ID) + } + } + } // ホルダーの同期投影にカメラを供給する。ホルダーはコントローラより先に作られるので // ここで繋ぐ。これが無いと toScreenOffset が常に null を返し、InfoBubble と // マーカー追従が理由も出ずに落ちる(android-for-longdo と同じ)。 diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt b/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt index 18f9e7c..b0e5747 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt @@ -52,6 +52,9 @@ internal object MapTilerOverlayStacking { /** ポリゴンのタイル: ポリゴンの帯の一番下。 */ val POLYGON_TILE = key(OverlayKind.Polygon, Int.MIN_VALUE) + /** ポリラインのタイル(ラスター): ポリラインの帯の一番下(サークルの上、ネイティブのポリラインの下)。 */ + val POLYLINE_TILE = key(OverlayKind.Polyline, Int.MIN_VALUE) + fun add( style: MTStyle, layer: MTLayer, diff --git a/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt b/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt new file mode 100644 index 0000000..15342e5 --- /dev/null +++ b/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt @@ -0,0 +1,124 @@ +package com.mapconductor.maptiler + +import com.maptiler.maptilersdk.bridge.MTBridgeReturnType +import com.maptiler.maptilersdk.bridge.MTCommand +import com.maptiler.maptilersdk.map.MTMapViewController +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Waits until the page's map has the layers an overlay was just given. + * + * The MapTiler SDK hands a layer to the page later than `MTStyle.addLayer` + * returns: the call launches a coroutine, and a layer whose source has not + * loaded yet waits in the SDK's queue until the map's next `idle`. + * So a renderer's `onAdd` returned before its layers were on the map, and + * the overlay's `onAdded` (asked for right after the hand-over) came while + * the map still showed nothing. Boston's 604 downtown polygons reported + * `onAdded` 0.1 s after the hand-over; the last of their layers reached the + * map 15 s after it (Lenovo TB520FU). + * Renderers call [await] at the end of `onAdd`, so `compositionXxx` + * returns, and `onAdded` is called, once the layers are there. + * + * Only that the layers are in the style is waited for, not their tiles or + * images (same as every other provider). + */ +internal object PageLayerWait { + /** The page's map, or a falsy value while there is none. */ + const val MAP_EXPR = "window.map" + + /** + * Only a guard against a layer the page refused, which never shows up: + * a few hundred layers can take the page well over 10 s. + */ + const val TIMEOUT_MS = 60_000L + const val INTERVAL_MS = 100L + + /** + * Script that answers how many of [ids], from the front, the map has + * (as text, e.g. `"3"`); `"0"` while there is no map. + */ + fun script(ids: List): String { + val list = ids.joinToString(",", "[", "]") { quote(it) } + return "(function(){try{var m=$MAP_EXPR;if(!m)return '0';var ids=$list;" + + "for(var i=0;i, + timeoutMs: Long = TIMEOUT_MS, + intervalMs: Long = INTERVAL_MS, + evaluate: suspend (String) -> String?, + ): Boolean { + if (ids.isEmpty()) return true + var missing = ids + val done = + withTimeoutOrNull(timeoutMs) { + while (missing.isNotEmpty()) { + val present = + try { + parse(evaluate(script(missing))) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + 0 + } + // Layers mostly arrive in the order they were added: ask only for the rest. + missing = missing.drop(present) + if (missing.isNotEmpty()) delay(intervalMs) + } + true + } ?: false + if (!done) { + runCatching { Log.w(TAG, "${missing.size} of ${ids.size} layers not on the map after $timeoutMs ms") } + } + return done + } + + private fun quote(text: String): String = + buildString { + append('"') + for (c in text) { + when { + c == '"' -> append("\\\"") + c == '\\' -> append("\\\\") + c < ' ' || c == '\u2028' || c == '\u2029' -> append(String.format("\\u%04x", c.code)) + else -> append(c) + } + } + append('"') + } + + private const val TAG = "PageLayerWait" +} + +/** [PageLayerWait.await] against this map's page. */ +internal suspend fun MTMapViewController.awaitPageLayers(ids: List): Boolean = + PageLayerWait.await(ids) { js -> + when (val result = execute(PageScript(js))) { + is MTBridgeReturnType.StringValue -> result.value + is MTBridgeReturnType.DoubleValue -> result.value.toInt().toString() + else -> null + } + } + +/** A script whose (string) value comes back. */ +private class PageScript( + private val js: String, +) : MTCommand { + override val isPrimitiveReturnType: Boolean = true + + override fun toJS(): String = js +} diff --git a/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt index c53364e..a3e6f7d 100644 --- a/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt @@ -8,6 +8,7 @@ import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.geometry.OverlayGeoJson import com.mapconductor.core.geometry.circleToRing import com.mapconductor.maptiler.MapTilerOverlayStacking +import com.mapconductor.maptiler.awaitPageLayers import com.mapconductor.maptiler.removeLayerAndForget import com.mapconductor.maptiler.removeSourceAndForget import com.maptiler.maptilersdk.map.MTMapViewController @@ -34,9 +35,19 @@ import android.util.Log class MapTilerCircleOverlayRenderer( private val mtController: MTMapViewController, ) : CircleOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the circles' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addCircle(it.state) } + ): List { + val handles = data.map { addCircle(it.state) } + if (mtController.style != null) { + mtController.awaitPageLayers(handles.filterNotNull().flatMap { listOf(it.fillLayerId, it.lineLayerId) }) + } + return handles + } override suspend fun onChange( data: List>, diff --git a/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt index 3eadab9..85160d9 100644 --- a/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt @@ -6,6 +6,7 @@ import com.mapconductor.core.groundimage.GroundImageEntityInterface import com.mapconductor.core.groundimage.GroundImageOverlayRendererInterface import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.maptiler.MapTilerOverlayStacking +import com.mapconductor.maptiler.awaitPageLayers import com.mapconductor.maptiler.removeLayerAndForget import com.mapconductor.maptiler.removeSourceAndForget import com.maptiler.maptilersdk.map.LngLat @@ -36,9 +37,17 @@ import android.util.Log class MapTilerGroundImageOverlayRenderer( private val mtController: MTMapViewController, ) : GroundImageOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the ground images' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addGroundImage(it.state) } + ): List { + val handles = data.map { addGroundImage(it.state) } + if (mtController.style != null) mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + return handles + } override suspend fun onChange( data: List>, diff --git a/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt b/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt new file mode 100644 index 0000000..a913a71 --- /dev/null +++ b/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt @@ -0,0 +1,28 @@ +package com.mapconductor.maptiler.marker + +import com.mapconductor.core.marker.MarkerState + +/** + * The ids of a marker list, rebuilt only when the list itself is replaced. + * + * `hasMarker` is asked once per marker after every composition (that is how + * `MarkerState.onAdded` finds out what is on the map) and on every in-place + * change. A linear search of the list made the first of those quadratic: 1e5 + * markers, 1e10 comparisons. + */ +internal class MarkerIdIndex { + private var list: List? = null + private var ids: Set = emptySet() + + @Synchronized + fun contains( + markers: List, + id: String, + ): Boolean { + if (markers !== list) { + ids = markers.mapTo(HashSet(markers.size * 2)) { it.id } + list = markers + } + return id in ids + } +} diff --git a/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt index 012b403..0bf43aa 100644 --- a/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt @@ -9,6 +9,7 @@ import com.mapconductor.core.polygon.PolygonOverlayRendererInterface import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polygon.unionHoles import com.mapconductor.maptiler.MapTilerOverlayStacking +import com.mapconductor.maptiler.awaitPageLayers import com.mapconductor.maptiler.removeLayerAndForget import com.mapconductor.maptiler.removeSourceAndForget import com.maptiler.maptilersdk.map.MTMapViewController @@ -34,9 +35,19 @@ import android.util.Log class MapTilerPolygonOverlayRenderer( private val mtController: MTMapViewController, ) : PolygonOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the polygons' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addPolygon(it.state) } + ): List { + val handles = data.map { addPolygon(it.state) } + if (mtController.style != null) { + mtController.awaitPageLayers(handles.filterNotNull().flatMap { listOf(it.fillLayerId, it.lineLayerId) }) + } + return handles + } override suspend fun onChange( data: List>, diff --git a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt index 524688f..bd3fcbe 100644 --- a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt +++ b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt @@ -18,6 +18,7 @@ class MapTilerPolylineController( * 既知のポリラインを再適用する(地図 `ready` 後の復元用)。 */ fun reapply() { - renderer.reapply(polylineManager.allEntities().map { it.polyline }) + // タイルで描く線(ハンドル無し)は飛ばす。 + renderer.reapply(polylineManager.allEntities().mapNotNull { it.polyline as MapTilerPolylineHandle? }) } } diff --git a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt index a7049c2..aae87c8 100644 --- a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt @@ -8,6 +8,7 @@ import com.mapconductor.core.polyline.PolylineEntityInterface import com.mapconductor.core.polyline.PolylineOverlayRendererInterface import com.mapconductor.core.polyline.PolylineState import com.mapconductor.maptiler.MapTilerOverlayStacking +import com.mapconductor.maptiler.awaitPageLayers import com.mapconductor.maptiler.removeLayerAndForget import com.mapconductor.maptiler.removeSourceAndForget import com.maptiler.maptilersdk.map.MTMapViewController @@ -32,9 +33,17 @@ import android.util.Log class MapTilerPolylineOverlayRenderer( private val mtController: MTMapViewController, ) : PolylineOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the polylines' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addLine(it.state) } + ): List { + val handles = data.map { addLine(it.state) } + if (mtController.style != null) mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + return handles + } override suspend fun onChange( data: List>, diff --git a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt index 1f79a5e..0c541f9 100644 --- a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt @@ -2,6 +2,7 @@ package com.mapconductor.maptiler.raster import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID import com.mapconductor.core.raster.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface @@ -9,6 +10,7 @@ import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.maptiler.MapTilerOverlayStacking +import com.mapconductor.maptiler.awaitPageLayers import com.mapconductor.maptiler.removeLayerAndForget import com.mapconductor.maptiler.removeSourceAndForget import com.maptiler.maptilersdk.map.MTMapViewController @@ -40,19 +42,29 @@ class MapTilerRasterLayerOverlayRenderer( /** * MapTiler Android は WebView(MapLibre GL JS) 越しにタイルを取るため、 * ネイティブ側からリクエストヘッダを差し替える口が無い。 + * + * Returns once the layers are on the page's map, so `onAdded` of the + * layer -- and of the polygons and markers drawn into it as tiles -- + * comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). */ override suspend fun onAdd( data: List, - ): List = - data.map { - RasterHeaderRuleSet.warnUnsupported(provider = "MapTiler", state = it.state) - addLayer(it.state) - } + ): List { + val handles = + data.map { + RasterHeaderRuleSet.warnUnsupported(provider = "MapTiler", state = it.state) + addLayer(it.state) + } + awaitOnMap(handles) + return handles + } + /** Returns once a layer added again under a new source is on the map, as [onAdd]. */ override suspend fun onChange( data: List>, - ): List = - data.map { params -> + ): List { + val replaced = mutableListOf() + val handles = data.map { params -> val prev = params.prev val next = params.current.state if (prev.state.source != next.source) { @@ -61,12 +73,20 @@ class MapTilerRasterLayerOverlayRenderer( // add below, which then fails with "Source already exists" and // leaves the layer gone. The marker and polygon tiles change // source on every in-place change. - addLayer(next, generation = ++replacements) + addLayer(next, generation = ++replacements).also { replaced += it } } else { updatePaint(prev.layer, next) prev.layer } } + awaitOnMap(replaced) + return handles + } + + private suspend fun awaitOnMap(handles: List) { + if (mtController.style == null) return + mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + } override suspend fun onRemove(data: List>) { data.forEach { removeLayer(it) } @@ -100,6 +120,8 @@ class MapTilerRasterLayerOverlayRenderer( MapTilerOverlayStacking.MARKER_TILE } else if (layerId.startsWith("raster-layer-$POLYGON_TILE_LAYER_ID")) { MapTilerOverlayStacking.POLYGON_TILE + } else if (layerId.startsWith("raster-layer-$POLYLINE_TILE_LAYER_ID")) { + MapTilerOverlayStacking.POLYLINE_TILE } else { MapTilerOverlayStacking.key(OverlayKind.RasterLayer, zIndexByLayer[layerId] ?: 0) } diff --git a/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt b/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt new file mode 100644 index 0000000..5fcc979 --- /dev/null +++ b/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt @@ -0,0 +1,109 @@ +package com.mapconductor.maptiler + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [PageLayerWait] returns only once the page's map has every layer, which is + * what holds an overlay's `onAdded` until it is really on the map. + * + * The page is faked: [FakePage] answers the script the way the real one does, + * from a set of layer ids that tests add to between polls. + */ +class PageLayerWaitTest { + /** Answers [PageLayerWait.script] from [layers], counting the calls. */ + private class FakePage { + val layers = mutableSetOf() + val asked = mutableListOf>() + var onAsk: (Int) -> Unit = {} + + fun evaluate(js: String): String { + val ids = + Regex("var ids=\\[(.*?)\\];") + .find(js)!! + .groupValues[1] + .split(',') + .filter { it.isNotEmpty() } + .map { it.trim('"') } + asked += ids + onAsk(asked.size) + val present = ids.takeWhile { it in layers }.size + // evaluateJavascript hands a string value back JSON-quoted. + return "\"$present\"" + } + } + + @Test + fun returnsAtOnceForNoLayers() = + runBlocking { + var calls = 0 + assertTrue( + PageLayerWait.await(emptyList()) { + calls++ + "0" + }, + ) + assertEquals(0, calls) + } + + @Test + fun waitsUntilEveryLayerIsOnTheMap() = + runBlocking { + val page = FakePage() + // The layers arrive over three polls, as the SDK's queue empties. + page.onAsk = { n -> + if (n == 2) page.layers += "a" + if (n == 3) page.layers += listOf("b", "c") + } + val done = PageLayerWait.await(listOf("a", "b", "c"), intervalMs = 1) { page.evaluate(it) } + assertTrue(done) + assertEquals(listOf("a", "b", "c"), page.layers.sorted()) + // Asks again only for the layers still missing. + assertEquals(listOf(listOf("a", "b", "c"), listOf("a", "b", "c"), listOf("b", "c")), page.asked) + } + + @Test + fun givesUpAfterTheTimeout() = + runBlocking { + val page = FakePage() + page.layers += "a" + val done = PageLayerWait.await(listOf("a", "never"), timeoutMs = 100, intervalMs = 5) { page.evaluate(it) } + assertFalse(done) + assertTrue(page.asked.size > 1) + } + + @Test + fun aFailedAskCountsAsNothingYet() = + runBlocking { + var calls = 0 + val done = + PageLayerWait.await(listOf("a"), intervalMs = 1) { + calls++ + when (calls) { + 1 -> throw IllegalStateException("bridge not loaded") + 2 -> null + else -> "\"1\"" + } + } + assertTrue(done) + assertEquals(3, calls) + } + + @Test + fun parseReadsTheCountQuotedOrNot() { + assertEquals(3, PageLayerWait.parse("\"3\"")) + assertEquals(3, PageLayerWait.parse("3")) + assertEquals(0, PageLayerWait.parse(null)) + assertEquals(0, PageLayerWait.parse("null")) + } + + @Test + fun idsAreQuotedForTheScript() { + val js = PageLayerWait.script(listOf("polygon-fill-a\"b", "x\\y")) + assertTrue(js, js.contains("[\"polygon-fill-a\\\"b\",\"x\\\\y\"]")) + assertTrue(js.contains(PageLayerWait.MAP_EXPR)) + } +}