From 5b3cc2d8301f922b4283762e414af9a8443eba44 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 02:23:57 -0800 Subject: [PATCH 1/2] Build the ground image's tile levels on its tile size, like the raster layer GroundImage drew nothing on ArcGIS, in 3D and 2D. Its WebTiledLayer still built the levels of detail on a 256 reference when handed 512-pixel tiles, so level L covered the ground of level L-1 and ArcGIS asked for "z=15, x=9618" over Eswatini, where z=15 is x=19235. The ground image tile provider draws the tile the URL names, which is outside the image, so every tile came back transparent (fetched from the device: 512x512, all alpha 0). The raster layer stopped doing this in d0f4819, after ArcGIS 300 (the bump to 300.1.0 in the 1.3.0 release) no longer needed the reading for SceneView. The ground image kept its own copy of the old ladder. Both now take the resolution and scale from one place, WebMercatorTileLevels, so the two cannot drift again. Measured on the TB520FU: the University of Eswatini image is back inside its frame on ArcGIS 3D and 2D. Raster, polygon, marker and heatmap pages look the same before and after. WebMercatorTileLevels is internal; the API dump lists it like ArcGIS2DTiltEmulation, so it is added to the baseline (additions only). --- api/android-for-arcgis.api | 7 ++ .../arcgis/WebMercatorTileLevels.kt | 46 ++++++++++++ .../ArcGISGroundImageOverlayRenderer.kt | 34 +++------ .../ArcGISRasterLayerOverlayRenderer.kt | 24 ++----- .../arcgis/WebMercatorTileLevelsTest.kt | 71 +++++++++++++++++++ 5 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt create mode 100644 src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 1b544a5..3cb3149 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -361,6 +361,13 @@ public final class com.mapconductor.arcgis.MapCameraPositionKt { public static final com.arcgismaps.mapping.view.Camera withZoomLevel(com.arcgismaps.mapping.view.Camera, double); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.arcgismaps.mapping.view.Camera); } +public final class com.mapconductor.arcgis.WebMercatorTileLevels { + public static final com.mapconductor.arcgis.WebMercatorTileLevels INSTANCE; + public static final int DEFAULT_DPI; + public final double unifiedZoomForLevel(int, int); + public final double resolution(int, int); + public final double scale(double); +} public final class com.mapconductor.arcgis.WrapMapView extends android.widget.FrameLayout { public static final com.mapconductor.arcgis.WrapMapView$Companion Companion; public com.arcgismaps.mapping.view.MapView arcGISMapView; diff --git a/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt b/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt new file mode 100644 index 0000000..a1c793c --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt @@ -0,0 +1,46 @@ +package com.mapconductor.arcgis + +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.log2 +import kotlin.math.pow + +/** + * WebTiledLayer に渡すレベルの梯子の数字。ローカルのタイル(ラスター・GroundImage)で共有する。 + * + * 梯子は**統一ズーム**(Google Maps 基準・256px タイル)から組む。 + * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の + * 1px あたりメートル数。512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、 + * level `z` は統一ズーム `z + 1` に対応する。 + * + * 1 枚のタイルが覆う地面は `resolution * tileSize` なので、解像度は**必ず tileSize 基準**で + * 刻む。256 基準のまま tileSize に 512 を渡すと level L のタイルが L-1 の広さを覆い、 + * ArcGIS は番号だけ 1 段深い (z, x, y) を要求する(ArcGIS 300 で計測。200.x の + * SceneView は自前の 256 格子で (col, row) を数えていたので、その読み替えで合っていた)。 + * ラスターは d0f4819 でこれに直したが、GroundImage は同じ読み替えを自前で持ったまま + * 残っていて、地球の別の場所の(空の)タイルを引いて何も表示されなかった。 + * 二度と片方だけ直すことにならないよう、ここ 1 か所で持つ。 + */ +internal object WebMercatorTileLevels { + /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ + private const val UNIFIED_TILE_SIZE = 256.0 + const val DEFAULT_DPI = 96 + private const val INCHES_PER_METER = 39.37 + + /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ + fun unifiedZoomForLevel( + level: Int, + tileSize: Int, + ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) + + /** level のタイル 1px あたりのメートル数。`resolution * tileSize` が level のタイル 1 枚の地面の幅。 */ + fun resolution( + level: Int, + tileSize: Int, + ): Double = AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / 2.0.pow(unifiedZoomForLevel(level, tileSize)) + + /** + * 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、解像度と同じ比率で + * 刻んでいないと 1 段ずれる。3D の SceneView は見ていない(自前の 256px の梯子で選ぶ)。 + */ + fun scale(resolution: Double): Double = resolution * DEFAULT_DPI * INCHES_PER_METER +} diff --git a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt index 3dd071e..e35e8d9 100644 --- a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt @@ -9,16 +9,14 @@ import com.arcgismaps.mapping.layers.TileInfo import com.arcgismaps.mapping.layers.WebTiledLayer import com.mapconductor.arcgis.ArcGISGeoViewHolder import com.mapconductor.arcgis.ArcGISOverlayStacking +import com.mapconductor.arcgis.WebMercatorTileLevels import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer import com.mapconductor.core.groundimage.GroundImageEntityInterface import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.core.groundimage.GroundImageTileProvider -import com.mapconductor.core.projection.Earth import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS import com.mapconductor.core.tileserver.LocalTileServer -import kotlin.math.PI -import kotlin.math.pow import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -168,7 +166,7 @@ class ArcGISGroundImageOverlayRenderer( private fun buildWebMercatorTileInfo(tileSize: Int): TileInfo { val spatialReference = SpatialReference(WEB_MERCATOR_WKID) val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) - val levels = buildWebMercatorLevels(resolveLodReferenceTileSize(tileSize)) + val levels = buildWebMercatorLevels(tileSize) return TileInfo( DEFAULT_DPI, TileImageFormat.Png, @@ -180,22 +178,14 @@ class ArcGISGroundImageOverlayRenderer( ) } - private fun buildWebMercatorLevels(tileSize: Int): List { - val initialResolution = - (2.0 * PI * WEB_MERCATOR_RADIUS_METERS) / tileSize.toDouble() - val levels = mutableListOf() - for (level in MIN_ZOOM..MAX_ZOOM) { - val resolution = initialResolution / 2.0.pow(level.toDouble()) - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER - levels.add(LevelOfDetail(level, resolution, scale)) - } - return levels - } - - private fun resolveLodReferenceTileSize(tileSize: Int): Int = - when (tileSize) { - 512 -> 256 - else -> tileSize + // 梯子はラスターと同じもの([WebMercatorTileLevels])を使う。以前は 512 のときだけ + // 256 基準へ読み替えていて、ArcGIS 300 では level L に L-1 の格子の (x, y) を付けて + // 要求された。GroundImageTileProvider はそれを素直に z=L として描くので、どのタイルも + // 画像の外になり透明だった。 + private fun buildWebMercatorLevels(tileSize: Int): List = + (MIN_ZOOM..MAX_ZOOM).map { level -> + val resolution = WebMercatorTileLevels.resolution(level, tileSize) + LevelOfDetail(level, resolution, WebMercatorTileLevels.scale(resolution)) } private fun buildWebMercatorExtent(): Envelope = @@ -232,11 +222,9 @@ class ArcGISGroundImageOverlayRenderer( companion object { private const val WEB_MERCATOR_WKID = 3857 - private const val WEB_MERCATOR_RADIUS_METERS = Earth.RADIUS_METERS private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 + private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI private const val MIN_ZOOM = 0 private const val MAX_ZOOM = 22 } diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt index 1a5a893..cd28760 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt @@ -12,6 +12,7 @@ import com.arcgismaps.mapping.layers.WebTiledLayer import com.mapconductor.arcgis.ArcGISGeoViewHolder import com.mapconductor.arcgis.ArcGISMapViewHolder import com.mapconductor.arcgis.ArcGISOverlayStacking +import com.mapconductor.arcgis.WebMercatorTileLevels import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS import com.mapconductor.core.raster.RasterHeaderRuleSet @@ -22,8 +23,6 @@ import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.log2 -import kotlin.math.pow import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -315,7 +314,7 @@ class ArcGISRasterLayerOverlayRenderer( * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 - * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も + * その対応を [WebMercatorTileLevels.unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 * * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに @@ -329,10 +328,7 @@ class ArcGISRasterLayerOverlayRenderer( ): List { val levels = mutableListOf() for (level in minZoom..maxZoom) { - val unifiedZoom = unifiedZoomForLevel(level, tileSize) - val resolution = - AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / - 2.0.pow(unifiedZoom) + val resolution = WebMercatorTileLevels.resolution(level, tileSize) // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 // 解像度と同じ比率で刻んでいないと 1 段ずれる。 // @@ -341,18 +337,12 @@ class ArcGISRasterLayerOverlayRenderer( // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 // タイルの位置は申告どおりに決まる。 - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER + val scale = WebMercatorTileLevels.scale(resolution) levels.add(LevelOfDetail(level, resolution, scale)) } return levels } - /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ - private fun unifiedZoomForLevel( - level: Int, - tileSize: Int, - ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) - private fun buildWebMercatorExtent(): Envelope = Envelope( WEB_MERCATOR_MIN, @@ -368,11 +358,7 @@ class ArcGISRasterLayerOverlayRenderer( private const val WEB_MERCATOR_WKID = 3857 private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 - - /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ - private const val UNIFIED_TILE_SIZE = 256.0 + private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI private const val DEFAULT_MIN_ZOOM = 0 private const val DEFAULT_MAX_ZOOM = 22 } diff --git a/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt b/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt new file mode 100644 index 0000000..592a9aa --- /dev/null +++ b/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt @@ -0,0 +1,71 @@ +package com.mapconductor.arcgis + +import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.math.PI +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.tan + +/** + * ArcGIS がレベルの梯子から数える (level, col, row) が、ローカルのタイルサーバ + * (GroundImageTileProvider など、素直な XYZ で z を読む)の同じ番号のタイルを指すこと。 + * + * 512px のときだけ 256 基準へ読み替えていた頃は、level L に L-1 の格子の col/row が付き + * (Eswatini の z=15 で x=9617 になる。正しくは 19235)、GroundImage が何も描かれなかった。 + */ +class WebMercatorTileLevelsTest { + // GroundImage サンプルの画像の中心(University of Eswatini) + private val lon = 31.306239 + private val lat = -26.479235 + + private fun mercatorX(lon: Double) = lon / 180.0 * WEB_MERCATOR_MAX_EXTENT_METERS + + private fun mercatorY(lat: Double) = ln(tan(PI / 4 + Math.toRadians(lat) / 2)) / PI * WEB_MERCATOR_MAX_EXTENT_METERS + + /** WebTiledLayer の TileInfo(原点は左上)から ArcGIS が出す col / row。 */ + private fun arcgisColRow( + level: Int, + tileSize: Int, + ): Pair { + val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize + val col = floor((mercatorX(lon) + WEB_MERCATOR_MAX_EXTENT_METERS) / footprint).toInt() + val row = floor((WEB_MERCATOR_MAX_EXTENT_METERS - mercatorY(lat)) / footprint).toInt() + return col to row + } + + /** 素直な XYZ で z=level のときの x / y(タイルの px 数に依らない)。 */ + private fun xyz(level: Int): Pair { + val n = 2.0.pow(level) + val x = floor((lon + 180.0) / 360.0 * n).toInt() + val y = floor((1.0 - ln(tan(Math.toRadians(lat)) + 1.0 / kotlin.math.cos(Math.toRadians(lat))) / PI) / 2.0 * n).toInt() + return x to y + } + + @Test + fun levelNamesTheSameTileAsXyz_for512() { + for (level in listOf(0, 5, 13, 15, 18)) { + assertEquals("level $level", xyz(level), arcgisColRow(level, 512)) + } + } + + @Test + fun levelNamesTheSameTileAsXyz_for256() { + for (level in listOf(0, 5, 13, 15, 18)) { + assertEquals("level $level", xyz(level), arcgisColRow(level, 256)) + } + } + + @Test + fun aLevelCoversTheGroundOfThatZoom_whateverTheTileSize() { + val circumference = 2 * WEB_MERCATOR_MAX_EXTENT_METERS + for (tileSize in listOf(256, 512)) { + for (level in 0..22) { + val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize + assertEquals("tileSize $tileSize level $level", circumference / 2.0.pow(level), footprint, footprint * 1e-6) + } + } + } +} From 30fbf215657e8bee0d22cd7338a115c4558ad8e5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 10:18:07 -0800 Subject: [PATCH 2/2] Look up stacked layers by identity, not by Layer.equals On ArcGIS 3D a raster added after a ground image went on top of it: the ground image's band key could not be found again (logged as "?" for the very layer registered a moment earlier), so the raster's insertion point was "none above" and it was appended. ArcGIS's Layer overrides equals and hashCode, and a WeakHashMap keyed on it does not reliably find the same layer again. The 2D map happened to work; the 3D one did not. The stacking table now holds layers by weak reference and looks them up with ===. It holds at most a few dozen entries, so a linear scan is fine. Measured on the TB520FU (stack check page, raster at 0.85 opacity under a checker ground image): before, in reverse order the checker showed only as a faint tint through the raster on 3D; after, it sits above the raster in both orders on 3D and 2D, under polygons, circles and polylines as before. IdentityWeakTable is internal; the API dump lists it, so it is added to the baseline (additions only). --- api/android-for-arcgis.api | 6 +++ .../arcgis/ArcGISOverlayStacking.kt | 32 ++++++++++-- .../arcgis/IdentityWeakTableTest.kt | 52 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 3cb3149..60defdf 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -347,6 +347,12 @@ public final class com.mapconductor.arcgis.GeoPointKt { public final class com.mapconductor.arcgis.GeoRectBoundsKt { public static final com.arcgismaps.geometry.Envelope toEnvelope(com.mapconductor.core.features.GeoRectBounds); } +public final class com.mapconductor.arcgis.IdentityWeakTable { + public com.mapconductor.arcgis.IdentityWeakTable(); + public final V get(K); + public final void put(K, V); + public final int getSize(); +} public final class com.mapconductor.arcgis.MapCameraPositionKt { public static final double EARTH_MEAN_RADIUS_METERS; public static final double DEFAULT_MAX_GMAPS_TILT; diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt b/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt index 1b9bfe6..caac2c7 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt @@ -3,7 +3,7 @@ package com.mapconductor.arcgis import com.arcgismaps.mapping.layers.Layer import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.OverlayStacking -import java.util.WeakHashMap +import java.lang.ref.WeakReference /** * 重なり順([OverlayStacking])。ArcGIS では: @@ -18,7 +18,7 @@ import java.util.WeakHashMap * 下になる(SDK の制約。マーカーの帯には置けない)。operational layer の中では一番上に置く。 */ internal object ArcGISOverlayStacking { - private val keys = WeakHashMap() + private val keys = IdentityWeakTable() /** マーカーのタイル: operational layer の中で一番上。 */ val MARKER_TILE = OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE) @@ -36,7 +36,7 @@ internal object ArcGISOverlayStacking { ) { val before = synchronized(keys) { - keys[layer] = key + keys.put(layer, key) OverlayStacking.insertBefore(layers.toList(), key) { keys[it] } } val index = before?.let { layers.indexOf(it) } ?: -1 @@ -48,6 +48,30 @@ internal object ArcGISOverlayStacking { old: Layer, new: Layer, ) { - synchronized(keys) { keys[old]?.let { keys[new] = it } } + synchronized(keys) { keys[old]?.let { keys.put(new, it) } } } } + +/** + * 層 → 値の表。**同一性(===)で引き**、層は弱参照で持つ。 + * + * 以前は `WeakHashMap` だったが、ArcGIS の `Layer` は `equals` / `hashCode` を + * 上書きしていて、載せた後に同じ層を引いても見つからないことがあった(3D で、GroundImage を + * 先に載せてからラスターを足すと、GroundImage の鍵が引けずにラスターが一番上に積まれた)。 + * 層の数は高々数十なので線形探索でよい。呼び出し側で同期する。 + */ +internal class IdentityWeakTable { + private val entries = ArrayList, V>>() + + operator fun get(key: K): V? = entries.firstOrNull { it.first.get() === key }?.second + + fun put( + key: K, + value: V, + ) { + entries.removeAll { it.first.get().let { k -> k == null || k === key } } + entries.add(WeakReference(key) to value) + } + + val size: Int get() = entries.count { it.first.get() != null } +} diff --git a/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt b/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt new file mode 100644 index 0000000..75cdddb --- /dev/null +++ b/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt @@ -0,0 +1,52 @@ +package com.mapconductor.arcgis + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class IdentityWeakTableTest { + /** ArcGIS の Layer のように、載せた後で hashCode / equals が変わる鍵。 */ + private class Shifty( + var hash: Int, + ) { + override fun hashCode(): Int = hash + + override fun equals(other: Any?): Boolean = other is Shifty && other.hash == hash + } + + @Test + fun findsTheKeyAfterItsHashCodeChanges() { + val table = IdentityWeakTable() + val layer = Shifty(1) + table.put(layer, "ground") + + layer.hash = 2 + + assertEquals("ground", table[layer]) + } + + @Test + fun doesNotConfuseEqualButDistinctKeys() { + val table = IdentityWeakTable() + val a = Shifty(7) + val b = Shifty(7) + table.put(a, "raster") + + assertNull(table[b]) + table.put(b, "ground") + assertEquals("raster", table[a]) + assertEquals("ground", table[b]) + assertEquals(2, table.size) + } + + @Test + fun putReplacesTheValueOfTheSameKey() { + val table = IdentityWeakTable() + val a = Shifty(1) + table.put(a, "raster") + table.put(a, "ground") + + assertEquals("ground", table[a]) + assertEquals(1, table.size) + } +}