From 4a40cadcd6266dd494d312107b0f708470eb859c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 3 Feb 2026 16:12:13 -0300 Subject: [PATCH 1/2] tests(#2406) --- package-lock.json | 15 +- tests/conftest.py | 25 ++ tests/helpers/__init__.py | 18 + tests/helpers/assertions.py | 81 +++++ tests/helpers/factories.py | 109 ++++++ tests/mocks/__init__.py | 40 +++ tests/mocks/mock_ee.py | 590 +++++++++++++++++++++++++++++++++ tests/mocks/mock_map.py | 232 +++++++++++++ tests/mocks/mock_osmnx.py | 112 +++++++ tests/mocks/mock_plotly.py | 59 ++++ tests/mocks/mock_requests.py | 42 +++ tests/test_ai.py | 617 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 40 +++ tests/test_deck.py | 214 ++++++++++++ tests/test_foliumap.py | 350 ++++++++++++++++++++ tests/test_infrastructure.py | 228 +++++++++++++ tests/test_kepler.py | 335 +++++++++++++++++++ tests/test_maplibregl.py | 218 ++++++++++++ tests/test_ml.py | 392 ++++++++++++++++++++++ tests/test_osm.py | 469 ++++++++++++++++++++++++++ tests/test_plot.py | 426 ++++++++++++++++++++++++ tests/test_plotlymap.py | 257 +++++++++++++++ tests/test_timelapse.py | 620 +++++++++++++++++++++++++++++++++++ 23 files changed, 5486 insertions(+), 3 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/assertions.py create mode 100644 tests/helpers/factories.py create mode 100644 tests/mocks/__init__.py create mode 100644 tests/mocks/mock_ee.py create mode 100644 tests/mocks/mock_map.py create mode 100644 tests/mocks/mock_osmnx.py create mode 100644 tests/mocks/mock_plotly.py create mode 100644 tests/mocks/mock_requests.py create mode 100644 tests/test_ai.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_deck.py create mode 100644 tests/test_foliumap.py create mode 100644 tests/test_infrastructure.py create mode 100644 tests/test_kepler.py create mode 100644 tests/test_maplibregl.py create mode 100644 tests/test_ml.py create mode 100644 tests/test_osm.py create mode 100644 tests/test_plot.py create mode 100644 tests/test_plotlymap.py create mode 100644 tests/test_timelapse.py diff --git a/package-lock.json b/package-lock.json index 352282426b..2ed3ae8109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1432,6 +1432,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1773,6 +1774,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001663", "electron-to-chromium": "^1.5.28", @@ -2294,7 +2296,8 @@ "version": "0.0.1342118", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1342118.tgz", "integrity": "sha512-75fMas7PkYNDTmDyb6PRJCH7ILmHLp+BhrZGeMsa4bCh40DTxgCz2NRy5UDzII4C5KuD0oBMZ9vXKhEl6UD/3w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/di": { "version": "0.0.1", @@ -3515,7 +3518,8 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.4.0.tgz", "integrity": "sha512-T4fio3W++llLd7LGSGsioriDHgWyhoL6YTu4k37uwJLF7DzOzspz7mNxRoM3cQdLWtL/ebazQpIf/yZGJx/gzg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/jasmine-expect": { "version": "5.0.0", @@ -3677,6 +3681,7 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -4021,7 +4026,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4909,6 +4913,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5776,6 +5781,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5996,6 +6002,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -6042,6 +6049,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6404,6 +6412,7 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.19.tgz", "integrity": "sha512-GNKw4mEUn5yWU2QPHRx8jppxmCm9KzbBhB4qJLUJFiiYD0g/tDVgXQ7aPkyh01YO28kbs2J/BEbWBagjuWyejw==", "dev": true, + "peer": true, "dependencies": { "lib0": "^0.2.86" }, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..c14af6c7a9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest + + +class GeemapTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.test_data_dir = os.path.join(os.path.dirname(__file__), "data") + cls.temp_dir = tempfile.mkdtemp() + + @classmethod + def tearDownClass(cls) -> None: + if os.path.exists(cls.temp_dir): + shutil.rmtree(cls.temp_dir) + + def get_fixture(self, filename: str) -> str: + return os.path.join(self.test_data_dir, filename) + + def get_temp_path(self, filename: str) -> str: + return os.path.join(self.temp_dir, filename) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000000..c098aaaca9 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from tests.helpers.assertions import (assert_file_created, + assert_valid_geojson, + assert_valid_hex_color) +from tests.helpers.factories import (create_ee_feature_collection, + create_ee_geometry, create_ee_image, + create_sample_dataframe) + +__all__ = [ + "assert_file_created", + "assert_valid_geojson", + "assert_valid_hex_color", + "create_ee_feature_collection", + "create_ee_geometry", + "create_ee_image", + "create_sample_dataframe", +] diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py new file mode 100644 index 0000000000..1dd42ad289 --- /dev/null +++ b/tests/helpers/assertions.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +import re +import unittest + + +def assert_valid_geojson(test_case: unittest.TestCase, geojson: dict) -> None: + valid_types = [ + "Feature", + "FeatureCollection", + "Point", + "LineString", + "Polygon", + "MultiPoint", + "MultiLineString", + "MultiPolygon", + "GeometryCollection", + ] + test_case.assertIn("type", geojson) + test_case.assertIn(geojson["type"], valid_types) + + +def assert_file_created(test_case: unittest.TestCase, filepath: str) -> None: + test_case.assertTrue( + os.path.exists(filepath), + f"File not created: {filepath}", + ) + + +def assert_file_not_empty(test_case: unittest.TestCase, filepath: str) -> None: + assert_file_created(test_case, filepath) + test_case.assertGreater( + os.path.getsize(filepath), + 0, + f"File is empty: {filepath}", + ) + + +def assert_valid_hex_color(test_case: unittest.TestCase, color: str) -> None: + pattern = r"^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" + test_case.assertIsNotNone( + re.match(pattern, color), + f"Invalid hex color: {color}", + ) + + +def assert_valid_rgb_color( + test_case: unittest.TestCase, color: tuple[int, int, int] +) -> None: + test_case.assertEqual(len(color), 3, "RGB color must have 3 components") + for i, component in enumerate(color): + test_case.assertGreaterEqual( + component, 0, f"RGB component {i} must be >= 0" + ) + test_case.assertLessEqual( + component, 255, f"RGB component {i} must be <= 255" + ) + + +def assert_valid_bbox( + test_case: unittest.TestCase, + bbox: tuple[float, float, float, float], +) -> None: + test_case.assertEqual(len(bbox), 4, "Bounding box must have 4 components") + west, south, east, north = bbox + test_case.assertLessEqual(west, east, "West must be <= East") + test_case.assertLessEqual(south, north, "South must be <= North") + test_case.assertGreaterEqual(west, -180, "West must be >= -180") + test_case.assertLessEqual(east, 180, "East must be <= 180") + test_case.assertGreaterEqual(south, -90, "South must be >= -90") + test_case.assertLessEqual(north, 90, "North must be <= 90") + + +def assert_dict_contains_keys( + test_case: unittest.TestCase, + d: dict, + keys: list[str], +) -> None: + for key in keys: + test_case.assertIn(key, d, f"Missing key: {key}") diff --git a/tests/helpers/factories.py b/tests/helpers/factories.py new file mode 100644 index 0000000000..3dbb403593 --- /dev/null +++ b/tests/helpers/factories.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import pandas as pd + +from tests.mocks import mock_ee + + +def create_ee_image( + bands: list[str] | None = None, + crs: str = "EPSG:4326", +) -> mock_ee.Image: + img = mock_ee.Image() + img._bands = bands or ["B1", "B2", "B3"] + img._crs = crs + return img + + +def create_ee_feature_collection( + features: list | None = None, +) -> mock_ee.FeatureCollection: + fc = mock_ee.FeatureCollection(features or []) + return fc + + +def create_ee_geometry( + geom_type: str = "Point", + coords: list | None = None, +) -> mock_ee.Geometry: + coords = coords or [0, 0] + if geom_type == "Point": + return mock_ee.Geometry.Point(coords) + elif geom_type == "Polygon": + return mock_ee.Geometry.Polygon(coords) + elif geom_type == "LineString": + return mock_ee.Geometry.LineString(coords) + elif geom_type == "Rectangle": + return mock_ee.Geometry.Rectangle(coords) + else: + geom = mock_ee.Geometry(type=mock_ee.String(geom_type)) + geom._coords = coords + return geom + + +def create_ee_feature( + geometry: mock_ee.Geometry | None = None, + properties: dict | None = None, +) -> mock_ee.Feature: + geom = geometry or create_ee_geometry("Point", [0, 0]) + return mock_ee.Feature(geom, properties) + + +def create_ee_image_collection( + images: list[mock_ee.Image] | None = None, + count: int = 3, +) -> mock_ee.ImageCollection: + if images is None: + images = [create_ee_image() for _ in range(count)] + return mock_ee.ImageCollection(images) + + +def create_sample_dataframe( + rows: int = 5, + columns: list[str] | None = None, +) -> pd.DataFrame: + columns = columns or ["x", "y", "value"] + data = {col: list(range(rows)) for col in columns} + return pd.DataFrame(data) + + +def create_sample_geodataframe( + rows: int = 5, + crs: str = "EPSG:4326", +): + try: + import geopandas as gpd + from shapely.geometry import Point + except ImportError: + return None + + data = { + "name": [f"feature_{i}" for i in range(rows)], + "value": list(range(rows)), + "geometry": [Point(i, i) for i in range(rows)], + } + return gpd.GeoDataFrame(data, crs=crs) + + +def create_sample_geojson( + num_features: int = 3, + geom_type: str = "Point", +) -> dict: + features = [] + for i in range(num_features): + if geom_type == "Point": + coordinates = [i, i] + elif geom_type == "Polygon": + coordinates = [[[i, i], [i + 1, i], [i + 1, i + 1], [i, i + 1], [i, i]]] + elif geom_type == "LineString": + coordinates = [[i, i], [i + 1, i + 1]] + else: + coordinates = [i, i] + + features.append({ + "type": "Feature", + "properties": {"id": i, "name": f"feature_{i}"}, + "geometry": {"type": geom_type, "coordinates": coordinates}, + }) + + return {"type": "FeatureCollection", "features": features} diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py new file mode 100644 index 0000000000..170791f0b9 --- /dev/null +++ b/tests/mocks/__init__.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from tests.mocks.mock_ee import (Algorithms, Dictionary, Feature, + FeatureCollection, Geometry, Image, + ImageCollection, List, Reducer, String) +from tests.mocks.mock_map import (FakeEeTileLayer, FakeGeoJSONLayer, FakeMap, + FakeTileLayer) +from tests.mocks.mock_osmnx import (MockGeoDataFrame, + mock_features_from_address, + mock_features_from_bbox, + mock_features_from_point) +from tests.mocks.mock_plotly import MockFigure, MockPlotlyExpress +from tests.mocks.mock_requests import (MockResponse, RequestError, + create_mock_response) + +__all__ = [ + "Algorithms", + "create_mock_response", + "Dictionary", + "FakeEeTileLayer", + "FakeGeoJSONLayer", + "FakeMap", + "FakeTileLayer", + "Feature", + "FeatureCollection", + "Geometry", + "Image", + "ImageCollection", + "List", + "mock_features_from_address", + "mock_features_from_bbox", + "mock_features_from_point", + "MockFigure", + "MockGeoDataFrame", + "MockPlotlyExpress", + "MockResponse", + "Reducer", + "RequestError", + "String", +] diff --git a/tests/mocks/mock_ee.py b/tests/mocks/mock_ee.py new file mode 100644 index 0000000000..ba71352f88 --- /dev/null +++ b/tests/mocks/mock_ee.py @@ -0,0 +1,590 @@ +from __future__ import annotations + +import box + + +class Image: + + def __init__(self, *_, **__) -> None: + self._bands: list[str] = ["B1", "B2", "B3"] + self._crs: str = "EPSG:4326" + + @classmethod + def constant(cls, *_, **__) -> "Image": + return Image() + + def getMapId(self, *_, **__) -> box.Box: + return box.Box({"tile_fetcher": {"url_format": "url-format"}}) + + def updateMask(self, *_, **__) -> "Image": + return self + + def blend(self, *_, **__) -> "Image": + return self + + def bandNames(self, *_, **__) -> "List": + return List(self._bands) + + def reduceRegion(self, *_, **__) -> "Dictionary": + return Dictionary({"B1": 42, "B2": 3.14}) + + def select(self, *args, **__) -> "Image": + if args: + self._bands = list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + return self + + def clip(self, *_, **__) -> "Image": + return self + + def rename(self, *args, **__) -> "Image": + if args: + self._bands = list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + return self + + def multiply(self, *_, **__) -> "Image": + return self + + def add(self, *_, **__) -> "Image": + return self + + def divide(self, *_, **__) -> "Image": + return self + + def subtract(self, *_, **__) -> "Image": + return self + + def normalizedDifference(self, bands: list[str] | None = None, *_, **__) -> "Image": + return self + + def expression(self, *_, **__) -> "Image": + return self + + def abs(self, *_, **__) -> "Image": + return self + + def sqrt(self, *_, **__) -> "Image": + return self + + def log(self, *_, **__) -> "Image": + return self + + def log10(self, *_, **__) -> "Image": + return self + + def pow(self, *_, **__) -> "Image": + return self + + def addBands(self, *_, **__) -> "Image": + return self + + def getInfo(self) -> dict: + return { + "type": "Image", + "bands": [ + { + "id": "band-1", + "data_type": { + "type": "PixelType", + "precision": "int", + "min": -2, + "max": 2, + }, + "dimensions": [4, 2], + "crs": "EPSG:4326", + "crs_transform": [1, 0, -180, 0, -1, 84], + }, + ], + "version": 42, + "id": "some/image/id", + "properties": { + "type_name": "Image", + "keywords": ["keyword-1", "keyword-2"], + "thumb": "https://some-thumbnail.png", + }, + } + + +class List: + + def __init__(self, items: list | None = None, *_, **__) -> None: + self.items = items or [] + + def getInfo(self, *_, **__) -> list: + return self.items + + def size(self, *_, **__) -> "Number": + return Number(len(self.items)) + + def get(self, index: int, *_, **__): + return self.items[index] if index < len(self.items) else None + + def map(self, func, *_, **__) -> "List": + return List([func(item) for item in self.items]) + + +class Number: + + def __init__(self, value: int | float = 0) -> None: + self.value = value + + def getInfo(self, *_, **__) -> int | float: + return self.value + + +class Dictionary: + + def __init__(self, data: dict | None = None) -> None: + self.data = data or {} + + def getInfo(self) -> dict: + return self.data + + def get(self, key: str, *_, **__): + return self.data.get(key) + + def keys(self, *_, **__) -> List: + return List(list(self.data.keys())) + + +class ReduceRegionResult: + + def getInfo(self) -> None: + return None + + +class Geometry: + + geometry = None + + def __init__(self, *args, **kwargs) -> None: + if len(args): + self.geometry = args[0] + if kwargs.get("type"): + self.geom_type = kwargs.get("type") + else: + self.geom_type = None + + @classmethod + def Point(cls, coords: list | None = None, *_, **__) -> "Geometry": + geom = Geometry(type=String("Point")) + geom._coords = coords or [0, 0] + return geom + + @classmethod + def BBox(cls, *_, **__) -> "Geometry": + return Geometry(type=String("BBox")) + + @classmethod + def Polygon(cls, coords: list | None = None, *_, **__) -> "Geometry": + geom = Geometry(type=String("Polygon")) + geom._coords = coords + return geom + + @classmethod + def Rectangle(cls, coords: list | None = None, *_, **__) -> "Geometry": + geom = Geometry(type=String("Polygon")) + geom._coords = coords + return geom + + @classmethod + def LineString(cls, coords: list | None = None, *_, **__) -> "Geometry": + geom = Geometry(type=String("LineString")) + geom._coords = coords + return geom + + @classmethod + def MultiPoint(cls, coords: list | None = None, *_, **__) -> "Geometry": + geom = Geometry(type=String("MultiPoint")) + geom._coords = coords + return geom + + def transform(self, *_, **__) -> "Geometry": + return Geometry(type=self.geom_type) + + def bounds(self, *_, **__) -> "Geometry": + return Geometry.Polygon() + + def centroid(self, *_, **__) -> "Geometry": + return Geometry.Point() + + def buffer(self, *_, **__) -> "Geometry": + return Geometry.Polygon() + + def type(self, *_, **__) -> "String": + return self.geom_type + + def coordinates(self, *_, **__) -> List: + return List(getattr(self, "_coords", [])) + + def getInfo(self, *_, **__) -> dict: + the_type = self.type() + if the_type is None: + return {"type": "Unknown"} + type_value = the_type.value + if type_value == "Polygon": + return { + "geodesic": False, + "type": "Polygon", + "coordinates": [ + [[-178, -76], [179, -76], [179, 80], [-178, 80], [-178, -76]] + ], + } + if type_value == "Point": + return { + "geodesic": False, + "type": "Point", + "coordinates": [120, -70], + } + if type_value == "BBox": + return { + "geodesic": False, + "type": "Polygon", + "coordinates": [[0, 1], [1, 2], [0, 1]], + } + if type_value == "LineString": + return { + "geodesic": False, + "type": "LineString", + "coordinates": [[0, 0], [1, 1]], + } + if type_value == "MultiPoint": + return { + "geodesic": False, + "type": "MultiPoint", + "coordinates": [[0, 0], [1, 1]], + } + return {"type": type_value} + + def __eq__(self, other: object) -> bool: + return self.geometry == getattr(other, "geometry", None) + + +class String: + + def __init__(self, value: str = "") -> None: + self.value = value + + def compareTo(self, other_str: "String") -> bool: + return self.value == other_str.value + + def getInfo(self, *_, **__) -> str: + return self.value + + def replace(self, pattern: str, replacement: str, flags: str = "") -> "String": + return String(self.value.replace(pattern, replacement)) + + +class FeatureCollection: + + features: list = [] + + def __init__(self, *args, **_) -> None: + if len(args): + self.features = args[0] if isinstance(args[0], list) else [] + else: + self.features = [] + + def style(self, *_, **__) -> Image: + return Image() + + def first(self, *_, **__) -> "Feature": + return Feature() + + def filterBounds(self, *_, **__) -> "FeatureCollection": + return FeatureCollection() + + def filter(self, *_, **__) -> "FeatureCollection": + return FeatureCollection(self.features) + + def geometry(self, *_, **__) -> Geometry: + return Geometry.Polygon() + + def aggregate_array(self, *_, **__) -> List: + return List(["aggregation-one", "aggregation-two"]) + + def size(self, *_, **__) -> Number: + return Number(len(self.features)) + + def toList(self, *_, **__) -> List: + return List(self.features) + + def getInfo(self, *_, **__) -> dict: + return { + "type": "FeatureCollection", + "features": [f.getInfo() if hasattr(f, "getInfo") else f for f in self.features], + } + + def __eq__(self, other: object) -> bool: + return self.features == getattr(other, "features", None) + + +class Feature: + + feature = None + properties = None + + def __init__(self, *args, **_) -> None: + if len(args) > 0: + self.feature = args[0] + if len(args) >= 2: + self.properties = args[1] + + def geometry(self, *_, **__) -> Geometry: + return Geometry(type=String("Polygon")) + + def get(self, key: str, *_, **__): + if self.properties: + return self.properties.get(key) + return None + + def set(self, key: str, value, *_, **__) -> "Feature": + if self.properties is None: + self.properties = {} + self.properties[key] = value + return self + + def getInfo(self, *_, **__) -> dict: + return { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[-67.1, 46.2], [-67.3, 46.4], [-67.5, 46.6]], + }, + "id": "00000000000000000001", + "properties": { + "fullname": "some-full-name", + "linearid": "110469267091", + "mtfcc": "S1400", + "rttyp": "some-rttyp", + }, + } + + def __eq__(self, other: object) -> bool: + features_equal = self.feature == getattr(other, "feature", None) + properties_equal = self.properties == getattr(other, "properties", None) + return features_equal and properties_equal + + def propertyNames(self, *_, **__) -> List: + return List(["prop-1", "prop-2"]) + + +class ImageCollection: + + def __init__(self, images: list | str | None = None, *_, **__) -> None: + if isinstance(images, str): + self.images = [Image()] + elif images is None: + self.images = [] + else: + self.images = images + + def mosaic(self, *_, **__) -> Image: + return Image() + + def median(self, *_, **__) -> Image: + return Image() + + def mean(self, *_, **__) -> Image: + return Image() + + def first(self, *_, **__) -> Image: + return Image() if self.images else Image() + + def filterDate(self, *_, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def filterBounds(self, *_, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def filter(self, *_, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def filterMetadata(self, *_, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def select(self, *args, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def map(self, func, *_, **__) -> "ImageCollection": + return ImageCollection(self.images) + + def size(self, *_, **__) -> Number: + return Number(len(self.images)) + + def toList(self, *_, **__) -> List: + return List(self.images) + + def getInfo(self) -> dict: + return { + "type": "ImageCollection", + "bands": [], + "features": [f.getInfo() for f in self.images], + } + + +class Reducer: + + @classmethod + def first(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def mean(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def median(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def sum(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def min(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def max(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def count(cls, *_, **__) -> "Reducer": + return Reducer() + + @classmethod + def stdDev(cls, *_, **__) -> "Reducer": + return Reducer() + + +class Algorithms: + + @classmethod + def If(cls, *_, **__) -> "Algorithms": + return Algorithms() + + +class Filter: + + @classmethod + def eq(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def lt(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def lte(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def gt(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def gte(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def date(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def bounds(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def And(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def Or(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def listContains(cls, *_, **__) -> "Filter": + return Filter() + + @classmethod + def inList(cls, *_, **__) -> "Filter": + return Filter() + + +class Date: + + def __init__(self, date_str: str | None = None, *_, **__) -> None: + self._date = date_str or "2020-01-01" + + def getInfo(self, *_, **__) -> dict: + return {"type": "Date", "value": self._date} + + def format(self, *_, **__) -> String: + return String(self._date) + + @classmethod + def fromYMD(cls, year: int, month: int, day: int, *_, **__) -> "Date": + return Date(f"{year}-{month:02d}-{day:02d}") + + +class Projection: + + def __init__(self, crs: str = "EPSG:4326", *_, **__) -> None: + self._crs = crs + + def getInfo(self, *_, **__) -> dict: + return {"type": "Projection", "crs": self._crs} + + +class Classifier: + + def __init__(self, *_, **__) -> None: + self._trees = [] + + @classmethod + def decisionTreeEnsemble(cls, trees, *_, **__) -> "Classifier": + clf = Classifier() + clf._trees = trees + return clf + + def getInfo(self, *_, **__) -> dict: + return {"type": "Classifier", "trees": len(self._trees)} + + +class _ExportTable: + + @staticmethod + def toAsset(collection, description: str = "", assetId: str = "", *_, **__): + return _Task(description=description, assetId=assetId) + + +class _Export: + + table = _ExportTable() + + +class _Task: + + def __init__(self, description: str = "", assetId: str = "") -> None: + self.description = description + self.assetId = assetId + self._started = False + + def start(self) -> None: + self._started = True + + +class _Batch: + + Export = _Export() + + +batch = _Batch() + + +def Initialize(*_, **__) -> None: + pass + + +def Authenticate(*_, **__) -> None: + pass diff --git a/tests/mocks/mock_map.py b/tests/mocks/mock_map.py new file mode 100644 index 0000000000..d9b3cbee05 --- /dev/null +++ b/tests/mocks/mock_map.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import unittest.mock + +import ipywidgets +import traitlets + +from tests.mocks import mock_ee + + +class FakeMap: + + def __init__(self) -> None: + self.default_style: dict = {} + self.interaction_handlers: set = set() + self.scale: int = 1024 + self.zoom: int = 7 + self.center: list = [0, 0] + self.layers: list = [] + self.ee_layers: dict = {} + self.geojson_layers: list = [] + self.controls: list = [] + self.add = unittest.mock.MagicMock() + + self._recognized_attrs = set(self.__dict__.keys()) + + def __setattr__(self, k: str, v) -> None: + if hasattr(self, "_recognized_attrs") and k not in self._recognized_attrs: + raise AttributeError(f"{k} is not a recognized attr") + super().__setattr__(k, v) + + def on_interaction(self, func, remove: bool = False) -> None: + if remove: + if func in self.interaction_handlers: + self.interaction_handlers.remove(func) + else: + raise ValueError("Removing an unknown on_interaction func.") + else: + if func in self.interaction_handlers: + raise ValueError("This on_interaction func already exists.") + else: + self.interaction_handlers.add(func) + + def click(self, coordinates: tuple, event_type: str) -> None: + for handler in self.interaction_handlers: + handler(coordinates=coordinates, type=event_type) + + def get_scale(self) -> int: + return self.scale + + @property + def bounds(self) -> tuple: + return ((1, 2), (3, 4)) + + def find_layer_index(self, name: str) -> int: + layers = self.layers + for index, layer in enumerate(layers): + if layer.name == name: + return index + return -1 + + def add_layer( + self, + ee_object, + vis_params: dict | None = None, + name: str | None = None, + shown: bool = True, + opacity: float = 1.0, + ) -> None: + layer = ee_object + if isinstance( + ee_object, + ( + mock_ee.FeatureCollection, + mock_ee.Feature, + mock_ee.Geometry, + mock_ee.Image, + ), + ): + layer = FakeEeTileLayer(name=name or "layer", visible=shown, opacity=opacity) + self.ee_layers[name] = { + "ee_object": ee_object, + "ee_layer": layer, + "vis_params": vis_params, + } + self.layers.append(layer) + + def remove_layer(self, layer) -> None: + if isinstance(layer, str): + layer = self.ee_layers[layer]["ee_layer"] + self.layers.remove(layer) + del self.ee_layers[layer.name] + + def get_layer_names(self) -> list[str]: + return [layer.name for layer in self.layers] + + def zoom_to_bounds(self, _) -> None: + pass + + def substitute(self, old_layer: str, new_layer) -> None: + i = self.find_layer_index(old_layer) + if i >= 0: + self.layers[i] = new_layer + + def add_basemap(self, basemap: str = "HYBRID", show: bool = True, **kwargs) -> None: + self.add_layer(FakeTileLayer(name=basemap, visible=show)) + + def _add_legend( + self, + title: str | None = None, + keys: list | None = None, + colors: list | None = None, + position: str | None = None, + builtin_legend: str | None = None, + layer_name: str | None = None, + add_header: bool | None = None, + widget_args: dict | None = None, + **kwargs, + ) -> None: + del ( + title, + keys, + colors, + position, + builtin_legend, + add_header, + widget_args, + kwargs, + ) + if layer := self.ee_layers.get(layer_name): + layer["legend"] = {} + + def _add_colorbar( + self, + vis_params: dict | None = None, + cmap: str | None = None, + discrete: bool | None = None, + label: str | None = None, + orientation: str | None = None, + position: str | None = None, + transparent_bg: bool | None = None, + layer_name: str | None = None, + font_size: int | None = None, + axis_off: bool | None = None, + max_width: str | None = None, + **kwargs, + ) -> None: + del ( + vis_params, + cmap, + discrete, + label, + orientation, + position, + transparent_bg, + font_size, + axis_off, + max_width, + kwargs, + ) + if layer := self.ee_layers.get(layer_name): + layer["colorbar"] = {} + + @property + def cursor_style(self) -> str | None: + return self.default_style.get("cursor") + + def set_center(self, lon: float, lat: float, zoom: int | None = None) -> None: + self.center = [lat, lon] + if zoom is not None: + self.zoom = zoom + + +class FakeEeTileLayer: + + def __init__( + self, + name: str = "test-layer", + visible: bool = True, + opacity: float = 1.0, + ) -> None: + self.name = name + self.visible = visible + self.opacity = opacity + + def observe(self, func, names) -> None: + pass + + def calculate_vis_minmax( + self, + *, + bounds, + bands: list[str] | None = None, + percent: float | None = None, + sigma: float | None = None, + ) -> tuple[float, float]: + return (21, 42) + + +class FakeTileLayer(ipywidgets.Widget): + + name = traitlets.Unicode("").tag(sync=True) + visible = traitlets.Bool(True).tag(sync=True) + opacity = traitlets.Float(1).tag(sync=True) + loading = traitlets.Bool(False).tag(sync=True) + + def __init__( + self, + name: str = "test-layer", + visible: bool = True, + opacity: float = 1.0, + ) -> None: + super().__init__() + self.name = name + self.visible = visible + self.opacity = opacity + + +class FakeGeoJSONLayer: + + def __init__( + self, + name: str = "test-layer", + visible: bool = True, + style: dict | None = None, + ) -> None: + self.name = name + self.visible = visible + self.style = style or {} + + def observe(self, func, names) -> None: + pass diff --git a/tests/mocks/mock_osmnx.py b/tests/mocks/mock_osmnx.py new file mode 100644 index 0000000000..e8dbefa72e --- /dev/null +++ b/tests/mocks/mock_osmnx.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from shapely.geometry import Point, Polygon + + +class MockGeoDataFrame: + + def __init__( + self, + data: dict | None = None, + geometry: list | None = None, + crs: str = "EPSG:4326", + ) -> None: + self.data = data or {} + self.geometry = geometry or [] + self.crs = crs + self.empty = len(self.geometry) == 0 + self.__geo_interface__ = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"name": "Test"}, + } + ], + } + + def to_file(self, filepath: str, driver: str | None = None) -> None: + pass + + def to_json(self) -> str: + return '{"type": "FeatureCollection", "features": []}' + + def __len__(self) -> int: + return len(self.geometry) + + +def mock_features_from_address( + address: str, + tags: dict, + dist: int = 1000, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[Point(0, 0)], + data={"name": ["Test Feature"]}, + ) + + +def mock_features_from_point( + center_point: tuple[float, float], + tags: dict, + dist: int = 1000, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[Point(center_point[0], center_point[1])], + data={"name": ["Test Point Feature"]}, + ) + + +def mock_features_from_bbox( + bbox: tuple[float, float, float, float], + tags: dict, +) -> MockGeoDataFrame: + west, south, east, north = bbox + return MockGeoDataFrame( + geometry=[Polygon([(west, south), (east, south), (east, north), (west, north)])], + data={"name": ["Test BBox Feature"]}, + ) + + +def mock_features_from_polygon( + polygon, + tags: dict, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[polygon], + data={"name": ["Test Polygon Feature"]}, + ) + + +def mock_features_from_place( + query: str | dict | list, + tags: dict, + which_result: int | None = None, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[Point(0, 0)], + data={"name": ["Test Place Feature"]}, + ) + + +def mock_features_from_xml( + filepath: str, + polygon=None, + tags: dict | None = None, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[Point(0, 0)], + data={"name": ["Test XML Feature"]}, + ) + + +def mock_geocode_to_gdf( + query: str | dict | list, + which_result: int | None = None, + by_osmid: bool = False, +) -> MockGeoDataFrame: + return MockGeoDataFrame( + geometry=[Point(0, 0)], + data={"name": ["Test Geocode Feature"]}, + ) diff --git a/tests/mocks/mock_plotly.py b/tests/mocks/mock_plotly.py new file mode 100644 index 0000000000..9999ef4af7 --- /dev/null +++ b/tests/mocks/mock_plotly.py @@ -0,0 +1,59 @@ +from __future__ import annotations + + +class MockFigure: + + def __init__( + self, + data: list | None = None, + layout: dict | None = None, + ) -> None: + self.data = data or [] + self.layout = layout or {} + + def update_layout(self, **kwargs) -> "MockFigure": + self.layout.update(kwargs) + return self + + def show(self) -> None: + pass + + def to_json(self) -> str: + return '{"data": [], "layout": {}}' + + def write_html(self, file: str, **kwargs) -> None: + pass + + def write_image(self, file: str, **kwargs) -> None: + pass + + +class MockPlotlyExpress: + + @staticmethod + def bar(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "bar"}]) + + @staticmethod + def line(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "line"}]) + + @staticmethod + def histogram(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "histogram"}]) + + @staticmethod + def pie(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "pie"}]) + + @staticmethod + def scatter(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "scatter"}]) + + @staticmethod + def scatter_mapbox(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "scattermapbox"}]) + + @staticmethod + def choropleth_mapbox(*args, **kwargs) -> MockFigure: + return MockFigure(data=[{"type": "choroplethmapbox"}]) diff --git a/tests/mocks/mock_requests.py b/tests/mocks/mock_requests.py new file mode 100644 index 0000000000..598404fcb1 --- /dev/null +++ b/tests/mocks/mock_requests.py @@ -0,0 +1,42 @@ +from __future__ import annotations + + +class RequestError(Exception): + pass + + +class MockResponse: + + def __init__( + self, + json_data: dict | None = None, + status_code: int = 200, + text: str = "", + content: bytes = b"", + ) -> None: + self._json_data = json_data + self.status_code = status_code + self.text = text + self.content = content + self.ok = status_code < 400 + + def json(self) -> dict | None: + return self._json_data + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RequestError(f"HTTP {self.status_code}") + + +def create_mock_response( + json_data: dict | None = None, + status_code: int = 200, + text: str = "", + content: bytes = b"", +) -> MockResponse: + return MockResponse( + json_data=json_data, + status_code=status_code, + text=text, + content=content, + ) diff --git a/tests/test_ai.py b/tests/test_ai.py new file mode 100644 index 0000000000..47f0839636 --- /dev/null +++ b/tests/test_ai.py @@ -0,0 +1,617 @@ +"""Tests for the ai module.""" + +from __future__ import annotations + +import datetime +import sys +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +if TYPE_CHECKING: + from geemap.ai import Collection as AICollection + +MOCK_MODULES = [ + "vertexai", + "vertexai.preview", + "vertexai.preview.language_models", + "google.generativeai", + "google.ai", + "google.ai.generativelanguage", + "google.cloud.storage", + "langchain", + "langchain.embeddings", + "langchain.embeddings.base", + "langchain.indexes", + "langchain.indexes.vectorstore", + "langchain.schema", + "langchain_google_genai", + "langchain_core", + "langchain_core.vectorstores", + "langchain_core.vectorstores.base", + "langchain_core.language_models", + "langchain_core.language_models.base", + "google.colab", + "google.colab.output", + "google.colab.data_table", + "google.colab.syntax", +] + +for mod_name in MOCK_MODULES: + if mod_name not in sys.modules: + sys.modules[mod_name] = mock.MagicMock() + +mock_storage = mock.MagicMock() +mock_storage.Client = mock.MagicMock() +mock_storage.Blob = mock.MagicMock() +sys.modules["google.cloud.storage"] = mock_storage + +mock_embeddings_base = mock.MagicMock() +mock_embeddings_base.Embeddings = object +sys.modules["langchain.embeddings.base"] = mock_embeddings_base + +from geemap import ai + + +class TestMatchesInterval(unittest.TestCase): + + def test_matches_interval_overlapping(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 12, 31, tzinfo=datetime.UTC), + ) + query_interval = ( + datetime.datetime(2021, 6, 1, tzinfo=datetime.UTC), + datetime.datetime(2023, 6, 1, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) + self.assertTrue(result) + + def test_matches_interval_query_within_collection(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_interval = ( + datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) + self.assertTrue(result) + + def test_matches_interval_collection_within_query(self) -> None: + collection_interval = ( + datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC), + ) + query_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) + self.assertTrue(result) + + def test_matches_interval_no_overlap_before(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2020, 12, 31, tzinfo=datetime.UTC), + ) + query_interval = ( + datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) + self.assertFalse(result) + + def test_matches_interval_no_overlap_after(self) -> None: + collection_interval = ( + datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2024, 12, 31, tzinfo=datetime.UTC), + ) + query_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) + self.assertFalse(result) + + def test_matches_interval_none_end_date(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + None, + ) + query_interval = ( + datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC), + ) + result = ai.matches_interval(collection_interval, query_interval) # type: ignore[arg-type] + self.assertTrue(result) + + +class TestMatchesDatetime(unittest.TestCase): + + def test_matches_datetime_within_interval(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_datetime = datetime.datetime(2022, 6, 15, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertTrue(result) + + def test_matches_datetime_at_start(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_datetime = datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertTrue(result) + + def test_matches_datetime_at_end(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_datetime = datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertTrue(result) + + def test_matches_datetime_before_interval(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_datetime = datetime.datetime(2019, 6, 15, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertFalse(result) + + def test_matches_datetime_after_interval(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2025, 12, 31, tzinfo=datetime.UTC), + ) + query_datetime = datetime.datetime(2026, 6, 15, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertFalse(result) + + def test_matches_datetime_none_end_date(self) -> None: + collection_interval = ( + datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC), + None, + ) + query_datetime = datetime.datetime(2022, 6, 15, tzinfo=datetime.UTC) + result = ai.matches_datetime(collection_interval, query_datetime) + self.assertTrue(result) + + +class TestBBox(unittest.TestCase): + + def test_bbox_init(self) -> None: + bbox = ai.BBox(west=-10.0, south=-20.0, east=30.0, north=40.0) + self.assertEqual(bbox.west, -10.0) + self.assertEqual(bbox.south, -20.0) + self.assertEqual(bbox.east, 30.0) + self.assertEqual(bbox.north, 40.0) + + def test_bbox_is_global_true(self) -> None: + bbox = ai.BBox(west=-180, south=-90, east=180, north=90) + self.assertTrue(bbox.is_global()) + + def test_bbox_is_global_false(self) -> None: + bbox = ai.BBox(west=-10.0, south=-20.0, east=30.0, north=40.0) + self.assertFalse(bbox.is_global()) + + def test_bbox_from_list_valid(self) -> None: + bbox = ai.BBox.from_list([-10.0, -20.0, 30.0, 40.0]) + self.assertEqual(bbox.west, -10.0) + self.assertEqual(bbox.south, -20.0) + self.assertEqual(bbox.east, 30.0) + self.assertEqual(bbox.north, 40.0) + + def test_bbox_from_list_inverted_lon_raises(self) -> None: + with self.assertRaises(ValueError) as ctx: + ai.BBox.from_list([30.0, -20.0, -10.0, 40.0]) + self.assertIn("west", str(ctx.exception).lower()) + + def test_bbox_from_list_inverted_lat_raises(self) -> None: + with self.assertRaises(ValueError) as ctx: + ai.BBox.from_list([-10.0, 40.0, 30.0, -20.0]) + self.assertIn("south", str(ctx.exception).lower()) + + def test_bbox_to_list(self) -> None: + bbox = ai.BBox(west=-10.0, south=-20.0, east=30.0, north=40.0) + result = bbox.to_list() + self.assertEqual(result, [-10.0, -20.0, 30.0, 40.0]) + + def test_bbox_intersects_overlapping(self) -> None: + bbox1 = ai.BBox(west=0, south=0, east=20, north=20) + bbox2 = ai.BBox(west=10, south=10, east=30, north=30) + self.assertTrue(bbox1.intersects(bbox2)) + + def test_bbox_intersects_contained(self) -> None: + bbox1 = ai.BBox(west=0, south=0, east=100, north=100) + bbox2 = ai.BBox(west=10, south=10, east=30, north=30) + self.assertTrue(bbox1.intersects(bbox2)) + + def test_bbox_intersects_no_overlap_horizontal(self) -> None: + bbox1 = ai.BBox(west=0, south=0, east=10, north=10) + bbox2 = ai.BBox(west=20, south=0, east=30, north=10) + self.assertFalse(bbox1.intersects(bbox2)) + + def test_bbox_intersects_no_overlap_vertical(self) -> None: + bbox1 = ai.BBox(west=0, south=0, east=10, north=10) + bbox2 = ai.BBox(west=0, south=20, east=10, north=30) + self.assertFalse(bbox1.intersects(bbox2)) + + def test_bbox_intersects_touching_edge(self) -> None: + bbox1 = ai.BBox(west=0, south=0, east=10, north=10) + bbox2 = ai.BBox(west=10, south=0, east=20, north=10) + self.assertFalse(bbox1.intersects(bbox2)) + + +class TestCollection(unittest.TestCase): + + def setUp(self) -> None: + self.sample_stac = { + "id": "LANDSAT/LC08/C02/T1", + "title": "Landsat 8 Collection 2 Tier 1", + "gee:type": "image_collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2013-04-11T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "summaries": { + "eo:bands": [ + {"name": "B1", "description": "Coastal"}, + {"name": "B2", "description": "Blue"}, + ], + "gsd": [30], + }, + "gee:interval": {"interval": 16, "unit": "day"}, + "links": [ + { + "rel": "preview", + "href": "https://example.com/preview.png", + "type": "image/png", + }, + { + "rel": "catalog", + "href": "https://developers.google.com/earth-engine/datasets/catalog/LANDSAT_LC08_C02_T1", + }, + ], + "code": { + "js_code": "var image = ee.Image();", + "py_code": "image = ee.Image()", + }, + } + + def test_collection_getitem(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection["id"], "LANDSAT/LC08/C02/T1") + + def test_collection_get_existing(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.get("title"), "Landsat 8 Collection 2 Tier 1") + + def test_collection_get_missing_returns_default(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertIsNone(collection.get("nonexistent")) + self.assertEqual(collection.get("nonexistent", "default"), "default") + + def test_collection_public_id(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.public_id(), "LANDSAT/LC08/C02/T1") + + def test_collection_hyphen_id(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.hyphen_id(), "LANDSAT_LC08_C02_T1") + + def test_collection_get_dataset_type(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.get_dataset_type(), "image_collection") + + def test_collection_is_deprecated_false(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertFalse(collection.is_deprecated()) + + def test_collection_is_deprecated_true(self) -> None: + stac = self.sample_stac.copy() + stac["gee:status"] = "deprecated" + collection = ai.Collection(stac) + self.assertTrue(collection.is_deprecated()) + + def test_collection_datetime_interval(self) -> None: + collection = ai.Collection(self.sample_stac) + intervals = list(collection.datetime_interval()) + self.assertEqual(len(intervals), 1) + start, end = intervals[0] + self.assertEqual(start.year, 2013) + self.assertEqual(end.year, 2024) # type: ignore[union-attr] + + def test_collection_datetime_interval_none_end(self) -> None: + stac = self.sample_stac.copy() + stac["extent"]["temporal"]["interval"] = [["2013-04-11T00:00:00Z", None]] # type: ignore[index] + collection = ai.Collection(stac) + intervals = list(collection.datetime_interval()) + start, end = intervals[0] + self.assertEqual(start.year, 2013) # type: ignore[union-attr] + self.assertIsNone(end) + + def test_collection_start(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.start().year, 2013) + + def test_collection_start_str(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.start_str(), "2013-04-11") + + def test_collection_end(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.end().year, 2024) # type: ignore[union-attr] + + def test_collection_end_str(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.end_str(), "2024-01-01") + + def test_collection_end_str_none(self) -> None: + stac = self.sample_stac.copy() + stac["extent"]["temporal"]["interval"] = [["2013-04-11T00:00:00Z", None]] # type: ignore[index] + collection = ai.Collection(stac) + self.assertEqual(collection.end_str(), "") + + def test_collection_bbox_list(self) -> None: + collection = ai.Collection(self.sample_stac) + bboxes = collection.bbox_list() + self.assertEqual(len(bboxes), 1) + self.assertTrue(bboxes[0].is_global()) + + def test_collection_bbox_list_no_extent(self) -> None: + stac = self.sample_stac.copy() + del stac["extent"] + collection = ai.Collection(stac) + bboxes = collection.bbox_list() + self.assertEqual(len(bboxes), 1) + self.assertTrue(bboxes[0].is_global()) + + def test_collection_bands(self) -> None: + collection = ai.Collection(self.sample_stac) + bands = collection.bands() + self.assertEqual(len(bands), 2) + self.assertEqual(bands[0]["name"], "B1") + + def test_collection_bands_no_summaries(self) -> None: + stac = self.sample_stac.copy() + del stac["summaries"] # type: ignore[attr-defined] + collection = ai.Collection(stac) + self.assertEqual(collection.bands(), []) + + def test_collection_spatial_resolution_m(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.spatial_resolution_m(), 30) + + def test_collection_spatial_resolution_m_no_gsd(self) -> None: + stac = self.sample_stac.copy() + del stac["summaries"]["gsd"] # type: ignore[attr-defined] + collection = ai.Collection(stac) + self.assertEqual(collection.spatial_resolution_m(), -1) + + def test_collection_temporal_resolution_str(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.temporal_resolution_str(), "16 day") + + def test_collection_temporal_resolution_str_no_interval(self) -> None: + stac = self.sample_stac.copy() + del stac["gee:interval"] + collection = ai.Collection(stac) + self.assertEqual(collection.temporal_resolution_str(), "") + + def test_collection_python_code(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual(collection.python_code(), "image = ee.Image()") + + def test_collection_python_code_missing(self) -> None: + stac = self.sample_stac.copy() + del stac["code"] + collection = ai.Collection(stac) + self.assertEqual(collection.python_code(), "") + + def test_collection_set_python_code(self) -> None: + collection = ai.Collection(self.sample_stac) + collection.set_python_code("new_code = ee.Image('NEW')") + self.assertEqual(collection.python_code(), "new_code = ee.Image('NEW')") + + def test_collection_image_preview_url(self) -> None: + collection = ai.Collection(self.sample_stac) + self.assertEqual( + collection.image_preview_url(), "https://example.com/preview.png" + ) + + def test_collection_image_preview_url_not_found_raises(self) -> None: + stac = self.sample_stac.copy() + stac["links"] = [] + collection = ai.Collection(stac) + with self.assertRaises(ValueError): + collection.image_preview_url() + + def test_collection_catalog_url(self) -> None: + collection = ai.Collection(self.sample_stac) + url = collection.catalog_url() + self.assertIn("developers.google.com", url) + + +class TestCollectionList(unittest.TestCase): + + def setUp(self) -> None: + self.stac1 = { + "id": "LANDSAT/LC08/C02/T1", + "title": "Landsat 8", + "gee:type": "image_collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2013-04-11T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "summaries": {"gsd": [30]}, + "links": [], + } + self.stac2 = { + "id": "COPERNICUS/S2", + "title": "Sentinel-2", + "gee:type": "image_collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2015-06-27T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "summaries": {"gsd": [10]}, + "links": [], + } + self.stac3 = { + "id": "MODIS/006/MOD09GA", + "title": "MODIS", + "gee:type": "image_collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2000-02-24T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "summaries": {"gsd": [500]}, + "links": [], + } + self.col1 = ai.Collection(self.stac1) + self.col2 = ai.Collection(self.stac2) + self.col3 = ai.Collection(self.stac3) + self.collection_list = ai.CollectionList([self.col1, self.col2, self.col3]) + + def test_collection_list_len(self) -> None: + self.assertEqual(len(self.collection_list), 3) + + def test_collection_list_getitem(self) -> None: + self.assertEqual(self.collection_list[0].public_id(), "LANDSAT/LC08/C02/T1") + self.assertEqual(self.collection_list[1].public_id(), "COPERNICUS/S2") + + def test_collection_list_iter(self) -> None: + ids = [c.public_id() for c in self.collection_list] + self.assertEqual( + ids, ["LANDSAT/LC08/C02/T1", "COPERNICUS/S2", "MODIS/006/MOD09GA"] + ) + + def test_collection_list_eq(self) -> None: + other = ai.CollectionList([self.col1, self.col2, self.col3]) + self.assertEqual(self.collection_list, other) + + def test_collection_list_filter_by_ids(self) -> None: + filtered = self.collection_list.filter_by_ids(["COPERNICUS/S2"]) + self.assertEqual(len(filtered), 1) + self.assertEqual(filtered[0].public_id(), "COPERNICUS/S2") + + def test_collection_list_filter_by_ids_multiple(self) -> None: + filtered = self.collection_list.filter_by_ids( + ["COPERNICUS/S2", "MODIS/006/MOD09GA"] + ) + self.assertEqual(len(filtered), 2) + + def test_collection_list_filter_by_ids_none_match(self) -> None: + filtered = self.collection_list.filter_by_ids(["NONEXISTENT"]) + self.assertEqual(len(filtered), 0) + + def test_collection_list_filter_by_datetime(self) -> None: + query_dt = datetime.datetime(2010, 1, 1, tzinfo=datetime.UTC) + filtered = self.collection_list.filter_by_datetime(query_dt) + self.assertEqual(len(filtered), 1) + self.assertEqual(filtered[0].public_id(), "MODIS/006/MOD09GA") + + def test_collection_list_filter_by_datetime_all_match(self) -> None: + query_dt = datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC) + filtered = self.collection_list.filter_by_datetime(query_dt) + self.assertEqual(len(filtered), 3) + + def test_collection_list_filter_by_interval(self) -> None: + query_interval = ( + datetime.datetime(2010, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2012, 1, 1, tzinfo=datetime.UTC), + ) + filtered = self.collection_list.filter_by_interval(query_interval) + self.assertEqual(len(filtered), 1) + self.assertEqual(filtered[0].public_id(), "MODIS/006/MOD09GA") + + def test_collection_list_filter_by_bounding_box(self) -> None: + query_bbox = ai.BBox(west=-10, south=-10, east=10, north=10) + filtered = self.collection_list.filter_by_bounding_box(query_bbox) + self.assertEqual(len(filtered), 3) + + def test_collection_list_filter_by_bounding_box_regional(self) -> None: + stac_regional = { + "id": "REGIONAL/DATA", + "title": "Regional", + "gee:type": "image", + "extent": { + "spatial": {"bbox": [[100, 10, 110, 20]]}, + "temporal": { + "interval": [["2020-01-01T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "links": [], + } + col_regional = ai.Collection(stac_regional) + col_list = ai.CollectionList([self.col1, col_regional]) + query_bbox = ai.BBox(west=-10, south=-10, east=10, north=10) + filtered = col_list.filter_by_bounding_box(query_bbox) + self.assertEqual(len(filtered), 1) + self.assertEqual(filtered[0].public_id(), "LANDSAT/LC08/C02/T1") + + def test_collection_list_sort_by_spatial_resolution(self) -> None: + sorted_list = self.collection_list.sort_by_spatial_resolution() + resolutions = [c.spatial_resolution_m() for c in sorted_list] + self.assertEqual(resolutions, [10, 30, 500]) + + def test_collection_list_sort_by_spatial_resolution_reverse(self) -> None: + sorted_list = self.collection_list.sort_by_spatial_resolution(reverse=True) + resolutions = [c.spatial_resolution_m() for c in sorted_list] + self.assertEqual(resolutions, [500, 30, 10]) + + def test_collection_list_sort_by_spatial_resolution_unknown_at_end(self) -> None: + stac_no_gsd = { + "id": "NO_GSD/DATA", + "title": "No GSD", + "gee:type": "image", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2020-01-01T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + "links": [], + } + col_no_gsd = ai.Collection(stac_no_gsd) + col_list = ai.CollectionList([col_no_gsd, self.col1, self.col2]) + sorted_list = col_list.sort_by_spatial_resolution() + self.assertEqual(sorted_list[2].public_id(), "NO_GSD/DATA") + + def test_collection_list_limit(self) -> None: + limited = self.collection_list.limit(2) + self.assertEqual(len(limited), 2) + self.assertEqual(limited[0].public_id(), "LANDSAT/LC08/C02/T1") + self.assertEqual(limited[1].public_id(), "COPERNICUS/S2") + + def test_collection_list_limit_larger_than_list(self) -> None: + limited = self.collection_list.limit(10) + self.assertEqual(len(limited), 3) + + def test_collection_list_to_df(self) -> None: + df = self.collection_list.to_df() + self.assertEqual(len(df), 3) + self.assertIn("id", df.columns) + self.assertIn("name", df.columns) + self.assertIn("spatial_res_m", df.columns) + self.assertEqual(df.iloc[0]["id"], "LANDSAT/LC08/C02/T1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000000..7b6da47b6c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,40 @@ +"""Tests for the cli module.""" + +from __future__ import annotations + +import unittest + +from click.testing import CliRunner + +from geemap.cli import main + + +class TestMain(unittest.TestCase): + + def setUp(self) -> None: + self.runner = CliRunner() + + def test_main_no_args_returns_zero(self) -> None: + result = self.runner.invoke(main) + self.assertEqual(result.exit_code, 0) + + def test_main_no_args_outputs_message(self) -> None: + result = self.runner.invoke(main) + self.assertIn("geemap.cli.main", result.output) + + def test_main_no_args_outputs_click_docs_reference(self) -> None: + result = self.runner.invoke(main) + self.assertIn("click.palletsprojects.com", result.output) + + def test_main_help_flag_shows_help(self) -> None: + result = self.runner.invoke(main, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Console script for geemap", result.output) + + def test_main_help_flag_shows_usage(self) -> None: + result = self.runner.invoke(main, ["--help"]) + self.assertIn("Usage:", result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deck.py b/tests/test_deck.py new file mode 100644 index 0000000000..c340d6e9e9 --- /dev/null +++ b/tests/test_deck.py @@ -0,0 +1,214 @@ +"""Tests for the deck module.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest import mock + +try: + import pydeck as pdk + PYDECK_AVAILABLE = True +except ImportError: + PYDECK_AVAILABLE = False + +DECK_MODULE = None +IMPORT_ERROR = None + + +def get_deck(): + global DECK_MODULE, IMPORT_ERROR + if DECK_MODULE is not None: + return DECK_MODULE + if IMPORT_ERROR is not None: + raise IMPORT_ERROR + try: + with mock.patch("geemap.coreutils.ee_initialize"): + from geemap import deck + DECK_MODULE = deck + return deck + except Exception as e: + IMPORT_ERROR = e + raise + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class DeckTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.mkdtemp() + try: + cls.deck = get_deck() + cls.skip_tests = False + except Exception as e: + cls.skip_tests = True + cls.skip_reason = str(e) + + @classmethod + def tearDownClass(cls) -> None: + import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def setUp(self) -> None: + if self.skip_tests: + self.skipTest(f"deck import failed: {self.skip_reason}") + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestLayerInit(DeckTestCase): + + def test_layer_init_default(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + layer = self.deck.Layer("ScatterplotLayer") + self.assertIsInstance(layer, pdk.Layer) + + def test_layer_init_with_data(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + layer = self.deck.Layer( + "ScatterplotLayer", + data=[{"position": [0, 0]}], + id="test_layer" + ) + self.assertIsInstance(layer, pdk.Layer) + + def test_layer_init_hexagon(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + layer = self.deck.Layer("HexagonLayer") + self.assertIsInstance(layer, pdk.Layer) + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestMapInit(DeckTestCase): + + def test_map_init_default_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + self.assertIsInstance(m, pdk.Deck) + + def test_map_init_custom_center(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(center=(40, -100), ee_initialize=False) + self.assertIsInstance(m, pdk.Deck) + + def test_map_init_custom_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(zoom=10, ee_initialize=False) + self.assertIsInstance(m, pdk.Deck) + + def test_map_init_custom_height_width(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(height=800, width=1000, ee_initialize=False) + self.assertIsInstance(m, pdk.Deck) + + def test_map_init_custom_map_style(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(map_style="dark", ee_initialize=False) + self.assertIsInstance(m, pdk.Deck) + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddLayer(DeckTestCase): + + def test_add_layer_pydeck_layer(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + layer = pdk.Layer("ScatterplotLayer", data=[{"position": [0, 0]}]) + m.add_layer(layer) + self.assertEqual(len(m.layers), 1) + + def test_add_layer_with_name(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + layer = pdk.Layer("ScatterplotLayer", data=[{"position": [0, 0]}]) + m.add_layer(layer, layer_name="test_layer") + self.assertEqual(len(m.layers), 1) + + def test_add_layer_url(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + url = "https://example.com/tiles/{z}/{x}/{y}.png" + m.add_layer(url, layer_name="tile_layer") + self.assertEqual(len(m.layers), 1) + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddEeLayer(DeckTestCase): + + def test_add_ee_layer_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(AttributeError) as ctx: + m.add_ee_layer("not_an_ee_object") + self.assertIn("instance of", str(ctx.exception)) + + def test_addlayer_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + self.assertEqual(m.addLayer, m.add_ee_layer) + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddBasemap(DeckTestCase): + + def test_add_basemap_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + # Test that add_basemap doesn't raise for valid basemaps + # Note: Invalid basemaps print a message but don't raise + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddGdf(DeckTestCase): + + def test_add_gdf_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_gdf("not_a_gdf") + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddVector(DeckTestCase): + + def test_add_vector_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_vector("/nonexistent/path/file.shp") + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddGeojson(DeckTestCase): + + def test_add_geojson_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_geojson("/nonexistent/path/file.geojson") + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddShp(DeckTestCase): + + def test_add_shp_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_shp("/nonexistent/path/file.shp") + + +@unittest.skipUnless(PYDECK_AVAILABLE, "pydeck not available") +class TestAddKml(DeckTestCase): + + def test_add_kml_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.deck.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_kml("/nonexistent/path/file.kml") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_foliumap.py b/tests/test_foliumap.py new file mode 100644 index 0000000000..97159ff2d3 --- /dev/null +++ b/tests/test_foliumap.py @@ -0,0 +1,350 @@ +"""Tests for the foliumap module.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest import mock + +import pandas as pd + +try: + import folium + FOLIUM_AVAILABLE = True +except ImportError: + FOLIUM_AVAILABLE = False + +FOLIUMAP_MODULE = None +IMPORT_ERROR = None + + +def get_foliumap(): + global FOLIUMAP_MODULE, IMPORT_ERROR + if FOLIUMAP_MODULE is not None: + return FOLIUMAP_MODULE + if IMPORT_ERROR is not None: + raise IMPORT_ERROR + try: + with mock.patch("geemap.coreutils.ee_initialize"): + from geemap import foliumap + FOLIUMAP_MODULE = foliumap + return foliumap + except Exception as e: + IMPORT_ERROR = e + raise + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class FoliumapTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.mkdtemp() + try: + cls.foliumap = get_foliumap() + cls.skip_tests = False + except Exception as e: + cls.skip_tests = True + cls.skip_reason = str(e) + + @classmethod + def tearDownClass(cls) -> None: + import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def setUp(self) -> None: + if self.skip_tests: + self.skipTest(f"foliumap import failed: {self.skip_reason}") + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestMapInit(FoliumapTestCase): + + def test_map_init_default_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + self.assertEqual(m.baseclass, "folium") + self.assertEqual(m.draw_features, []) + self.assertIsNone(m.draw_last_feature) + + def test_map_init_custom_center(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(center=[40, -100], ee_initialize=False) + self.assertEqual(m.baseclass, "folium") + + def test_map_init_custom_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(zoom=10, ee_initialize=False) + self.assertEqual(m.baseclass, "folium") + + def test_map_init_plugins_disabled(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map( + plugin_Fullscreen=False, + plugin_Draw=False, + search_control=False, + ee_initialize=False, + ) + self.assertEqual(m.baseclass, "folium") + + def test_map_init_width_height(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(width=800, height=600, ee_initialize=False) + self.assertEqual(m.baseclass, "folium") + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestSetCenter(FoliumapTestCase): + + def test_set_center_valid_coords(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + with mock.patch("geemap.common.is_arcpy", return_value=False): + m = self.foliumap.Map(ee_initialize=False) + m.set_center(-122.4, 37.8, zoom=12) + + def test_setcenter_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + with mock.patch("geemap.common.is_arcpy", return_value=False): + m = self.foliumap.Map(ee_initialize=False) + self.assertEqual(m.setCenter, m.set_center) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestZoomToBounds(FoliumapTestCase): + + def test_zoom_to_bounds_valid_bounds(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.zoom_to_bounds([-122.5, 37.5, -122.0, 38.0]) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddTileLayer(FoliumapTestCase): + + def test_add_tile_layer_valid_url(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.add_tile_layer( + tiles="https://tile.example.com/{z}/{x}/{y}.png", + name="Test Layer", + attribution="Test", + ) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddWmsLayer(FoliumapTestCase): + + def test_add_wms_layer_valid_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.add_wms_layer( + url="https://wms.example.com/service", + layers="layer1", + name="WMS Layer", + ) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddMarker(FoliumapTestCase): + + def test_add_marker_list_location(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.add_marker(location=[37.8, -122.4], popup="Test Popup") + + def test_add_marker_tuple_location(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.add_marker(location=(37.8, -122.4), tooltip="Test Tooltip") + + def test_add_marker_invalid_location_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(TypeError) as ctx: + m.add_marker(location="invalid") + self.assertIn("list or a tuple", str(ctx.exception)) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddGeojson(FoliumapTestCase): + + def test_add_geojson_dict_input(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.4, 37.8]}, + "properties": {"name": "Test"}, + } + ], + } + m = self.foliumap.Map(ee_initialize=False) + m.add_geojson(geojson_data, layer_name="Test GeoJSON") + + def test_add_geojson_from_file(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"id": 1}, + } + ], + } + temp_path = os.path.join(self.temp_dir, "test.geojson") + with open(temp_path, "w") as f: + json.dump(geojson_data, f) + + m = self.foliumap.Map(ee_initialize=False) + m.add_geojson(temp_path, layer_name="Test File") + + def test_add_geojson_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_geojson("/nonexistent/path/file.geojson") + + def test_add_geojson_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_geojson(12345) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddShapefile(FoliumapTestCase): + + def test_add_shapefile_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(FileNotFoundError): + m.add_shapefile("/nonexistent/path/file.shp") + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddHeatmap(FoliumapTestCase): + + def test_add_heatmap_list_data(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + data = [[37.8, -122.4, 1.0], [37.7, -122.3, 0.5]] + m.add_heatmap(data=data, name="Test Heatmap") + + def test_add_heatmap_dataframe(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + df = pd.DataFrame( + {"latitude": [37.8, 37.7], "longitude": [-122.4, -122.3], "value": [1.0, 0.5]} + ) + m.add_heatmap(data=df, name="Test Heatmap") + + def test_add_heatmap_invalid_data_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(ValueError): + m.add_heatmap(data={"invalid": "data"}) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddColorbar(FoliumapTestCase): + + def test_add_colorbar_not_dict_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(ValueError) as ctx: + m.add_colorbar("not_a_dict") + self.assertIn("dictionary", str(ctx.exception)) + + def test_add_colorbar_no_palette_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(ValueError) as ctx: + m.add_colorbar({"min": 0, "max": 100}) + self.assertIn("palette", str(ctx.exception)) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddLayerControl(FoliumapTestCase): + + def test_add_layer_control(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.add_layer_control() + + def test_addlayercontrol_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + self.assertEqual(m.addLayerControl, m.add_layer_control) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestToHtml(FoliumapTestCase): + + def test_to_html_returns_string(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + html = m.to_html() + self.assertIsInstance(html, str) + + def test_to_html_saves_file(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + temp_path = os.path.join(self.temp_dir, "test_map.html") + m.to_html(temp_path) + self.assertTrue(os.path.exists(temp_path)) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestSetControlVisibility(FoliumapTestCase): + + def test_set_control_visibility_all_true(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + m.set_control_visibility( + layerControl=True, fullscreenControl=True, latLngPopup=True + ) + + def test_setcontrolvisibility_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + self.assertEqual(m.setControlVisibility, m.set_control_visibility) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddCogMosaic(FoliumapTestCase): + + def test_add_cog_mosaic_raises_not_implemented(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(NotImplementedError): + m.add_cog_mosaic() + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestAddRemoteTile(FoliumapTestCase): + + def test_add_remote_tile_non_url_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + with self.assertRaises(Exception) as ctx: + m.add_remote_tile("/local/path/file.tif") + self.assertIn("URL", str(ctx.exception)) + + +@unittest.skipUnless(FOLIUM_AVAILABLE, "folium not available") +class TestSetOptions(FoliumapTestCase): + + def test_setoptions_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.foliumap.Map(ee_initialize=False) + self.assertEqual(m.set_options, m.setOptions) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py new file mode 100644 index 0000000000..6eea510221 --- /dev/null +++ b/tests/test_infrastructure.py @@ -0,0 +1,228 @@ +"""Tests for the test infrastructure (mocks, helpers, conftest).""" + +from __future__ import annotations + +import os +import unittest + +from tests.conftest import GeemapTestCase +from tests.helpers import assertions, factories +from tests.mocks import (mock_ee, mock_map, mock_osmnx, mock_plotly, + mock_requests) + + +class TestMockEe(unittest.TestCase): + + def test_image_creation(self) -> None: + img = mock_ee.Image() + self.assertIsNotNone(img) + + def test_image_getinfo(self) -> None: + img = mock_ee.Image() + info = img.getInfo() + self.assertIn("type", info) + self.assertEqual(info["type"], "Image") + + def test_geometry_point(self) -> None: + point = mock_ee.Geometry.Point([0, 0]) + self.assertIsNotNone(point) + info = point.getInfo() + self.assertEqual(info["type"], "Point") + + def test_geometry_polygon(self) -> None: + poly = mock_ee.Geometry.Polygon() + self.assertIsNotNone(poly) + info = poly.getInfo() + self.assertEqual(info["type"], "Polygon") + + def test_feature_collection(self) -> None: + fc = mock_ee.FeatureCollection([]) + self.assertIsNotNone(fc) + self.assertEqual(fc.size().getInfo(), 0) + + def test_image_collection(self) -> None: + ic = mock_ee.ImageCollection([mock_ee.Image()]) + self.assertIsNotNone(ic) + info = ic.getInfo() + self.assertEqual(info["type"], "ImageCollection") + + def test_list(self) -> None: + ee_list = mock_ee.List(["a", "b", "c"]) + self.assertEqual(ee_list.getInfo(), ["a", "b", "c"]) + self.assertEqual(ee_list.size().getInfo(), 3) + + def test_dictionary(self) -> None: + ee_dict = mock_ee.Dictionary({"key": "value"}) + self.assertEqual(ee_dict.getInfo(), {"key": "value"}) + + def test_reducer(self) -> None: + reducer = mock_ee.Reducer.mean() + self.assertIsNotNone(reducer) + + def test_filter(self) -> None: + filt = mock_ee.Filter.eq("prop", "value") + self.assertIsNotNone(filt) + + +class TestMockMap(unittest.TestCase): + + def test_fake_map_creation(self) -> None: + fake_map = mock_map.FakeMap() + self.assertIsNotNone(fake_map) + + def test_fake_map_layers(self) -> None: + fake_map = mock_map.FakeMap() + self.assertEqual(len(fake_map.layers), 0) + + def test_fake_map_add_layer(self) -> None: + fake_map = mock_map.FakeMap() + layer = mock_map.FakeTileLayer(name="test") + fake_map.add_layer(layer, name="test") + self.assertEqual(len(fake_map.layers), 1) + + def test_fake_ee_tile_layer(self) -> None: + layer = mock_map.FakeEeTileLayer(name="test") + self.assertEqual(layer.name, "test") + self.assertTrue(layer.visible) + + def test_fake_geojson_layer(self) -> None: + layer = mock_map.FakeGeoJSONLayer(name="test") + self.assertEqual(layer.name, "test") + + +class TestMockRequests(unittest.TestCase): + + def test_mock_response_success(self) -> None: + response = mock_requests.MockResponse( + json_data={"key": "value"}, + status_code=200, + ) + self.assertTrue(response.ok) + self.assertEqual(response.json(), {"key": "value"}) + + def test_mock_response_error(self) -> None: + response = mock_requests.MockResponse(status_code=404) + self.assertFalse(response.ok) + with self.assertRaises(mock_requests.RequestError): + response.raise_for_status() + + def test_create_mock_response(self) -> None: + response = mock_requests.create_mock_response( + json_data={"data": [1, 2, 3]}, + status_code=200, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"], [1, 2, 3]) + + +class TestMockOsmnx(unittest.TestCase): + + def test_mock_geodataframe(self) -> None: + gdf = mock_osmnx.MockGeoDataFrame() + self.assertTrue(gdf.empty) + + def test_mock_features_from_address(self) -> None: + result = mock_osmnx.mock_features_from_address( + "Test Address", + tags={"building": True}, + ) + self.assertIsNotNone(result) + self.assertFalse(result.empty) + + def test_mock_features_from_bbox(self) -> None: + result = mock_osmnx.mock_features_from_bbox( + bbox=(-122, 37, -121, 38), + tags={"building": True}, + ) + self.assertIsNotNone(result) + + +class TestMockPlotly(unittest.TestCase): + + def test_mock_figure(self) -> None: + fig = mock_plotly.MockFigure() + self.assertIsNotNone(fig) + + def test_mock_figure_update_layout(self) -> None: + fig = mock_plotly.MockFigure() + fig.update_layout(title="Test") + self.assertEqual(fig.layout["title"], "Test") + + def test_mock_plotly_express_bar(self) -> None: + fig = mock_plotly.MockPlotlyExpress.bar() + self.assertEqual(fig.data[0]["type"], "bar") + + def test_mock_plotly_express_pie(self) -> None: + fig = mock_plotly.MockPlotlyExpress.pie() + self.assertEqual(fig.data[0]["type"], "pie") + + +class TestHelperFactories(unittest.TestCase): + + def test_create_ee_image(self) -> None: + img = factories.create_ee_image(bands=["B1", "B2"]) + self.assertEqual(img._bands, ["B1", "B2"]) + + def test_create_ee_feature_collection(self) -> None: + fc = factories.create_ee_feature_collection() + self.assertIsNotNone(fc) + + def test_create_ee_geometry(self) -> None: + geom = factories.create_ee_geometry("Point", [10, 20]) + self.assertIsNotNone(geom) + + def test_create_sample_dataframe(self) -> None: + df = factories.create_sample_dataframe(rows=10) + self.assertEqual(len(df), 10) + + def test_create_sample_geojson(self) -> None: + geojson = factories.create_sample_geojson(num_features=5) + self.assertEqual(len(geojson["features"]), 5) + + +class TestHelperAssertions(unittest.TestCase): + + def test_assert_valid_geojson_feature(self) -> None: + geojson = {"type": "Feature", "geometry": {}, "properties": {}} + assertions.assert_valid_geojson(self, geojson) + + def test_assert_valid_geojson_collection(self) -> None: + geojson = {"type": "FeatureCollection", "features": []} + assertions.assert_valid_geojson(self, geojson) + + def test_assert_valid_hex_color(self) -> None: + assertions.assert_valid_hex_color(self, "#FF0000") + assertions.assert_valid_hex_color(self, "FF0000") + assertions.assert_valid_hex_color(self, "#FFF") + + def test_assert_valid_rgb_color(self) -> None: + assertions.assert_valid_rgb_color(self, (255, 0, 0)) + assertions.assert_valid_rgb_color(self, (0, 128, 255)) + + def test_assert_valid_bbox(self) -> None: + assertions.assert_valid_bbox(self, (-122, 37, -121, 38)) + + def test_assert_dict_contains_keys(self) -> None: + d = {"a": 1, "b": 2, "c": 3} + assertions.assert_dict_contains_keys(self, d, ["a", "b"]) + + +class TestGeemapTestCase(GeemapTestCase): + + def test_temp_dir_exists(self) -> None: + self.assertTrue(os.path.exists(self.temp_dir)) + + def test_get_temp_path(self) -> None: + path = self.get_temp_path("test.txt") + self.assertTrue(path.endswith("test.txt")) + self.assertIn(self.temp_dir, path) + + def test_temp_file_creation(self) -> None: + path = self.get_temp_path("test_file.txt") + with open(path, "w") as f: + f.write("test content") + assertions.assert_file_created(self, path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_kepler.py b/tests/test_kepler.py new file mode 100644 index 0000000000..84d05d043f --- /dev/null +++ b/tests/test_kepler.py @@ -0,0 +1,335 @@ +"""Tests for the kepler module.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest import mock + +import pandas as pd + +try: + import keplergl + KEPLERGL_AVAILABLE = True +except ImportError: + KEPLERGL_AVAILABLE = False + +KEPLER_MODULE = None +IMPORT_ERROR = None + + +def get_kepler(): + global KEPLER_MODULE, IMPORT_ERROR + if KEPLER_MODULE is not None: + return KEPLER_MODULE + if IMPORT_ERROR is not None: + raise IMPORT_ERROR + try: + with mock.patch("geemap.coreutils.ee_initialize"): + from geemap import kepler + KEPLER_MODULE = kepler + return kepler + except Exception as e: + IMPORT_ERROR = e + raise + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class KeplerTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.mkdtemp() + try: + cls.kepler = get_kepler() + cls.skip_tests = False + except Exception as e: + cls.skip_tests = True + cls.skip_reason = str(e) + + @classmethod + def tearDownClass(cls) -> None: + import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def setUp(self) -> None: + if self.skip_tests: + self.skipTest(f"kepler import failed: {self.skip_reason}") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestMapInit(KeplerTestCase): + + def test_map_init_default_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + self.assertIsInstance(m, keplergl.KeplerGl) + self.assertIn("config", dir(m)) + + def test_map_init_custom_center(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(center=[40, -100]) + self.assertIsInstance(m, keplergl.KeplerGl) + self.assertEqual(m.config["config"]["mapState"]["latitude"], 40) + self.assertEqual(m.config["config"]["mapState"]["longitude"], -100) + + def test_map_init_custom_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(zoom=10) + self.assertIsInstance(m, keplergl.KeplerGl) + self.assertEqual(m.config["config"]["mapState"]["zoom"], 10) + + def test_map_init_custom_height_width(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(height=800, width=1000) + self.assertIsInstance(m, keplergl.KeplerGl) + self.assertEqual(m.config["config"]["mapState"]["height"], 800) + self.assertEqual(m.config["config"]["mapState"]["width"], 1000) + + def test_map_init_height_width_with_px(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(height="800px", width="1000px") + self.assertEqual(m.config["config"]["mapState"]["height"], "800") + self.assertEqual(m.config["config"]["mapState"]["width"], "1000") + + def test_map_init_pitch_bearing(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(pitch=45, bearing=90) + self.assertEqual(m.config["config"]["mapState"]["pitch"], 45) + self.assertEqual(m.config["config"]["mapState"]["bearing"], 90) + + def test_map_init_drag_rotate(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(dragRotate=True) + self.assertTrue(m.config["config"]["mapState"]["dragRotate"]) + + def test_map_init_is_split(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map(isSplit=True) + self.assertTrue(m.config["config"]["mapState"]["isSplit"]) + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddGeojson(KeplerTestCase): + + def test_add_geojson_dict_input(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.4, 37.8]}, + "properties": {"name": "Test"}, + } + ], + } + m = self.kepler.Map() + m.add_geojson(geojson_data, layer_name="Test GeoJSON") + + def test_add_geojson_from_file(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"id": 1}, + } + ], + } + temp_path = os.path.join(self.temp_dir, "test.geojson") + with open(temp_path, "w") as f: + json.dump(geojson_data, f) + + m = self.kepler.Map() + m.add_geojson(temp_path, layer_name="Test File") + + def test_add_geojson_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(Exception): + m.add_geojson("/nonexistent/path/file.geojson") + + def test_add_geojson_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(Exception): + m.add_geojson(12345) + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddShapefile(KeplerTestCase): + + def test_add_shapefile_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(FileNotFoundError): + m.add_shp("/nonexistent/path/file.shp") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddDf(KeplerTestCase): + + def test_add_df_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + df = pd.DataFrame({ + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5] + }) + m.add_df(df, layer_name="Test DataFrame") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddCsv(KeplerTestCase): + + def test_add_csv_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + df = pd.DataFrame({ + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5] + }) + temp_path = os.path.join(self.temp_dir, "test.csv") + df.to_csv(temp_path, index=False) + + m = self.kepler.Map() + m.add_csv(temp_path, layer_name="Test CSV") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddVector(KeplerTestCase): + + def test_add_vector_shp_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(FileNotFoundError): + m.add_vector("/nonexistent/path/file.shp") + + def test_add_vector_geojson_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(Exception): + m.add_vector("/nonexistent/path/file.geojson") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestAddKml(KeplerTestCase): + + def test_add_kml_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(FileNotFoundError): + m.add_kml("/nonexistent/path/file.kml") + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestToHtml(KeplerTestCase): + + def test_to_html_returns_string(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + html = m.to_html() + self.assertIsInstance(html, str) + + def test_to_html_saves_file(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + temp_path = os.path.join(self.temp_dir, "test_map.html") + m.to_html(temp_path) + self.assertTrue(os.path.exists(temp_path)) + + def test_to_html_invalid_extension_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + temp_path = os.path.join(self.temp_dir, "test_map.txt") + with self.assertRaises(ValueError) as ctx: + m.to_html(temp_path) + self.assertIn("html", str(ctx.exception)) + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestLoadConfig(KeplerTestCase): + + def test_load_config_none(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + m.load_config(None) + + def test_load_config_dict(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + config = {"version": "v1", "config": {"mapState": {}}} + m.load_config(config) + self.assertEqual(m.config, config) + + def test_load_config_from_file(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + config = {"version": "v1", "config": {"mapState": {"zoom": 5}}} + temp_path = os.path.join(self.temp_dir, "config.json") + with open(temp_path, "w") as f: + json.dump(config, f) + + m = self.kepler.Map() + m.load_config(temp_path) + self.assertEqual(m.config["config"]["mapState"]["zoom"], 5) + + def test_load_config_file_not_found_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(FileNotFoundError): + m.load_config("/nonexistent/path/config.json") + + def test_load_config_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(TypeError): + m.load_config(12345) + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestSaveConfig(KeplerTestCase): + + def test_save_config_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + temp_path = os.path.join(self.temp_dir, "saved_config.json") + m.save_config(temp_path) + self.assertTrue(os.path.exists(temp_path)) + + with open(temp_path) as f: + saved_config = json.load(f) + self.assertEqual(saved_config["version"], "v1") + + def test_save_config_invalid_extension_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + temp_path = os.path.join(self.temp_dir, "config.txt") + with self.assertRaises(ValueError) as ctx: + m.save_config(temp_path) + self.assertIn("json", str(ctx.exception)) + + def test_save_config_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.kepler.Map() + with self.assertRaises(TypeError): + m.save_config(12345) + + +@unittest.skipUnless(KEPLERGL_AVAILABLE, "keplergl not available") +class TestStaticMap(KeplerTestCase): + + def test_static_map_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + with self.assertRaises(TypeError): + not_a_map = "not a map" + self.kepler.Map.static_map(not_a_map) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_maplibregl.py b/tests/test_maplibregl.py new file mode 100644 index 0000000000..56a4b66f98 --- /dev/null +++ b/tests/test_maplibregl.py @@ -0,0 +1,218 @@ +"""Tests for the maplibregl module.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest import mock + +try: + from maplibre.ipywidget import MapWidget + MAPLIBRE_AVAILABLE = True +except ImportError: + MAPLIBRE_AVAILABLE = False + +MAPLIBREGL_MODULE = None +IMPORT_ERROR = None + + +def get_maplibregl(): + global MAPLIBREGL_MODULE, IMPORT_ERROR + if MAPLIBREGL_MODULE is not None: + return MAPLIBREGL_MODULE + if IMPORT_ERROR is not None: + raise IMPORT_ERROR + try: + with mock.patch("geemap.coreutils.ee_initialize"): + from geemap import maplibregl + MAPLIBREGL_MODULE = maplibregl + return maplibregl + except Exception as e: + IMPORT_ERROR = e + raise + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class MaplibreglTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.mkdtemp() + try: + cls.maplibregl = get_maplibregl() + cls.skip_tests = False + except Exception as e: + cls.skip_tests = True + cls.skip_reason = str(e) + + @classmethod + def tearDownClass(cls) -> None: + import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def setUp(self) -> None: + if self.skip_tests: + self.skipTest(f"maplibregl import failed: {self.skip_reason}") + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestMapInit(MaplibreglTestCase): + + def test_map_init_default_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + self.assertIsInstance(m, MapWidget) + + def test_map_init_custom_center(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(center=(-100, 40)) + self.assertIsInstance(m, MapWidget) + + def test_map_init_custom_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(zoom=10) + self.assertIsInstance(m, MapWidget) + + def test_map_init_custom_pitch(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(pitch=45) + self.assertIsInstance(m, MapWidget) + + def test_map_init_custom_bearing(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(bearing=90) + self.assertIsInstance(m, MapWidget) + + def test_map_init_custom_height(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(height="800px") + self.assertIsInstance(m, MapWidget) + + def test_map_init_carto_style(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(style="positron") + self.assertIsInstance(m, MapWidget) + + def test_map_init_no_controls(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(controls={}) + self.assertIsInstance(m, MapWidget) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestSetCenter(MaplibreglTestCase): + + def test_set_center_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.set_center(lon=-122.4, lat=37.8, zoom=12) + + def test_set_center_without_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.set_center(lon=-122.4, lat=37.8) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestSetZoom(MaplibreglTestCase): + + def test_set_zoom_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.set_zoom(10) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestFitBounds(MaplibreglTestCase): + + def test_fit_bounds_list_of_lists(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.fit_bounds([[-122.5, 37.5], [-122.0, 38.0]]) + + def test_fit_bounds_flat_list(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.fit_bounds([-122.5, 37.5, -122.0, 38.0]) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestAddControl(MaplibreglTestCase): + + def test_add_control_scale(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(controls={}) + m.add_control("scale", position="bottom-left") + + def test_add_control_fullscreen(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(controls={}) + m.add_control("fullscreen", position="top-right") + + def test_add_control_navigation(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map(controls={}) + m.add_control("navigation", position="top-right") + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestAddRemoveLayer(MaplibreglTestCase): + + def test_remove_layer_not_existing(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.remove_layer("nonexistent_layer") + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestAddTileLayer(MaplibreglTestCase): + + def test_add_tile_layer_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + m.add_tile_layer( + url="https://tile.example.com/{z}/{x}/{y}.png", + name="Test Layer", + attribution="Test" + ) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestAddGeojson(MaplibreglTestCase): + + def test_add_geojson_dict_input(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + geojson_data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.4, 37.8]}, + "properties": {"name": "Test"}, + } + ], + } + m = self.maplibregl.Map() + m.add_geojson(geojson_data, name="Test GeoJSON", fit_bounds=False) + + def test_add_geojson_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + with self.assertRaises(ValueError): + m.add_geojson(12345) + + +@unittest.skipUnless(MAPLIBRE_AVAILABLE, "maplibre not available") +class TestAddGdf(MaplibreglTestCase): + + def test_add_gdf_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.maplibregl.Map() + with self.assertRaises(ValueError): + m.add_gdf("not_a_gdf") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ml.py b/tests/test_ml.py new file mode 100644 index 0000000000..3e84df003d --- /dev/null +++ b/tests/test_ml.py @@ -0,0 +1,392 @@ +"""Tests for the ml module.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest import mock + +import numpy as np + +from tests.mocks import mock_ee + + +class MockTree: + + def __init__( + self, + n_nodes: int = 7, + n_classes: int = 2, + is_classifier: bool = True, + ) -> None: + self.node_count = n_nodes + self.children_left = np.array([1, 2, -1, -1, 5, -1, -1]) + self.children_right = np.array([4, 3, -1, -1, 6, -1, -1]) + self.feature = np.array([0, 1, -2, -2, 0, -2, -2]) + self.impurity = np.array([0.5, 0.4, 0.0, 0.0, 0.3, 0.0, 0.0]) + self.n_node_samples = np.array([100, 60, 30, 30, 40, 20, 20]) + self.threshold = np.array([0.5, 0.3, -2.0, -2.0, 0.7, -2.0, -2.0]) + + if is_classifier: + self.value = np.array([ + [[50, 50]], + [[40, 20]], + [[30, 0]], + [[10, 20]], + [[10, 30]], + [[5, 15]], + [[5, 15]], + ]) + else: + self.value = np.array([0.5, 0.4, 0.3, 0.5, 0.6, 0.7, 0.8]) + + +class MockDecisionTreeClassifier: + + def __init__(self, n_nodes: int = 7, n_classes: int = 2) -> None: + self.tree_ = MockTree(n_nodes=n_nodes, n_classes=n_classes, is_classifier=True) + + +class MockDecisionTreeRegressor: + + def __init__(self, n_nodes: int = 7) -> None: + self.tree_ = MockTree(n_nodes=n_nodes, is_classifier=False) + + +class MockRandomForestClassifier: + + def __init__(self, n_estimators: int = 3) -> None: + self.estimators_ = [MockDecisionTreeClassifier() for _ in range(n_estimators)] + self.criterion = "gini" + self.classes_ = np.array([0, 1]) + + +class MockRandomForestRegressor: + + def __init__(self, n_estimators: int = 3) -> None: + self.estimators_ = [MockDecisionTreeRegressor() for _ in range(n_estimators)] + self.criterion = "mse" + + +class TestTreeToString(unittest.TestCase): + + def test_tree_to_string_valid_tree(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string(estimator, feature_names) + + self.assertIsInstance(result, str) + self.assertIn("root", result) + self.assertIn("band1", result) + + def test_tree_to_string_classification_mode(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string( + estimator, feature_names, output_mode="CLASSIFICATION" + ) + + self.assertIsInstance(result, str) + self.assertIn("root", result) + + def test_tree_to_string_regression_mode(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeRegressor() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string(estimator, feature_names, output_mode="REGRESSION") + + self.assertIsInstance(result, str) + self.assertIn("root", result) + + def test_tree_to_string_probability_mode(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string( + estimator, feature_names, output_mode="PROBABILITY" + ) + + self.assertIsInstance(result, str) + self.assertIn("root", result) + + def test_tree_to_string_with_labels(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + labels = [10, 20] + + result = ml.tree_to_string( + estimator, feature_names, labels=labels, output_mode="CLASSIFICATION" + ) + + self.assertIsInstance(result, str) + self.assertIn("root", result) + + def test_tree_to_string_infer_classification(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string(estimator, feature_names, output_mode="INFER") + + self.assertIsInstance(result, str) + + def test_tree_to_string_infer_regression(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeRegressor() + feature_names = ["band1", "band2"] + + result = ml.tree_to_string(estimator, feature_names, output_mode="INFER") + + self.assertIsInstance(result, str) + + def test_tree_to_string_multiprobability_raises(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + with self.assertRaises(NotImplementedError): + ml.tree_to_string( + estimator, feature_names, output_mode="MULTIPROBABILITY" + ) + + def test_tree_to_string_invalid_mode_raises(self) -> None: + from geemap import ml + + estimator = MockDecisionTreeClassifier() + feature_names = ["band1", "band2"] + + with self.assertRaises(RuntimeError): + ml.tree_to_string(estimator, feature_names, output_mode="INVALID") + + +class TestRfToStrings(unittest.TestCase): + + @mock.patch("geemap.ml.mp.Pool") + def test_rf_to_strings_valid_forest(self, mock_pool: mock.Mock) -> None: + from geemap import ml + + mock_pool_instance = mock.MagicMock() + mock_pool.return_value.__enter__.return_value = mock_pool_instance + mock_result = mock.MagicMock() + mock_result.get.return_value = ["tree1", "tree2", "tree3"] + mock_pool_instance.map_async.return_value = mock_result + + estimator = MockRandomForestClassifier(n_estimators=3) + feature_names = ["band1", "band2"] + + result = ml.rf_to_strings(estimator, feature_names) + + self.assertEqual(len(result), 3) + + @mock.patch("geemap.ml.mp.Pool") + def test_rf_to_strings_regression(self, mock_pool: mock.Mock) -> None: + from geemap import ml + + mock_pool_instance = mock.MagicMock() + mock_pool.return_value.__enter__.return_value = mock_pool_instance + mock_result = mock.MagicMock() + mock_result.get.return_value = ["tree1", "tree2"] + mock_pool_instance.map_async.return_value = mock_result + + estimator = MockRandomForestRegressor(n_estimators=2) + feature_names = ["band1", "band2"] + + result = ml.rf_to_strings(estimator, feature_names, output_mode="REGRESSION") + + self.assertEqual(len(result), 2) + + def test_rf_to_strings_invalid_mode_raises(self) -> None: + from geemap import ml + + estimator = MockRandomForestClassifier() + feature_names = ["band1", "band2"] + + with self.assertRaises(ValueError): + ml.rf_to_strings(estimator, feature_names, output_mode="INVALID_MODE") + + @mock.patch("geemap.ml.mp.Pool") + @mock.patch("geemap.ml.mp.cpu_count", return_value=4) + def test_rf_to_strings_respects_cpu_limit( + self, mock_cpu_count: mock.Mock, mock_pool: mock.Mock + ) -> None: + from geemap import ml + + mock_pool_instance = mock.MagicMock() + mock_pool.return_value.__enter__.return_value = mock_pool_instance + mock_result = mock.MagicMock() + mock_result.get.return_value = ["tree1"] + mock_pool_instance.map_async.return_value = mock_result + + estimator = MockRandomForestClassifier(n_estimators=1) + feature_names = ["band1", "band2"] + + ml.rf_to_strings(estimator, feature_names, processes=10) + + mock_pool.assert_called_once_with(3) + + +class TestStringsToClassifier(unittest.TestCase): + + @mock.patch("geemap.ml.ee", mock_ee) + def test_strings_to_classifier_valid_input(self) -> None: + from geemap import ml + + trees = ["1) root 100 9999 9999", "1) root 100 9999 9999"] + + result = ml.strings_to_classifier(trees) + + self.assertIsInstance(result, mock_ee.Classifier) + + @mock.patch("geemap.ml.ee", mock_ee) + def test_strings_to_classifier_empty_list(self) -> None: + from geemap import ml + + trees = [] + + result = ml.strings_to_classifier(trees) + + self.assertIsInstance(result, mock_ee.Classifier) + + @mock.patch("geemap.ml.ee", mock_ee) + def test_strings_to_classifier_single_tree(self) -> None: + from geemap import ml + + trees = ["1) root 100 9999 9999 (0.5)\n 2) band1 <= 0.5 50 0.4 0"] + + result = ml.strings_to_classifier(trees) + + self.assertIsInstance(result, mock_ee.Classifier) + + +class TestFcToClassifier(unittest.TestCase): + + @mock.patch("geemap.ml.ee", mock_ee) + def test_fc_to_classifier_valid_fc(self) -> None: + from geemap import ml + + fc = mock_ee.FeatureCollection() + + result = ml.fc_to_classifier(fc) + + self.assertIsInstance(result, mock_ee.Classifier) + + +class TestExportTreesToFc(unittest.TestCase): + + @mock.patch("geemap.ml.ee", mock_ee) + def test_export_trees_to_fc_starts_task(self) -> None: + from geemap import ml + + trees = ["1) root 100\n 2) band1 <= 0.5", "1) root 100\n 2) band2 > 0.3"] + asset_id = "users/test/rf_export" + + ml.export_trees_to_fc(trees, asset_id) + + @mock.patch("geemap.ml.ee", mock_ee) + def test_export_trees_to_fc_custom_description(self) -> None: + from geemap import ml + + trees = ["1) root 100"] + asset_id = "users/test/rf_export" + + ml.export_trees_to_fc(trees, asset_id, description="custom_export") + + +class TestTreesToCsv(unittest.TestCase): + + def test_trees_to_csv_creates_file(self) -> None: + from geemap import ml + + trees = ["1) root 100\n 2) band1 <= 0.5", "1) root 100\n 2) band2 > 0.3"] + + with tempfile.TemporaryDirectory() as tmpdir: + out_csv = os.path.join(tmpdir, "trees.csv") + ml.trees_to_csv(trees, out_csv) + self.assertTrue(os.path.exists(out_csv)) + + def test_trees_to_csv_correct_content(self) -> None: + from geemap import ml + + trees = ["1) root 100\n 2) band1 <= 0.5", "1) root 100\n 2) band2 > 0.3"] + + with tempfile.TemporaryDirectory() as tmpdir: + out_csv = os.path.join(tmpdir, "trees.csv") + ml.trees_to_csv(trees, out_csv) + + with open(out_csv) as f: + lines = f.readlines() + + self.assertEqual(len(lines), 2) + self.assertIn("#", lines[0]) + + def test_trees_to_csv_empty_list(self) -> None: + from geemap import ml + + trees = [] + + with tempfile.TemporaryDirectory() as tmpdir: + out_csv = os.path.join(tmpdir, "trees.csv") + ml.trees_to_csv(trees, out_csv) + + with open(out_csv) as f: + content = f.read() + + self.assertEqual(content, "") + + +class TestCsvToClassifier(unittest.TestCase): + + @mock.patch("geemap.ml.ee", mock_ee) + def test_csv_to_classifier_valid_csv(self) -> None: + from geemap import ml + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "trees.csv") + with open(csv_path, "w") as f: + f.write("1) root 100# 2) band1 <= 0.5\n") + f.write("1) root 100# 2) band2 > 0.3\n") + + result = ml.csv_to_classifier(csv_path) + + self.assertIsInstance(result, mock_ee.Classifier) + + @mock.patch("geemap.ml.ee", mock_ee) + def test_csv_to_classifier_missing_file(self) -> None: + from geemap import ml + + result = ml.csv_to_classifier("/nonexistent/path/trees.csv") + + self.assertIsNone(result) + + @mock.patch("geemap.ml.ee", mock_ee) + def test_csv_to_classifier_empty_csv(self) -> None: + from geemap import ml + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "empty.csv") + with open(csv_path, "w") as f: + pass + + result = ml.csv_to_classifier(csv_path) + + self.assertIsInstance(result, mock_ee.Classifier) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_osm.py b/tests/test_osm.py new file mode 100644 index 0000000000..db785d6403 --- /dev/null +++ b/tests/test_osm.py @@ -0,0 +1,469 @@ +"""Tests for the osm module.""" + +from __future__ import annotations + +import sys +import unittest +from unittest import mock + +from shapely.geometry import Polygon + +from tests.mocks.mock_osmnx import MockGeoDataFrame + +mock_ox = mock.MagicMock() +mock_ox.features = mock.MagicMock() +mock_ox.geocoder = mock.MagicMock() +sys.modules["osmnx"] = mock_ox + +from geemap import osm + + +class TestOsmGdfFromAddress(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_address_valid_input(self) -> None: + mock_gdf = MockGeoDataFrame(geometry=[mock.Mock()], data={"name": ["Test"]}) + mock_ox.features.features_from_address.return_value = mock_gdf + result = osm.osm_gdf_from_address("New York", {"building": True}) + self.assertIsNotNone(result) + mock_ox.features.features_from_address.assert_called_once_with( + "New York", {"building": True}, 1000 + ) + + def test_osm_gdf_from_address_custom_distance(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_address.return_value = mock_gdf + osm.osm_gdf_from_address("Paris", {"amenity": True}, dist=500) + mock_ox.features.features_from_address.assert_called_with( + "Paris", {"amenity": True}, 500 + ) + + def test_osm_gdf_from_address_multiple_tags(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_address.return_value = mock_gdf + tags = {"building": True, "landuse": ["retail", "commercial"]} + osm.osm_gdf_from_address("London", tags) + mock_ox.features.features_from_address.assert_called_with("London", tags, 1000) + + +class TestOsmShpFromAddress(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_address") + def test_osm_shp_from_address_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_address("Berlin", {"highway": True}, "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + @mock.patch("geemap.osm.osm_gdf_from_address") + def test_osm_shp_from_address_passes_distance( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_address("Tokyo", {"building": True}, "/tmp/test.shp", dist=2000) + mock_gdf_func.assert_called_once_with("Tokyo", {"building": True}, 2000) + + +class TestOsmGeojsonFromAddress(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_address") + def test_osm_geojson_from_address_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_address("Sydney", {"building": True}) + self.assertIsInstance(result, dict) + self.assertEqual(result["type"], "FeatureCollection") + + @mock.patch("geemap.osm.osm_gdf_from_address") + def test_osm_geojson_from_address_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_address( + "Melbourne", {"building": True}, filepath="/tmp/test.geojson" + ) + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + self.assertIsNone(result) + + @mock.patch("geemap.osm.osm_gdf_from_address") + def test_osm_geojson_from_address_none_filepath( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_address("Rome", {"highway": True}, filepath=None) + self.assertIsNotNone(result) + + +class TestOsmGdfFromPlace(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_place_valid_input(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_place.return_value = mock_gdf + result = osm.osm_gdf_from_place("Manhattan, NY", {"building": True}) + self.assertIsNotNone(result) + mock_ox.features.features_from_place.assert_called() + + def test_osm_gdf_from_place_with_which_result(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_place.return_value = mock_gdf + osm.osm_gdf_from_place("Brooklyn, NY", {"landuse": True}, which_result=1) + mock_ox.features.features_from_place.assert_called_with( + "Brooklyn, NY", {"landuse": True}, which_result=1 + ) + + +class TestOsmShpFromPlace(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_place") + def test_osm_shp_from_place_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_place("Queens, NY", {"highway": True}, "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + +class TestOsmGeojsonFromPlace(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_place") + def test_osm_geojson_from_place_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_place("Bronx, NY", {"building": True}) + self.assertIsInstance(result, dict) + + @mock.patch("geemap.osm.osm_gdf_from_place") + def test_osm_geojson_from_place_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_geojson_from_place( + "Staten Island, NY", {"building": True}, filepath="/tmp/test.geojson" + ) + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + + +class TestOsmGdfFromPoint(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_point_valid_coords(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_point.return_value = mock_gdf + result = osm.osm_gdf_from_point((40.7128, -74.0060), {"building": True}) + self.assertIsNotNone(result) + mock_ox.features.features_from_point.assert_called_with( + (40.7128, -74.0060), {"building": True}, 1000 + ) + + def test_osm_gdf_from_point_custom_distance(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_point.return_value = mock_gdf + osm.osm_gdf_from_point((51.5074, -0.1278), {"amenity": True}, dist=500) + mock_ox.features.features_from_point.assert_called_with( + (51.5074, -0.1278), {"amenity": True}, 500 + ) + + def test_osm_gdf_from_point_zero_coords(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_point.return_value = mock_gdf + osm.osm_gdf_from_point((0.0, 0.0), {"building": True}) + mock_ox.features.features_from_point.assert_called() + + +class TestOsmShpFromPoint(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_point") + def test_osm_shp_from_point_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_point((48.8566, 2.3522), {"building": True}, "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + +class TestOsmGeojsonFromPoint(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_point") + def test_osm_geojson_from_point_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_point((35.6762, 139.6503), {"building": True}) + self.assertIsInstance(result, dict) + + @mock.patch("geemap.osm.osm_gdf_from_point") + def test_osm_geojson_from_point_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_geojson_from_point( + (35.6762, 139.6503), {"building": True}, filepath="/tmp/test.geojson" + ) + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + + +class TestOsmGdfFromPolygon(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_polygon_valid_geom(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_polygon.return_value = mock_gdf + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + result = osm.osm_gdf_from_polygon(polygon, {"building": True}) + self.assertIsNotNone(result) + mock_ox.features.features_from_polygon.assert_called_with( + polygon, {"building": True} + ) + + def test_osm_gdf_from_polygon_multiple_tags(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_polygon.return_value = mock_gdf + polygon = Polygon([(-1, -1), (1, -1), (1, 1), (-1, 1)]) + tags = {"building": True, "highway": "primary"} + osm.osm_gdf_from_polygon(polygon, tags) + mock_ox.features.features_from_polygon.assert_called_with(polygon, tags) + + +class TestOsmShpFromPolygon(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_polygon") + def test_osm_shp_from_polygon_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + osm.osm_shp_from_polygon(polygon, {"building": True}, "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + +class TestOsmGeojsonFromPolygon(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_polygon") + def test_osm_geojson_from_polygon_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + result = osm.osm_geojson_from_polygon(polygon, {"building": True}) + self.assertIsInstance(result, dict) + + @mock.patch("geemap.osm.osm_gdf_from_polygon") + def test_osm_geojson_from_polygon_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + osm.osm_geojson_from_polygon( + polygon, {"building": True}, filepath="/tmp/test.geojson" + ) + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + + +class TestOsmGdfFromBbox(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_bbox_valid_bounds(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_bbox.return_value = mock_gdf + result = osm.osm_gdf_from_bbox(40.8, 40.7, -73.9, -74.0, {"building": True}) + self.assertIsNotNone(result) + mock_ox.features.features_from_bbox.assert_called_with( + (40.8, 40.7, -73.9, -74.0), {"building": True} + ) + + def test_osm_gdf_from_bbox_global_bounds(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_bbox.return_value = mock_gdf + osm.osm_gdf_from_bbox(90, -90, 180, -180, {"natural": True}) + mock_ox.features.features_from_bbox.assert_called() + + +class TestOsmShpFromBbox(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_bbox") + def test_osm_shp_from_bbox_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_bbox(40.8, 40.7, -73.9, -74.0, {"building": True}, "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + +class TestOsmGeojsonFromBbox(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_bbox") + def test_osm_geojson_from_bbox_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_bbox( + 40.8, 40.7, -73.9, -74.0, {"building": True} + ) + self.assertIsInstance(result, dict) + + @mock.patch("geemap.osm.osm_gdf_from_bbox") + def test_osm_geojson_from_bbox_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_geojson_from_bbox( + 40.8, 40.7, -73.9, -74.0, {"building": True}, filepath="/tmp/test.geojson" + ) + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + + +class TestOsmGdfFromXml(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_xml_valid_file(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_xml.return_value = mock_gdf + result = osm.osm_gdf_from_xml("/path/to/file.xml") + self.assertIsNotNone(result) + mock_ox.features.features_from_xml.assert_called_with( + "/path/to/file.xml", polygon=None, tags=None + ) + + def test_osm_gdf_from_xml_with_polygon(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_xml.return_value = mock_gdf + polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + osm.osm_gdf_from_xml("/path/to/file.xml", polygon=polygon) + mock_ox.features.features_from_xml.assert_called_with( + "/path/to/file.xml", polygon=polygon, tags=None + ) + + def test_osm_gdf_from_xml_with_tags(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.features.features_from_xml.return_value = mock_gdf + tags = {"building": True} + osm.osm_gdf_from_xml("/path/to/file.xml", tags=tags) + mock_ox.features.features_from_xml.assert_called_with( + "/path/to/file.xml", polygon=None, tags=tags + ) + + +class TestOsmGdfFromGeocode(unittest.TestCase): + + def setUp(self) -> None: + mock_ox.reset_mock() + + def test_osm_gdf_from_geocode_valid_query(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.geocoder.geocode_to_gdf.return_value = mock_gdf + result = osm.osm_gdf_from_geocode("New York City") + self.assertIsNotNone(result) + mock_ox.geocoder.geocode_to_gdf.assert_called_with( + "New York City", which_result=None, by_osmid=False + ) + + def test_osm_gdf_from_geocode_with_which_result(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.geocoder.geocode_to_gdf.return_value = mock_gdf + osm.osm_gdf_from_geocode("Paris", which_result=1) + mock_ox.geocoder.geocode_to_gdf.assert_called_with( + "Paris", which_result=1, by_osmid=False + ) + + def test_osm_gdf_from_geocode_by_osmid(self) -> None: + mock_gdf = MockGeoDataFrame() + mock_ox.geocoder.geocode_to_gdf.return_value = mock_gdf + osm.osm_gdf_from_geocode("R123456", by_osmid=True) + mock_ox.geocoder.geocode_to_gdf.assert_called_with( + "R123456", which_result=None, by_osmid=True + ) + + +class TestOsmShpFromGeocode(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_geocode") + def test_osm_shp_from_geocode_creates_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_shp_from_geocode("London", "/tmp/test.shp") + mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") + + +class TestOsmGeojsonFromGeocode(unittest.TestCase): + + @mock.patch("geemap.osm.osm_gdf_from_geocode") + def test_osm_geojson_from_geocode_returns_dict( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf_func.return_value = mock_gdf + result = osm.osm_geojson_from_geocode("Tokyo") + self.assertIsInstance(result, dict) + + @mock.patch("geemap.osm.osm_gdf_from_geocode") + def test_osm_geojson_from_geocode_writes_file( + self, mock_gdf_func: mock.Mock + ) -> None: + mock_gdf = MockGeoDataFrame() + mock_gdf.to_file = mock.Mock() + mock_gdf_func.return_value = mock_gdf + osm.osm_geojson_from_geocode("Berlin", filepath="/tmp/test.geojson") + mock_gdf.to_file.assert_called_once_with("/tmp/test.geojson", driver="GeoJSON") + + +class TestOsmTagsList(unittest.TestCase): + + @mock.patch("geemap.osm.webbrowser.open_new_tab") + def test_osm_tags_list_opens_browser( + self, mock_open: mock.Mock + ) -> None: + osm.osm_tags_list() + mock_open.assert_called_once_with( + "https://wiki.openstreetmap.org/wiki/Map_features" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 0000000000..9a4744c545 --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,426 @@ +"""Tests for the plot module.""" + +from __future__ import annotations + +import unittest +from unittest import mock + +import pandas as pd + +from geemap import plot + + +class MockFigure: + + def __init__(self) -> None: + self.layout_updates: dict = {} + + def update_layout(self, **kwargs) -> MockFigure: + self.layout_updates.update(kwargs) + return self + + +def create_sample_dataframe() -> pd.DataFrame: + return pd.DataFrame({ + "category": ["A", "B", "C", "D", "E"], + "value": [10, 20, 15, 25, 5], + "count": [100, 200, 150, 250, 50], + }) + + +class TestBarChart(unittest.TestCase): + + def setUp(self) -> None: + self.df = create_sample_dataframe() + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_valid_dataframe(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + result = plot.bar_chart(data=self.df, x="category", y="value") + self.assertIsNotNone(result) + mock_bar.assert_called_once() + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_descending_order(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + df_copy = self.df.copy() + plot.bar_chart(data=df_copy, x="category", y="value", descending=True) + self.assertEqual(df_copy.iloc[0]["value"], 25) + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_ascending_order(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + df_copy = self.df.copy() + plot.bar_chart(data=df_copy, x="category", y="value", descending=False) + self.assertEqual(df_copy.iloc[0]["value"], 5) + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_max_rows_limit(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + plot.bar_chart(data=self.df, x="category", y="value", max_rows=3) + call_args = mock_bar.call_args + data_passed = call_args[0][0] + self.assertEqual(len(data_passed), 3) + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_layout_args_applied(self, mock_bar: mock.Mock) -> None: + mock_fig = MockFigure() + mock_bar.return_value = mock_fig + plot.bar_chart( + data=self.df, + x="category", + y="value", + layout_args={"title_x": 0.5, "showlegend": False}, + ) + self.assertEqual(mock_fig.layout_updates.get("title_x"), 0.5) + self.assertEqual(mock_fig.layout_updates.get("showlegend"), False) + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_x_label_applied(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + plot.bar_chart( + data=self.df, x="category", y="value", x_label="Category Label" + ) + call_args = mock_bar.call_args + labels = call_args[1].get("labels", {}) + self.assertEqual(labels.get("category"), "Category Label") + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_y_label_applied(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + plot.bar_chart( + data=self.df, x="category", y="value", y_label="Value Label" + ) + call_args = mock_bar.call_args + labels = call_args[1].get("labels", {}) + self.assertEqual(labels.get("value"), "Value Label") + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_legend_title_applied(self, mock_bar: mock.Mock) -> None: + mock_fig = MockFigure() + mock_bar.return_value = mock_fig + plot.bar_chart( + data=self.df, x="category", y="value", legend_title="My Legend" + ) + legend = mock_fig.layout_updates.get("legend", {}) + self.assertEqual(legend.get("title"), "My Legend") + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_title_passed(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + plot.bar_chart(data=self.df, x="category", y="value", title="My Chart") + call_args = mock_bar.call_args + self.assertEqual(call_args[1].get("title"), "My Chart") + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_height_passed(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + plot.bar_chart(data=self.df, x="category", y="value", height=600) + call_args = mock_bar.call_args + self.assertEqual(call_args[1].get("height"), 600) + + def test_bar_chart_invalid_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.bar_chart(data=[1, 2, 3], x="x", y="y") + + def test_bar_chart_none_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.bar_chart(data=None, x="x", y="y") + + @mock.patch("geemap.plot.px.bar") + def test_bar_chart_y_as_list(self, mock_bar: mock.Mock) -> None: + mock_bar.return_value = MockFigure() + df_copy = self.df.copy() + plot.bar_chart( + data=df_copy, x="category", y=["value", "count"], descending=True + ) + self.assertEqual(df_copy.iloc[0]["value"], 25) + + +class TestLineChart(unittest.TestCase): + + def setUp(self) -> None: + self.df = create_sample_dataframe() + + @mock.patch("geemap.plot.px.line") + def test_line_chart_valid_dataframe(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + result = plot.line_chart(data=self.df, x="category", y="value") + self.assertIsNotNone(result) + mock_line.assert_called_once() + + @mock.patch("geemap.plot.px.line") + def test_line_chart_descending_order(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + df_copy = self.df.copy() + plot.line_chart(data=df_copy, x="category", y="value", descending=True) + self.assertEqual(df_copy.iloc[0]["value"], 25) + + @mock.patch("geemap.plot.px.line") + def test_line_chart_ascending_order(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + df_copy = self.df.copy() + plot.line_chart(data=df_copy, x="category", y="value", descending=False) + self.assertEqual(df_copy.iloc[0]["value"], 5) + + @mock.patch("geemap.plot.px.line") + def test_line_chart_no_sorting(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + df_copy = self.df.copy() + original_first = df_copy.iloc[0]["value"] + plot.line_chart(data=df_copy, x="category", y="value", descending=None) + self.assertEqual(df_copy.iloc[0]["value"], original_first) + + @mock.patch("geemap.plot.px.line") + def test_line_chart_max_rows_limit(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + plot.line_chart(data=self.df, x="category", y="value", max_rows=2) + call_args = mock_line.call_args + data_passed = call_args[0][0] + self.assertEqual(len(data_passed), 2) + + @mock.patch("geemap.plot.px.line") + def test_line_chart_layout_args_applied(self, mock_line: mock.Mock) -> None: + mock_fig = MockFigure() + mock_line.return_value = mock_fig + plot.line_chart( + data=self.df, + x="category", + y="value", + layout_args={"title_x": 0.5}, + ) + self.assertEqual(mock_fig.layout_updates.get("title_x"), 0.5) + + @mock.patch("geemap.plot.px.line") + def test_line_chart_labels_applied(self, mock_line: mock.Mock) -> None: + mock_line.return_value = MockFigure() + plot.line_chart( + data=self.df, + x="category", + y="value", + x_label="X Axis", + y_label="Y Axis", + ) + call_args = mock_line.call_args + labels = call_args[1].get("labels", {}) + self.assertEqual(labels.get("category"), "X Axis") + self.assertEqual(labels.get("value"), "Y Axis") + + @mock.patch("geemap.plot.px.line") + def test_line_chart_legend_title_applied(self, mock_line: mock.Mock) -> None: + mock_fig = MockFigure() + mock_line.return_value = mock_fig + plot.line_chart( + data=self.df, x="category", y="value", legend_title="Legend" + ) + legend = mock_fig.layout_updates.get("legend", {}) + self.assertEqual(legend.get("title"), "Legend") + + def test_line_chart_invalid_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.line_chart(data={"a": 1}, x="x", y="y") + + def test_line_chart_none_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.line_chart(data=None, x="x", y="y") + + +class TestHistogram(unittest.TestCase): + + def setUp(self) -> None: + self.df = create_sample_dataframe() + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_valid_dataframe(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + result = plot.histogram(data=self.df, x="value") + self.assertIsNotNone(result) + mock_hist.assert_called_once() + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_descending_order(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + df_copy = self.df.copy() + plot.histogram(data=df_copy, x="category", y="value", descending=True) + self.assertEqual(df_copy.iloc[0]["value"], 25) + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_no_sorting(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + df_copy = self.df.copy() + original_first = df_copy.iloc[0]["value"] + plot.histogram(data=df_copy, x="value", descending=None) + self.assertEqual(df_copy.iloc[0]["value"], original_first) + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_max_rows_limit(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + plot.histogram(data=self.df, x="value", max_rows=3) + call_args = mock_hist.call_args + data_passed = call_args[0][0] + self.assertEqual(len(data_passed), 3) + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_layout_args_applied(self, mock_hist: mock.Mock) -> None: + mock_fig = MockFigure() + mock_hist.return_value = mock_fig + plot.histogram( + data=self.df, + x="value", + layout_args={"bargap": 0.2}, + ) + self.assertEqual(mock_fig.layout_updates.get("bargap"), 0.2) + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_labels_applied(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + plot.histogram( + data=self.df, + x="value", + y="count", + x_label="Values", + y_label="Count", + ) + call_args = mock_hist.call_args + labels = call_args[1].get("labels", {}) + self.assertEqual(labels.get("value"), "Values") + self.assertEqual(labels.get("count"), "Count") + + @mock.patch("geemap.plot.px.histogram") + def test_histogram_title_passed(self, mock_hist: mock.Mock) -> None: + mock_hist.return_value = MockFigure() + plot.histogram(data=self.df, x="value", title="Histogram Title") + call_args = mock_hist.call_args + self.assertEqual(call_args[1].get("title"), "Histogram Title") + + def test_histogram_invalid_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.histogram(data=123, x="x") + + def test_histogram_none_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.histogram(data=None, x="x") + + +class TestPieChart(unittest.TestCase): + + def setUp(self) -> None: + self.df = pd.DataFrame({ + "fruit": ["Apple", "Banana", "Cherry", "Date", "Elderberry"], + "quantity": [30, 20, 15, 25, 10], + }) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_valid_dataframe(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + result = plot.pie_chart(data=self.df, names="fruit", values="quantity") + self.assertIsNotNone(result) + mock_pie.assert_called_once() + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_descending_order(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + df_copy = self.df.copy() + plot.pie_chart(data=df_copy, names="fruit", values="quantity", descending=True) + self.assertEqual(df_copy.iloc[0]["quantity"], 30) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_ascending_order(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + df_copy = self.df.copy() + plot.pie_chart(data=df_copy, names="fruit", values="quantity", descending=False) + self.assertEqual(df_copy.iloc[0]["quantity"], 10) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_max_rows_with_other(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + df = pd.DataFrame({ + "fruit": ["A", "B", "C", "D", "E", "F"], + "quantity": [100, 80, 60, 40, 20, 10], + }) + plot.pie_chart(data=df, names="fruit", values="quantity", max_rows=4) + call_args = mock_pie.call_args + data_passed = call_args[1].get("data_frame") + self.assertIn("Other", data_passed["fruit"].values) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_custom_other_label(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + df = pd.DataFrame({ + "fruit": ["A", "B", "C", "D", "E"], + "quantity": [100, 80, 60, 40, 20], + }) + plot.pie_chart( + data=df, + names="fruit", + values="quantity", + max_rows=3, + other_label="Rest", + ) + call_args = mock_pie.call_args + data_passed = call_args[1].get("data_frame") + self.assertIn("Rest", data_passed["fruit"].values) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_donut_hole(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + plot.pie_chart( + data=self.df, names="fruit", values="quantity", hole=0.4 + ) + call_args = mock_pie.call_args + self.assertEqual(call_args[1].get("hole"), 0.4) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_layout_args_applied(self, mock_pie: mock.Mock) -> None: + mock_fig = MockFigure() + mock_pie.return_value = mock_fig + plot.pie_chart( + data=self.df, + names="fruit", + values="quantity", + layout_args={"showlegend": True}, + ) + self.assertEqual(mock_fig.layout_updates.get("showlegend"), True) + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_legend_title_applied(self, mock_pie: mock.Mock) -> None: + mock_fig = MockFigure() + mock_pie.return_value = mock_fig + plot.pie_chart( + data=self.df, + names="fruit", + values="quantity", + legend_title="Fruits", + ) + legend = mock_fig.layout_updates.get("legend", {}) + self.assertEqual(legend.get("title"), "Fruits") + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_title_passed(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + plot.pie_chart( + data=self.df, names="fruit", values="quantity", title="Pie Title" + ) + call_args = mock_pie.call_args + self.assertEqual(call_args[1].get("title"), "Pie Title") + + @mock.patch("geemap.plot.px.pie") + def test_pie_chart_opacity_passed(self, mock_pie: mock.Mock) -> None: + mock_pie.return_value = MockFigure() + plot.pie_chart( + data=self.df, names="fruit", values="quantity", opacity=0.8 + ) + call_args = mock_pie.call_args + self.assertEqual(call_args[1].get("opacity"), 0.8) + + def test_pie_chart_invalid_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.pie_chart(data=[1, 2, 3], names="x", values="y") + + def test_pie_chart_none_data_raises(self) -> None: + with self.assertRaises(ValueError): + plot.pie_chart(data=None, names="x", values="y") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plotlymap.py b/tests/test_plotlymap.py new file mode 100644 index 0000000000..bcd9e98f6b --- /dev/null +++ b/tests/test_plotlymap.py @@ -0,0 +1,257 @@ +"""Tests for the plotlymap module.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest import mock + +import pandas as pd + +try: + import plotly.graph_objects as go + PLOTLY_AVAILABLE = True +except ImportError: + PLOTLY_AVAILABLE = False + +PLOTLYMAP_MODULE = None +IMPORT_ERROR = None + + +def get_plotlymap(): + global PLOTLYMAP_MODULE, IMPORT_ERROR + if PLOTLYMAP_MODULE is not None: + return PLOTLYMAP_MODULE + if IMPORT_ERROR is not None: + raise IMPORT_ERROR + try: + with mock.patch("geemap.coreutils.ee_initialize"): + from geemap import plotlymap + PLOTLYMAP_MODULE = plotlymap + return plotlymap + except Exception as e: + IMPORT_ERROR = e + raise + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class PlotlymapTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.mkdtemp() + try: + cls.plotlymap = get_plotlymap() + cls.skip_tests = False + except Exception as e: + cls.skip_tests = True + cls.skip_reason = str(e) + + @classmethod + def tearDownClass(cls) -> None: + import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) + + def setUp(self) -> None: + if self.skip_tests: + self.skipTest(f"plotlymap import failed: {self.skip_reason}") + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestMapInit(PlotlymapTestCase): + + def test_map_init_default_params(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + self.assertIsInstance(m, go.FigureWidget) + + def test_map_init_custom_center(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(center=(40, -100), ee_initialize=False) + self.assertIsInstance(m, go.FigureWidget) + + def test_map_init_custom_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(zoom=10, ee_initialize=False) + self.assertIsInstance(m, go.FigureWidget) + + def test_map_init_custom_height(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(height=800, ee_initialize=False) + self.assertIsInstance(m, go.FigureWidget) + + def test_map_init_custom_basemap(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(basemap="carto-positron", ee_initialize=False) + self.assertIsInstance(m, go.FigureWidget) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestSetCenter(PlotlymapTestCase): + + def test_set_center_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.set_center(lat=37.8, lon=-122.4, zoom=12) + + def test_set_center_without_zoom(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.set_center(lat=37.8, lon=-122.4) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddBasemap(PlotlymapTestCase): + + def test_add_basemap_invalid_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(ValueError): + m.add_basemap("INVALID_BASEMAP_NAME_XYZ") + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddRemoveControls(PlotlymapTestCase): + + def test_add_controls_string(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.add_controls("drawline") + + def test_add_controls_list(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.add_controls(["drawline", "drawopenpath"]) + + def test_add_controls_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(ValueError): + m.add_controls(12345) + + def test_remove_controls_string(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.remove_controls("zoomin") + + def test_remove_controls_list(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.remove_controls(["zoomin", "zoomout"]) + + def test_remove_controls_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(ValueError): + m.remove_controls(12345) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddTileLayer(PlotlymapTestCase): + + def test_add_tile_layer_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.add_tile_layer( + url="https://tile.example.com/{z}/{x}/{y}.png", + name="Test Layer", + attribution="Test" + ) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddLayer(PlotlymapTestCase): + + def test_add_layer_valid(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + layer = go.Scattermapbox(lat=[37.8], lon=[-122.4], name="test") + m.add_layer(layer, name="Test Layer") + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestLayerManagement(PlotlymapTestCase): + + def test_get_layers_empty(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + layers = m.get_layers() + self.assertIsInstance(layers, dict) + + def test_get_tile_layers_empty(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + layers = m.get_tile_layers() + self.assertIsInstance(layers, dict) + + def test_get_data_layers_empty(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + layers = m.get_data_layers() + self.assertIsInstance(layers, dict) + + def test_clear_layers(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.clear_layers() + + def test_clear_layers_with_basemap(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + m.clear_layers(clear_basemap=True) + + def test_find_layer_index_not_found(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + index = m.find_layer_index("nonexistent_layer") + self.assertIsNone(index) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddHeatmap(PlotlymapTestCase): + + def test_add_heatmap_dataframe(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + df = pd.DataFrame({ + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5] + }) + m.add_heatmap(df) + + def test_add_heatmap_invalid_data_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(ValueError): + m.add_heatmap({"invalid": "data"}) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddEeLayer(PlotlymapTestCase): + + def test_add_ee_layer_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(AttributeError): + m.add_ee_layer("not_an_ee_object") + + def test_addlayer_alias(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + self.assertEqual(m.addLayer, m.add_ee_layer) + + +@unittest.skipUnless(PLOTLY_AVAILABLE, "plotly not available") +class TestAddGdf(PlotlymapTestCase): + + def test_add_gdf_invalid_type_raises(self) -> None: + with mock.patch("geemap.coreutils.ee_initialize"): + m = self.plotlymap.Map(ee_initialize=False) + with self.assertRaises(Exception): + m.add_gdf(12345) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_timelapse.py b/tests/test_timelapse.py new file mode 100644 index 0000000000..69a78b8c2e --- /dev/null +++ b/tests/test_timelapse.py @@ -0,0 +1,620 @@ +"""Tests for the timelapse module.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest import mock + +from tests.mocks import mock_ee + + +class MockPILImage: + + def __init__(self, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> None: + self._size = size + self._mode = mode + self.n_frames = 5 + + @property + def size(self) -> tuple[int, int]: + return self._size + + def save(self, *args, **kwargs) -> None: + pass + + def convert(self, mode: str) -> "MockPILImage": + return MockPILImage(size=self._size, mode=mode) + + @classmethod + def open(cls, path: str) -> "MockPILImage": + return MockPILImage() + + +class MockImageDraw: + + def __init__(self, image: MockPILImage) -> None: + self.image = image + + def text(self, *args, **kwargs) -> None: + pass + + def rectangle(self, *args, **kwargs) -> None: + pass + + +class MockImageFont: + + @staticmethod + def truetype(font: str, size: int) -> "MockImageFont": + return MockImageFont() + + +class MockImageSequence: + + class Iterator: + def __init__(self, image: MockPILImage) -> None: + self.image = image + self.index = 0 + + def __iter__(self): + return self + + def __next__(self): + if self.index < self.image.n_frames: + self.index += 1 + return self.image + raise StopIteration + + +class TestMakeGif(unittest.TestCase): + + def test_make_gif_invalid_images_type_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(ValueError): + timelapse.make_gif(12345, "out.gif") + + def test_make_gif_empty_directory_raises(self) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(ValueError): + timelapse.make_gif(tmpdir, "out.gif") + + @mock.patch("geemap.timelapse.Image") + def test_make_gif_from_directory(self, mock_image: mock.Mock) -> None: + from geemap import timelapse + + mock_frame = mock.MagicMock() + mock_image.open.return_value = mock_frame + + with tempfile.TemporaryDirectory() as tmpdir: + for i in range(3): + with open(os.path.join(tmpdir, f"frame{i}.jpg"), "w") as f: + f.write("test") + + out_gif = os.path.join(tmpdir, "out.gif") + timelapse.make_gif(tmpdir, out_gif, ext="jpg") + + self.assertTrue(mock_image.open.called) + + @mock.patch("geemap.timelapse.Image") + def test_make_gif_from_list(self, mock_image: mock.Mock) -> None: + from geemap import timelapse + + mock_frame = mock.MagicMock() + mock_image.open.return_value = mock_frame + + with tempfile.TemporaryDirectory() as tmpdir: + images = [] + for i in range(3): + path = os.path.join(tmpdir, f"frame{i}.jpg") + with open(path, "w") as f: + f.write("test") + images.append(path) + + out_gif = os.path.join(tmpdir, "out.gif") + timelapse.make_gif(images, out_gif) + + self.assertTrue(mock_image.open.called) + + +class TestGifToMp4(unittest.TestCase): + + def test_gif_to_mp4_missing_file_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(FileNotFoundError): + timelapse.gif_to_mp4("/nonexistent/file.gif", "out.mp4") + + @mock.patch("geemap.timelapse.is_tool", return_value=False) + @mock.patch("geemap.timelapse.Image") + def test_gif_to_mp4_no_ffmpeg( + self, mock_image: mock.Mock, mock_is_tool: mock.Mock + ) -> None: + from geemap import timelapse + + mock_image.open.return_value = MockPILImage() + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test.gif") + with open(in_gif, "w") as f: + f.write("test") + out_mp4 = os.path.join(tmpdir, "test.mp4") + + timelapse.gif_to_mp4(in_gif, out_mp4) + + mock_is_tool.assert_called_with("ffmpeg") + + +class TestMergeGifs(unittest.TestCase): + + def test_merge_gifs_invalid_type_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.merge_gifs(12345, "out.gif") + + @mock.patch("os.system") + def test_merge_gifs_from_list(self, mock_system: mock.Mock) -> None: + from geemap import timelapse + + in_gifs = ["gif1.gif", "gif2.gif"] + out_gif = "merged.gif" + + timelapse.merge_gifs(in_gifs, out_gif) + + mock_system.assert_called_once() + call_args = mock_system.call_args[0][0] + self.assertIn("gifsicle", call_args) + + @mock.patch("os.system") + @mock.patch("glob.glob", return_value=["gif1.gif", "gif2.gif"]) + def test_merge_gifs_from_directory( + self, mock_glob: mock.Mock, mock_system: mock.Mock + ) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + timelapse.merge_gifs(tmpdir, "merged.gif") + + mock_glob.assert_called_once() + + +class TestGifToPng(unittest.TestCase): + + def test_gif_to_png_missing_file_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(FileNotFoundError): + timelapse.gif_to_png("/nonexistent/file.gif") + + def test_gif_to_png_spaces_in_path_raises(self) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test file.gif") + with open(in_gif, "w") as f: + f.write("test") + + with self.assertRaises(Exception): + timelapse.gif_to_png(in_gif) + + def test_gif_to_png_invalid_out_dir_raises(self) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test.gif") + with open(in_gif, "w") as f: + f.write("test") + + with self.assertRaises(Exception): + timelapse.gif_to_png(in_gif, out_dir=12345) + + +class TestGifFading(unittest.TestCase): + + def test_gif_fading_invalid_input_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.gif_fading("not_a_gif.txt", "out.gif") + + def test_gif_fading_missing_file_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(FileNotFoundError): + timelapse.gif_fading("/nonexistent/file.gif", "out.gif") + + def test_gif_fading_spaces_in_path_raises(self) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test file.gif") + with open(in_gif, "w") as f: + f.write("test") + + with self.assertRaises(Exception): + timelapse.gif_fading(in_gif, "out.gif") + + +class TestAddTextToGif(unittest.TestCase): + + def test_add_text_to_gif_missing_file_returns(self) -> None: + from geemap import timelapse + + result = timelapse.add_text_to_gif("/nonexistent.gif", "out.gif") + + self.assertIsNone(result) + + @mock.patch("geemap.timelapse.Image") + @mock.patch("geemap.timelapse.ImageDraw") + @mock.patch("geemap.timelapse.ImageFont") + @mock.patch("geemap.timelapse.ImageSequence") + def test_add_text_to_gif_invalid_xy_tuple_returns( + self, + mock_seq: mock.Mock, + mock_font: mock.Mock, + mock_draw: mock.Mock, + mock_image: mock.Mock, + ) -> None: + from geemap import timelapse + + mock_img = MockPILImage() + mock_image.open.return_value = mock_img + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test.gif") + with open(in_gif, "w") as f: + f.write("test") + out_gif = os.path.join(tmpdir, "out.gif") + + result = timelapse.add_text_to_gif(in_gif, out_gif, xy="invalid") + + self.assertIsNone(result) + + +class TestReduceGifSize(unittest.TestCase): + + @mock.patch("geemap.timelapse.is_tool", return_value=False) + def test_reduce_gif_size_no_gifsicle_raises( + self, mock_is_tool: mock.Mock + ) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.reduce_gif_size("test.gif") + + +class TestValidRoi(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + @mock.patch("geemap.timelapse.ee_to_geojson") + @mock.patch("geemap.timelapse.adjust_longitude") + def test_valid_roi_with_geometry( + self, mock_adjust: mock.Mock, mock_to_geojson: mock.Mock + ) -> None: + from geemap import timelapse + + mock_to_geojson.return_value = {"type": "Polygon", "coordinates": []} + mock_adjust.return_value = {"type": "Polygon", "coordinates": []} + + roi = mock_ee.Geometry.Polygon() + result = timelapse.valid_roi(roi) + + self.assertIsNotNone(result) + + @mock.patch("geemap.timelapse.ee", mock_ee) + @mock.patch("geemap.timelapse.ee_to_geojson") + @mock.patch("geemap.timelapse.adjust_longitude") + def test_valid_roi_with_feature_collection( + self, mock_adjust: mock.Mock, mock_to_geojson: mock.Mock + ) -> None: + from geemap import timelapse + + mock_to_geojson.return_value = {"type": "Polygon", "coordinates": []} + mock_adjust.return_value = {"type": "Polygon", "coordinates": []} + + fc = mock_ee.FeatureCollection() + result = timelapse.valid_roi(fc) + + self.assertIsNotNone(result) + + +class TestSentinel1Defaults(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_sentinel1_defaults_returns_tuple(self) -> None: + from geemap import timelapse + + result = timelapse.sentinel1_defaults() + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 2) + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_sentinel1_defaults_year_is_current(self) -> None: + import datetime + + from geemap import timelapse + + year, _ = timelapse.sentinel1_defaults() + + self.assertEqual(year, datetime.date.today().year) + + +class TestCreateTimeseries(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + @mock.patch("geemap.timelapse.date_sequence") + def test_create_timeseries_with_string_collection( + self, mock_date_seq: mock.Mock + ) -> None: + from geemap import timelapse + + mock_dates = mock_ee.List(["2020-01-01", "2020-02-01"]) + mock_dates.map = mock.MagicMock(return_value=mock_ee.ImageCollection()) + mock_date_seq.return_value = mock_dates + + result = timelapse.create_timeseries( + "LANDSAT/LC08/C02/T1_L2", + "2020-01-01", + "2020-12-31", + ) + + self.assertIsNotNone(result) + + def test_create_timeseries_invalid_collection_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.create_timeseries(12345, "2020-01-01", "2020-12-31") + + @mock.patch("geemap.timelapse.ee", mock_ee) + @mock.patch("geemap.timelapse.date_sequence") + def test_create_timeseries_with_bands( + self, mock_date_seq: mock.Mock + ) -> None: + from geemap import timelapse + + mock_dates = mock_ee.List(["2020-01-01"]) + mock_dates.map = mock.MagicMock(return_value=mock_ee.ImageCollection()) + mock_date_seq.return_value = mock_dates + + collection = mock_ee.ImageCollection() + result = timelapse.create_timeseries( + collection, + "2020-01-01", + "2020-12-31", + bands=["B1", "B2"], + ) + + self.assertIsNotNone(result) + + +class TestSentinel1Filtering(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_sentinel1_filtering_default_band(self) -> None: + from geemap import timelapse + + collection = mock_ee.ImageCollection() + result = timelapse.sentinel1_filtering(collection) + + self.assertIsNotNone(result) + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_sentinel1_filtering_custom_band(self) -> None: + from geemap import timelapse + + collection = mock_ee.ImageCollection() + result = timelapse.sentinel1_filtering(collection, band="VH") + + self.assertIsNotNone(result) + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_sentinel1_filtering_with_orbit(self) -> None: + from geemap import timelapse + + collection = mock_ee.ImageCollection() + result = timelapse.sentinel1_filtering( + collection, orbitProperties_pass="ASCENDING" + ) + + self.assertIsNotNone(result) + + +class TestAddOverlay(unittest.TestCase): + + def test_add_overlay_invalid_collection_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.add_overlay("not_a_collection", "overlay") + + +class TestAddProgressBarToGif(unittest.TestCase): + + def test_add_progress_bar_to_gif_missing_file_returns(self) -> None: + from geemap import timelapse + + result = timelapse.add_progress_bar_to_gif("/nonexistent.gif", "out.gif") + + self.assertIsNone(result) + + +class TestDrawMarkers(unittest.TestCase): + + def test_draw_cross_marker(self) -> None: + from geemap import timelapse + + mock_draw = mock.MagicMock() + timelapse.draw_cross_marker(mock_draw, 50, 50, 10, "red") + + self.assertTrue(mock_draw.line.called) + + def test_draw_circle_marker(self) -> None: + from geemap import timelapse + + mock_draw = mock.MagicMock() + timelapse.draw_circle_marker(mock_draw, 50, 50, 10, "blue") + + self.assertTrue(mock_draw.ellipse.called) + + def test_draw_square_marker(self) -> None: + from geemap import timelapse + + mock_draw = mock.MagicMock() + timelapse.draw_square_marker(mock_draw, 50, 50, 10, "green") + + self.assertTrue(mock_draw.rectangle.called) + + +class TestGetPixelCoordinates(unittest.TestCase): + + def test_get_pixel_coordinates_from_geo(self) -> None: + from geemap import timelapse + + roi_bounds = [-115.5, 35.9, -114.3, 36.4] + x, y = timelapse.get_pixel_coordinates_from_geo( + lon=-115.0, + lat=36.1, + roi_bounds=roi_bounds, + gif_width=800, + gif_height=600, + ) + + self.assertIsInstance(x, int) + self.assertIsInstance(y, int) + self.assertGreaterEqual(x, 0) + self.assertLess(x, 800) + self.assertGreaterEqual(y, 0) + self.assertLess(y, 600) + + +class TestCalculateIndices(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_calculate_sentinel2_indices(self) -> None: + from geemap import timelapse + + image = mock_ee.Image() + result = timelapse.calculate_sentinel2_indices(image) + + self.assertIsInstance(result, mock_ee.Image) + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_calculate_landsat_indices(self) -> None: + from geemap import timelapse + + image = mock_ee.Image() + result = timelapse.calculate_landsat_indices(image) + + self.assertIsInstance(result, mock_ee.Image) + + +class TestGetVisParams(unittest.TestCase): + + def test_get_default_index_vis_params(self) -> None: + from geemap import timelapse + + result = timelapse.get_default_index_vis_params() + + self.assertIsInstance(result, dict) + self.assertIn("NDVI", result) + + def test_get_index_chart_labels(self) -> None: + from geemap import timelapse + + result = timelapse.get_index_chart_labels() + + self.assertIsInstance(result, dict) + + def test_get_default_landsat_index_vis_params(self) -> None: + from geemap import timelapse + + result = timelapse.get_default_landsat_index_vis_params() + + self.assertIsInstance(result, dict) + + def test_get_default_landsat_band_labels(self) -> None: + from geemap import timelapse + + result = timelapse.get_default_landsat_band_labels() + + self.assertIsInstance(result, dict) + + def test_get_landsat_index_chart_labels(self) -> None: + from geemap import timelapse + + result = timelapse.get_landsat_index_chart_labels() + + self.assertIsInstance(result, dict) + + +class TestAddImageToGif(unittest.TestCase): + + def test_add_image_to_gif_missing_gif_returns(self) -> None: + from geemap import timelapse + + result = timelapse.add_image_to_gif("/nonexistent.gif", "/image.png", "out.gif") + + self.assertIsNone(result) + + def test_add_image_to_gif_missing_image_returns(self) -> None: + from geemap import timelapse + + with tempfile.TemporaryDirectory() as tmpdir: + in_gif = os.path.join(tmpdir, "test.gif") + with open(in_gif, "w") as f: + f.write("test") + + result = timelapse.add_image_to_gif( + in_gif, "/nonexistent_image.png", "out.gif" + ) + + self.assertIsNone(result) + + +class TestGoesTimeseries(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_goes_timeseries_invalid_data_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(ValueError): + timelapse.goes_timeseries( + start_date="2020-01-01T14:00:00", + end_date="2020-01-02T01:00:00", + data="GOES-99", + ) + + +class TestModisNdviDoyTs(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_modis_ndvi_doy_ts_invalid_data_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(Exception): + timelapse.modis_ndvi_doy_ts(data="Invalid") + + +class TestGoesFireTimeseries(unittest.TestCase): + + @mock.patch("geemap.timelapse.ee", mock_ee) + def test_goes_fire_timeseries_invalid_data_raises(self) -> None: + from geemap import timelapse + + with self.assertRaises(ValueError): + timelapse.goes_fire_timeseries( + start_date="2020-01-01T14:00:00", + end_date="2020-01-02T01:00:00", + data="GOES-99", + ) + + +if __name__ == "__main__": + unittest.main() From 3b34c14af27a48cf6f363fcadf1fb9c13ec09c26 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:22:54 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/helpers/__init__.py | 17 ++++++---- tests/helpers/assertions.py | 8 ++--- tests/helpers/factories.py | 12 ++++--- tests/mocks/__init__.py | 36 ++++++++++++++------ tests/mocks/mock_ee.py | 12 +++++-- tests/mocks/mock_map.py | 4 ++- tests/mocks/mock_osmnx.py | 4 ++- tests/test_deck.py | 7 ++-- tests/test_foliumap.py | 9 ++++- tests/test_infrastructure.py | 3 +- tests/test_kepler.py | 27 +++++++++------ tests/test_maplibregl.py | 5 ++- tests/test_ml.py | 28 +++++++-------- tests/test_osm.py | 56 ++++++++++-------------------- tests/test_plot.py | 66 +++++++++++++++++------------------- tests/test_plotlymap.py | 17 ++++++---- tests/test_timelapse.py | 8 ++--- 17 files changed, 169 insertions(+), 150 deletions(-) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index c098aaaca9..ffb8681a47 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,11 +1,16 @@ from __future__ import annotations -from tests.helpers.assertions import (assert_file_created, - assert_valid_geojson, - assert_valid_hex_color) -from tests.helpers.factories import (create_ee_feature_collection, - create_ee_geometry, create_ee_image, - create_sample_dataframe) +from tests.helpers.assertions import ( + assert_file_created, + assert_valid_geojson, + assert_valid_hex_color, +) +from tests.helpers.factories import ( + create_ee_feature_collection, + create_ee_geometry, + create_ee_image, + create_sample_dataframe, +) __all__ = [ "assert_file_created", diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py index 1dd42ad289..be1445fa0f 100644 --- a/tests/helpers/assertions.py +++ b/tests/helpers/assertions.py @@ -50,12 +50,8 @@ def assert_valid_rgb_color( ) -> None: test_case.assertEqual(len(color), 3, "RGB color must have 3 components") for i, component in enumerate(color): - test_case.assertGreaterEqual( - component, 0, f"RGB component {i} must be >= 0" - ) - test_case.assertLessEqual( - component, 255, f"RGB component {i} must be <= 255" - ) + test_case.assertGreaterEqual(component, 0, f"RGB component {i} must be >= 0") + test_case.assertLessEqual(component, 255, f"RGB component {i} must be <= 255") def assert_valid_bbox( diff --git a/tests/helpers/factories.py b/tests/helpers/factories.py index 3dbb403593..2d1496704e 100644 --- a/tests/helpers/factories.py +++ b/tests/helpers/factories.py @@ -100,10 +100,12 @@ def create_sample_geojson( else: coordinates = [i, i] - features.append({ - "type": "Feature", - "properties": {"id": i, "name": f"feature_{i}"}, - "geometry": {"type": geom_type, "coordinates": coordinates}, - }) + features.append( + { + "type": "Feature", + "properties": {"id": i, "name": f"feature_{i}"}, + "geometry": {"type": geom_type, "coordinates": coordinates}, + } + ) return {"type": "FeatureCollection", "features": features} diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py index 170791f0b9..891691f8dd 100644 --- a/tests/mocks/__init__.py +++ b/tests/mocks/__init__.py @@ -1,17 +1,31 @@ from __future__ import annotations -from tests.mocks.mock_ee import (Algorithms, Dictionary, Feature, - FeatureCollection, Geometry, Image, - ImageCollection, List, Reducer, String) -from tests.mocks.mock_map import (FakeEeTileLayer, FakeGeoJSONLayer, FakeMap, - FakeTileLayer) -from tests.mocks.mock_osmnx import (MockGeoDataFrame, - mock_features_from_address, - mock_features_from_bbox, - mock_features_from_point) +from tests.mocks.mock_ee import ( + Algorithms, + Dictionary, + Feature, + FeatureCollection, + Geometry, + Image, + ImageCollection, + List, + Reducer, + String, +) +from tests.mocks.mock_map import ( + FakeEeTileLayer, + FakeGeoJSONLayer, + FakeMap, + FakeTileLayer, +) +from tests.mocks.mock_osmnx import ( + MockGeoDataFrame, + mock_features_from_address, + mock_features_from_bbox, + mock_features_from_point, +) from tests.mocks.mock_plotly import MockFigure, MockPlotlyExpress -from tests.mocks.mock_requests import (MockResponse, RequestError, - create_mock_response) +from tests.mocks.mock_requests import MockResponse, RequestError, create_mock_response __all__ = [ "Algorithms", diff --git a/tests/mocks/mock_ee.py b/tests/mocks/mock_ee.py index ba71352f88..ba12483a17 100644 --- a/tests/mocks/mock_ee.py +++ b/tests/mocks/mock_ee.py @@ -30,7 +30,9 @@ def reduceRegion(self, *_, **__) -> "Dictionary": def select(self, *args, **__) -> "Image": if args: - self._bands = list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + self._bands = ( + list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + ) return self def clip(self, *_, **__) -> "Image": @@ -38,7 +40,9 @@ def clip(self, *_, **__) -> "Image": def rename(self, *args, **__) -> "Image": if args: - self._bands = list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + self._bands = ( + list(args[0]) if isinstance(args[0], (list, tuple)) else [args[0]] + ) return self def multiply(self, *_, **__) -> "Image": @@ -311,7 +315,9 @@ def toList(self, *_, **__) -> List: def getInfo(self, *_, **__) -> dict: return { "type": "FeatureCollection", - "features": [f.getInfo() if hasattr(f, "getInfo") else f for f in self.features], + "features": [ + f.getInfo() if hasattr(f, "getInfo") else f for f in self.features + ], } def __eq__(self, other: object) -> bool: diff --git a/tests/mocks/mock_map.py b/tests/mocks/mock_map.py index d9b3cbee05..4b1e770c59 100644 --- a/tests/mocks/mock_map.py +++ b/tests/mocks/mock_map.py @@ -77,7 +77,9 @@ def add_layer( mock_ee.Image, ), ): - layer = FakeEeTileLayer(name=name or "layer", visible=shown, opacity=opacity) + layer = FakeEeTileLayer( + name=name or "layer", visible=shown, opacity=opacity + ) self.ee_layers[name] = { "ee_object": ee_object, "ee_layer": layer, diff --git a/tests/mocks/mock_osmnx.py b/tests/mocks/mock_osmnx.py index e8dbefa72e..ca89911570 100644 --- a/tests/mocks/mock_osmnx.py +++ b/tests/mocks/mock_osmnx.py @@ -64,7 +64,9 @@ def mock_features_from_bbox( ) -> MockGeoDataFrame: west, south, east, north = bbox return MockGeoDataFrame( - geometry=[Polygon([(west, south), (east, south), (east, north), (west, north)])], + geometry=[ + Polygon([(west, south), (east, south), (east, north), (west, north)]) + ], data={"name": ["Test BBox Feature"]}, ) diff --git a/tests/test_deck.py b/tests/test_deck.py index c340d6e9e9..ef5e706f06 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -10,6 +10,7 @@ try: import pydeck as pdk + PYDECK_AVAILABLE = True except ImportError: PYDECK_AVAILABLE = False @@ -27,6 +28,7 @@ def get_deck(): try: with mock.patch("geemap.coreutils.ee_initialize"): from geemap import deck + DECK_MODULE = deck return deck except Exception as e: @@ -50,6 +52,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) def setUp(self) -> None: @@ -68,9 +71,7 @@ def test_layer_init_default(self) -> None: def test_layer_init_with_data(self) -> None: with mock.patch("geemap.coreutils.ee_initialize"): layer = self.deck.Layer( - "ScatterplotLayer", - data=[{"position": [0, 0]}], - id="test_layer" + "ScatterplotLayer", data=[{"position": [0, 0]}], id="test_layer" ) self.assertIsInstance(layer, pdk.Layer) diff --git a/tests/test_foliumap.py b/tests/test_foliumap.py index 97159ff2d3..e6fa079980 100644 --- a/tests/test_foliumap.py +++ b/tests/test_foliumap.py @@ -12,6 +12,7 @@ try: import folium + FOLIUM_AVAILABLE = True except ImportError: FOLIUM_AVAILABLE = False @@ -29,6 +30,7 @@ def get_foliumap(): try: with mock.patch("geemap.coreutils.ee_initialize"): from geemap import foliumap + FOLIUMAP_MODULE = foliumap return foliumap except Exception as e: @@ -52,6 +54,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) def setUp(self) -> None: @@ -240,7 +243,11 @@ def test_add_heatmap_dataframe(self) -> None: with mock.patch("geemap.coreutils.ee_initialize"): m = self.foliumap.Map(ee_initialize=False) df = pd.DataFrame( - {"latitude": [37.8, 37.7], "longitude": [-122.4, -122.3], "value": [1.0, 0.5]} + { + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5], + } ) m.add_heatmap(data=df, name="Test Heatmap") diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py index 6eea510221..19aca23c0e 100644 --- a/tests/test_infrastructure.py +++ b/tests/test_infrastructure.py @@ -7,8 +7,7 @@ from tests.conftest import GeemapTestCase from tests.helpers import assertions, factories -from tests.mocks import (mock_ee, mock_map, mock_osmnx, mock_plotly, - mock_requests) +from tests.mocks import mock_ee, mock_map, mock_osmnx, mock_plotly, mock_requests class TestMockEe(unittest.TestCase): diff --git a/tests/test_kepler.py b/tests/test_kepler.py index 84d05d043f..4af87584cc 100644 --- a/tests/test_kepler.py +++ b/tests/test_kepler.py @@ -12,6 +12,7 @@ try: import keplergl + KEPLERGL_AVAILABLE = True except ImportError: KEPLERGL_AVAILABLE = False @@ -29,6 +30,7 @@ def get_kepler(): try: with mock.patch("geemap.coreutils.ee_initialize"): from geemap import kepler + KEPLER_MODULE = kepler return kepler except Exception as e: @@ -52,6 +54,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) def setUp(self) -> None: @@ -177,11 +180,13 @@ class TestAddDf(KeplerTestCase): def test_add_df_valid(self) -> None: with mock.patch("geemap.coreutils.ee_initialize"): m = self.kepler.Map() - df = pd.DataFrame({ - "latitude": [37.8, 37.7], - "longitude": [-122.4, -122.3], - "value": [1.0, 0.5] - }) + df = pd.DataFrame( + { + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5], + } + ) m.add_df(df, layer_name="Test DataFrame") @@ -190,11 +195,13 @@ class TestAddCsv(KeplerTestCase): def test_add_csv_valid(self) -> None: with mock.patch("geemap.coreutils.ee_initialize"): - df = pd.DataFrame({ - "latitude": [37.8, 37.7], - "longitude": [-122.4, -122.3], - "value": [1.0, 0.5] - }) + df = pd.DataFrame( + { + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5], + } + ) temp_path = os.path.join(self.temp_dir, "test.csv") df.to_csv(temp_path, index=False) diff --git a/tests/test_maplibregl.py b/tests/test_maplibregl.py index 56a4b66f98..9a918da09a 100644 --- a/tests/test_maplibregl.py +++ b/tests/test_maplibregl.py @@ -10,6 +10,7 @@ try: from maplibre.ipywidget import MapWidget + MAPLIBRE_AVAILABLE = True except ImportError: MAPLIBRE_AVAILABLE = False @@ -27,6 +28,7 @@ def get_maplibregl(): try: with mock.patch("geemap.coreutils.ee_initialize"): from geemap import maplibregl + MAPLIBREGL_MODULE = maplibregl return maplibregl except Exception as e: @@ -50,6 +52,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) def setUp(self) -> None: @@ -175,7 +178,7 @@ def test_add_tile_layer_valid(self) -> None: m.add_tile_layer( url="https://tile.example.com/{z}/{x}/{y}.png", name="Test Layer", - attribution="Test" + attribution="Test", ) diff --git a/tests/test_ml.py b/tests/test_ml.py index 3e84df003d..0bb1b3c00c 100644 --- a/tests/test_ml.py +++ b/tests/test_ml.py @@ -29,15 +29,17 @@ def __init__( self.threshold = np.array([0.5, 0.3, -2.0, -2.0, 0.7, -2.0, -2.0]) if is_classifier: - self.value = np.array([ - [[50, 50]], - [[40, 20]], - [[30, 0]], - [[10, 20]], - [[10, 30]], - [[5, 15]], - [[5, 15]], - ]) + self.value = np.array( + [ + [[50, 50]], + [[40, 20]], + [[30, 0]], + [[10, 20]], + [[10, 30]], + [[5, 15]], + [[5, 15]], + ] + ) else: self.value = np.array([0.5, 0.4, 0.3, 0.5, 0.6, 0.7, 0.8]) @@ -113,9 +115,7 @@ def test_tree_to_string_probability_mode(self) -> None: estimator = MockDecisionTreeClassifier() feature_names = ["band1", "band2"] - result = ml.tree_to_string( - estimator, feature_names, output_mode="PROBABILITY" - ) + result = ml.tree_to_string(estimator, feature_names, output_mode="PROBABILITY") self.assertIsInstance(result, str) self.assertIn("root", result) @@ -161,9 +161,7 @@ def test_tree_to_string_multiprobability_raises(self) -> None: feature_names = ["band1", "band2"] with self.assertRaises(NotImplementedError): - ml.tree_to_string( - estimator, feature_names, output_mode="MULTIPROBABILITY" - ) + ml.tree_to_string(estimator, feature_names, output_mode="MULTIPROBABILITY") def test_tree_to_string_invalid_mode_raises(self) -> None: from geemap import ml diff --git a/tests/test_osm.py b/tests/test_osm.py index db785d6403..42ac5655f1 100644 --- a/tests/test_osm.py +++ b/tests/test_osm.py @@ -51,9 +51,7 @@ def test_osm_gdf_from_address_multiple_tags(self) -> None: class TestOsmShpFromAddress(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_address") - def test_osm_shp_from_address_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_address_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -67,7 +65,9 @@ def test_osm_shp_from_address_passes_distance( mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf - osm.osm_shp_from_address("Tokyo", {"building": True}, "/tmp/test.shp", dist=2000) + osm.osm_shp_from_address( + "Tokyo", {"building": True}, "/tmp/test.shp", dist=2000 + ) mock_gdf_func.assert_called_once_with("Tokyo", {"building": True}, 2000) @@ -130,9 +130,7 @@ def test_osm_gdf_from_place_with_which_result(self) -> None: class TestOsmShpFromPlace(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_place") - def test_osm_shp_from_place_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_place_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -152,9 +150,7 @@ def test_osm_geojson_from_place_returns_dict( self.assertIsInstance(result, dict) @mock.patch("geemap.osm.osm_gdf_from_place") - def test_osm_geojson_from_place_writes_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_geojson_from_place_writes_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -196,9 +192,7 @@ def test_osm_gdf_from_point_zero_coords(self) -> None: class TestOsmShpFromPoint(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_point") - def test_osm_shp_from_point_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_point_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -218,9 +212,7 @@ def test_osm_geojson_from_point_returns_dict( self.assertIsInstance(result, dict) @mock.patch("geemap.osm.osm_gdf_from_point") - def test_osm_geojson_from_point_writes_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_geojson_from_point_writes_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -257,9 +249,7 @@ def test_osm_gdf_from_polygon_multiple_tags(self) -> None: class TestOsmShpFromPolygon(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_polygon") - def test_osm_shp_from_polygon_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_polygon_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -318,33 +308,27 @@ def test_osm_gdf_from_bbox_global_bounds(self) -> None: class TestOsmShpFromBbox(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_bbox") - def test_osm_shp_from_bbox_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_bbox_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf - osm.osm_shp_from_bbox(40.8, 40.7, -73.9, -74.0, {"building": True}, "/tmp/test.shp") + osm.osm_shp_from_bbox( + 40.8, 40.7, -73.9, -74.0, {"building": True}, "/tmp/test.shp" + ) mock_gdf.to_file.assert_called_once_with("/tmp/test.shp") class TestOsmGeojsonFromBbox(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_bbox") - def test_osm_geojson_from_bbox_returns_dict( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_geojson_from_bbox_returns_dict(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf_func.return_value = mock_gdf - result = osm.osm_geojson_from_bbox( - 40.8, 40.7, -73.9, -74.0, {"building": True} - ) + result = osm.osm_geojson_from_bbox(40.8, 40.7, -73.9, -74.0, {"building": True}) self.assertIsInstance(result, dict) @mock.patch("geemap.osm.osm_gdf_from_bbox") - def test_osm_geojson_from_bbox_writes_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_geojson_from_bbox_writes_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -421,9 +405,7 @@ def test_osm_gdf_from_geocode_by_osmid(self) -> None: class TestOsmShpFromGeocode(unittest.TestCase): @mock.patch("geemap.osm.osm_gdf_from_geocode") - def test_osm_shp_from_geocode_creates_file( - self, mock_gdf_func: mock.Mock - ) -> None: + def test_osm_shp_from_geocode_creates_file(self, mock_gdf_func: mock.Mock) -> None: mock_gdf = MockGeoDataFrame() mock_gdf.to_file = mock.Mock() mock_gdf_func.return_value = mock_gdf @@ -456,9 +438,7 @@ def test_osm_geojson_from_geocode_writes_file( class TestOsmTagsList(unittest.TestCase): @mock.patch("geemap.osm.webbrowser.open_new_tab") - def test_osm_tags_list_opens_browser( - self, mock_open: mock.Mock - ) -> None: + def test_osm_tags_list_opens_browser(self, mock_open: mock.Mock) -> None: osm.osm_tags_list() mock_open.assert_called_once_with( "https://wiki.openstreetmap.org/wiki/Map_features" diff --git a/tests/test_plot.py b/tests/test_plot.py index 9a4744c545..acb4664b5b 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -21,11 +21,13 @@ def update_layout(self, **kwargs) -> MockFigure: def create_sample_dataframe() -> pd.DataFrame: - return pd.DataFrame({ - "category": ["A", "B", "C", "D", "E"], - "value": [10, 20, 15, 25, 5], - "count": [100, 200, 150, 250, 50], - }) + return pd.DataFrame( + { + "category": ["A", "B", "C", "D", "E"], + "value": [10, 20, 15, 25, 5], + "count": [100, 200, 150, 250, 50], + } + ) class TestBarChart(unittest.TestCase): @@ -78,9 +80,7 @@ def test_bar_chart_layout_args_applied(self, mock_bar: mock.Mock) -> None: @mock.patch("geemap.plot.px.bar") def test_bar_chart_x_label_applied(self, mock_bar: mock.Mock) -> None: mock_bar.return_value = MockFigure() - plot.bar_chart( - data=self.df, x="category", y="value", x_label="Category Label" - ) + plot.bar_chart(data=self.df, x="category", y="value", x_label="Category Label") call_args = mock_bar.call_args labels = call_args[1].get("labels", {}) self.assertEqual(labels.get("category"), "Category Label") @@ -88,9 +88,7 @@ def test_bar_chart_x_label_applied(self, mock_bar: mock.Mock) -> None: @mock.patch("geemap.plot.px.bar") def test_bar_chart_y_label_applied(self, mock_bar: mock.Mock) -> None: mock_bar.return_value = MockFigure() - plot.bar_chart( - data=self.df, x="category", y="value", y_label="Value Label" - ) + plot.bar_chart(data=self.df, x="category", y="value", y_label="Value Label") call_args = mock_bar.call_args labels = call_args[1].get("labels", {}) self.assertEqual(labels.get("value"), "Value Label") @@ -99,9 +97,7 @@ def test_bar_chart_y_label_applied(self, mock_bar: mock.Mock) -> None: def test_bar_chart_legend_title_applied(self, mock_bar: mock.Mock) -> None: mock_fig = MockFigure() mock_bar.return_value = mock_fig - plot.bar_chart( - data=self.df, x="category", y="value", legend_title="My Legend" - ) + plot.bar_chart(data=self.df, x="category", y="value", legend_title="My Legend") legend = mock_fig.layout_updates.get("legend", {}) self.assertEqual(legend.get("title"), "My Legend") @@ -210,9 +206,7 @@ def test_line_chart_labels_applied(self, mock_line: mock.Mock) -> None: def test_line_chart_legend_title_applied(self, mock_line: mock.Mock) -> None: mock_fig = MockFigure() mock_line.return_value = mock_fig - plot.line_chart( - data=self.df, x="category", y="value", legend_title="Legend" - ) + plot.line_chart(data=self.df, x="category", y="value", legend_title="Legend") legend = mock_fig.layout_updates.get("legend", {}) self.assertEqual(legend.get("title"), "Legend") @@ -305,10 +299,12 @@ def test_histogram_none_data_raises(self) -> None: class TestPieChart(unittest.TestCase): def setUp(self) -> None: - self.df = pd.DataFrame({ - "fruit": ["Apple", "Banana", "Cherry", "Date", "Elderberry"], - "quantity": [30, 20, 15, 25, 10], - }) + self.df = pd.DataFrame( + { + "fruit": ["Apple", "Banana", "Cherry", "Date", "Elderberry"], + "quantity": [30, 20, 15, 25, 10], + } + ) @mock.patch("geemap.plot.px.pie") def test_pie_chart_valid_dataframe(self, mock_pie: mock.Mock) -> None: @@ -334,10 +330,12 @@ def test_pie_chart_ascending_order(self, mock_pie: mock.Mock) -> None: @mock.patch("geemap.plot.px.pie") def test_pie_chart_max_rows_with_other(self, mock_pie: mock.Mock) -> None: mock_pie.return_value = MockFigure() - df = pd.DataFrame({ - "fruit": ["A", "B", "C", "D", "E", "F"], - "quantity": [100, 80, 60, 40, 20, 10], - }) + df = pd.DataFrame( + { + "fruit": ["A", "B", "C", "D", "E", "F"], + "quantity": [100, 80, 60, 40, 20, 10], + } + ) plot.pie_chart(data=df, names="fruit", values="quantity", max_rows=4) call_args = mock_pie.call_args data_passed = call_args[1].get("data_frame") @@ -346,10 +344,12 @@ def test_pie_chart_max_rows_with_other(self, mock_pie: mock.Mock) -> None: @mock.patch("geemap.plot.px.pie") def test_pie_chart_custom_other_label(self, mock_pie: mock.Mock) -> None: mock_pie.return_value = MockFigure() - df = pd.DataFrame({ - "fruit": ["A", "B", "C", "D", "E"], - "quantity": [100, 80, 60, 40, 20], - }) + df = pd.DataFrame( + { + "fruit": ["A", "B", "C", "D", "E"], + "quantity": [100, 80, 60, 40, 20], + } + ) plot.pie_chart( data=df, names="fruit", @@ -364,9 +364,7 @@ def test_pie_chart_custom_other_label(self, mock_pie: mock.Mock) -> None: @mock.patch("geemap.plot.px.pie") def test_pie_chart_donut_hole(self, mock_pie: mock.Mock) -> None: mock_pie.return_value = MockFigure() - plot.pie_chart( - data=self.df, names="fruit", values="quantity", hole=0.4 - ) + plot.pie_chart(data=self.df, names="fruit", values="quantity", hole=0.4) call_args = mock_pie.call_args self.assertEqual(call_args[1].get("hole"), 0.4) @@ -407,9 +405,7 @@ def test_pie_chart_title_passed(self, mock_pie: mock.Mock) -> None: @mock.patch("geemap.plot.px.pie") def test_pie_chart_opacity_passed(self, mock_pie: mock.Mock) -> None: mock_pie.return_value = MockFigure() - plot.pie_chart( - data=self.df, names="fruit", values="quantity", opacity=0.8 - ) + plot.pie_chart(data=self.df, names="fruit", values="quantity", opacity=0.8) call_args = mock_pie.call_args self.assertEqual(call_args[1].get("opacity"), 0.8) diff --git a/tests/test_plotlymap.py b/tests/test_plotlymap.py index bcd9e98f6b..bd796f5080 100644 --- a/tests/test_plotlymap.py +++ b/tests/test_plotlymap.py @@ -11,6 +11,7 @@ try: import plotly.graph_objects as go + PLOTLY_AVAILABLE = True except ImportError: PLOTLY_AVAILABLE = False @@ -28,6 +29,7 @@ def get_plotlymap(): try: with mock.patch("geemap.coreutils.ee_initialize"): from geemap import plotlymap + PLOTLYMAP_MODULE = plotlymap return plotlymap except Exception as e: @@ -51,6 +53,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: import shutil + shutil.rmtree(cls.temp_dir, ignore_errors=True) def setUp(self) -> None: @@ -156,7 +159,7 @@ def test_add_tile_layer_valid(self) -> None: m.add_tile_layer( url="https://tile.example.com/{z}/{x}/{y}.png", name="Test Layer", - attribution="Test" + attribution="Test", ) @@ -214,11 +217,13 @@ class TestAddHeatmap(PlotlymapTestCase): def test_add_heatmap_dataframe(self) -> None: with mock.patch("geemap.coreutils.ee_initialize"): m = self.plotlymap.Map(ee_initialize=False) - df = pd.DataFrame({ - "latitude": [37.8, 37.7], - "longitude": [-122.4, -122.3], - "value": [1.0, 0.5] - }) + df = pd.DataFrame( + { + "latitude": [37.8, 37.7], + "longitude": [-122.4, -122.3], + "value": [1.0, 0.5], + } + ) m.add_heatmap(df) def test_add_heatmap_invalid_data_raises(self) -> None: diff --git a/tests/test_timelapse.py b/tests/test_timelapse.py index 69a78b8c2e..38903bf0b0 100644 --- a/tests/test_timelapse.py +++ b/tests/test_timelapse.py @@ -279,9 +279,7 @@ def test_add_text_to_gif_invalid_xy_tuple_returns( class TestReduceGifSize(unittest.TestCase): @mock.patch("geemap.timelapse.is_tool", return_value=False) - def test_reduce_gif_size_no_gifsicle_raises( - self, mock_is_tool: mock.Mock - ) -> None: + def test_reduce_gif_size_no_gifsicle_raises(self, mock_is_tool: mock.Mock) -> None: from geemap import timelapse with self.assertRaises(Exception): @@ -374,9 +372,7 @@ def test_create_timeseries_invalid_collection_raises(self) -> None: @mock.patch("geemap.timelapse.ee", mock_ee) @mock.patch("geemap.timelapse.date_sequence") - def test_create_timeseries_with_bands( - self, mock_date_seq: mock.Mock - ) -> None: + def test_create_timeseries_with_bands(self, mock_date_seq: mock.Mock) -> None: from geemap import timelapse mock_dates = mock_ee.List(["2020-01-01"])