diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..08ccf6d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ + +name: Testing + +on: + pull_request: + +jobs: + test_3_16: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run test 3.16 + run: | + docker run -v ${GITHUB_WORKSPACE}:/src -w /src qgis/qgis:release-3_16 sh -c 'apt-get -y update && apt-get -y install xvfb && export ORS_API_KEY=${{ secrets.ORS_API_KEY }} && export DISPLAY=:0.0 && pip install -U pytest && xvfb-run pytest' + env: + DOCKER_IMAGE: ${{ steps.docker-build.outputs.FULL_IMAGE_NAME }} + test_3_22: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run test 3.22 + run: | + docker run -v ${GITHUB_WORKSPACE}:/src -w /src qgis/qgis:release-3_22 sh -c 'apt-get -y update && apt-get -y install xvfb && export DISPLAY=:0.0 && export ORS_API_KEY=${{ secrets.ORS_API_KEY }} && export DISPLAY=:0.0 && pip install -U pytest && xvfb-run pytest' + env: + DOCKER_IMAGE: ${{ steps.docker-build.outputs.FULL_IMAGE_NAME }} + test_latest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run test latest + run: | + docker run -v ${GITHUB_WORKSPACE}:/src -w /src qgis/qgis:latest sh -c 'apt-get -y update && apt-get -y install xvfb && export DISPLAY=:0.0 && export ORS_API_KEY=${{ secrets.ORS_API_KEY }} && apt install python3-pytest && xvfb-run pytest' + env: + DOCKER_IMAGE: ${{ steps.docker-build.outputs.FULL_IMAGE_NAME }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 93df682d..07219bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ RELEASING: - Improved type hints +# Unreleased +### Added +- Unit- and e2e-testing + ## [1.7.1] - 2024-01-15 ### Added diff --git a/ORStools/common/directions_core.py b/ORStools/common/directions_core.py index 705abdea..06c530a9 100644 --- a/ORStools/common/directions_core.py +++ b/ORStools/common/directions_core.py @@ -264,7 +264,7 @@ def build_default_parameters( def get_extra_info_features_directions( - response: dict, extra_info_order: list[str], to_from_values: Optional[list] = None + response: dict, extra_info_order: List[str], to_from_values: Optional[list] = None ): extra_info_order = [ key if key != "waytype" else "waytypes" for key in extra_info_order diff --git a/ORStools/gui/ORStoolsDialog.py b/ORStools/gui/ORStoolsDialog.py index 49f6a445..95cefdb5 100644 --- a/ORStools/gui/ORStoolsDialog.py +++ b/ORStools/gui/ORStoolsDialog.py @@ -31,7 +31,11 @@ import os from typing import Optional -import processing +try: + import processing +except ModuleNotFoundError: + pass + import webbrowser from qgis._core import Qgis, QgsAnnotation diff --git a/README.md b/README.md index 90f21505..9a9d2bdb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ORS Tools QGIS plugin +![Testing](https://github.com/Merydian/orstools-qgis-plugin/actions/workflows/test.yml/badge.svg) +![Ruff](https://github.com/Merydian/orstools-qgis-plugin/actions/workflows/ruff.yml/badge.svg) + ![ORS Tools](https://user-images.githubusercontent.com/23240110/122937401-3ee72400-d372-11eb-8e3b-6c435d1dd964.png) Set of tools for QGIS to use the [openrouteservice](https://openrouteservice.org) (ORS) API. @@ -120,6 +123,65 @@ where `` is one of: - Windows: `C:\Users\USER\AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins\ORStools` - Mac OS: `Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins/ORStools` +### CI +#### Testing +The repository tests on the QGis Versions *3.16*, *3.22* and the *latest* version. +Until now, it's only possible to test one version at a time. + +#### Linux +On linux machines you can run the tests with your local QGIS installation. + +1. Install QGIS and make sure it's available in your currently activated environment. + +You will need an ORS-API key. Either set it as an environment variable or do `export ORS_API_KEY=[Your API key here]` before you run the tests. + +To run the tests do: +```shell +cd orstools-qgis-plugin +pytest +``` + +#### Windows +Do all the following steps in a [*WSL*](https://learn.microsoft.com/en-us/windows/wsl/install). To run tests locally you can use a [conda installation](https://github.com/opengisch/qgis-conda-builder) of the QGis version you want to test. +You will also have to install *xvfb* to run the tests on involving an interface. +Lastly, we need [*Pytest*](https://docs.pytest.org/en/8.0.x/) to run tests in general. + +To do the above run use these commands: +1. Install a version of anaconda, preferrably [*miniforge*](https://github.com/conda-forge/miniforge). + +2. Create and prepare the environment. + +```shell +# create environment +conda create --name qgis_test +# activate environment +conda activate qgis_test +# install pip +conda install pip +``` + +3. Install QGis using mamba. +```shell +conda install -c conda-forge qgis=[3.16, 3.22, latest] # choose one +``` + +4. Install *xvfb* +```shell +sudo apt-get update +sudo apt install xvfb +``` + +5. Install *Pytest* using pip in testing environment. +```shell +pip install -U pytest +``` + +To run the tests you will need an ORS-API key: +```shell +cd orstools-qgis-plugin +export ORS_API_KEY=[Your API key here] && xvfb-run pytest +``` + ### Debugging In the **PyCharm community edition** you will have to use logging and printing to inspect elements. The First Aid QGIS plugin can probably also be used additionally. diff --git a/requirements.txt b/requirements.txt index af3ee576..ca4e9990 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,7 @@ +# developement ruff +pytest + +# testing +pyyaml +pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d39efd01 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +import os +import yaml + +from ORStools.utils.configmanager import read_config + +with open("ORStools/config.yml", "r+") as file: + data = yaml.safe_load(file) + + +def pytest_sessionstart(session): + """ + Called after the Session object has been created and + before performing collection and entering the run test loop. + """ + if data["providers"][0]["key"] == "": + data["providers"][0]["key"] = os.environ.get("ORS_API_KEY") + with open("ORStools/config.yml", "w") as file: + yaml.dump(data, file) + else: + raise ValueError("API key is not empty.") + + +def pytest_sessionfinish(session, exitstatus): + """ + Called after whole test run finished, right before + returning the exit status to the system. + """ + with open("ORStools/config.yml", "w") as file: + if not data["providers"][0]["key"] == "": + data['providers'][0]['key'] = '' # fmt: skip + yaml.dump(data, file) + + config = read_config() + assert config["providers"][0]["key"] == '' # fmt: skip diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000..2061f097 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,380 @@ +from qgis.core import QgsPointXY +from qgis.testing import unittest + +from ORStools.common import client, directions_core, isochrones_core +import os + + +class TestCommon(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api_key = os.environ.get("ORS_API_KEY") + if cls.api_key is None: + raise ValueError("ORS_API_KEY environment variable is not set") + + def test_client_request_geometry(self): + test_response = { + "type": "FeatureCollection", + "metadata": { + "id": "1", + "attribution": "openrouteservice.org | OpenStreetMap contributors", + "service": "routing", + "timestamp": 1708505372024, + "query": { + "coordinates": [[8.684101, 50.131613], [8.68534, 50.131651]], + "profile": "driving-car", + "id": "1", + "preference": "fastest", + "format": "geojson", + "geometry": True, + "elevation": True, + }, + "engine": { + "version": "7.1.1", + "build_date": "2024-01-29T14:41:12Z", + "graph_date": "2024-02-18T14:05:28Z", + }, + "system_message": "Preference 'fastest' has been deprecated, using 'recommended'.", + }, + "bbox": [8.684088, 50.131187, 131.0, 8.686212, 50.131663, 133.8], + "features": [ + { + "bbox": [8.684088, 50.131187, 131.0, 8.686212, 50.131663, 133.8], + "type": "Feature", + "properties": { + "ascent": 2.8, + "descent": 0.0, + "transfers": 0, + "fare": 0, + "way_points": [0, 13], + "summary": {"distance": 247.2, "duration": 45.1}, + }, + "geometry": { + "coordinates": [ + [8.684088, 50.131587, 131.0], + [8.684173, 50.13157, 131.0], + [8.684413, 50.131523, 131.0], + [8.684872, 50.131432, 131.0], + [8.685652, 50.131272, 132.1], + [8.685937, 50.131187, 132.7], + [8.686097, 50.131227, 132.9], + [8.686204, 50.131325, 133.1], + [8.686212, 50.13143, 133.3], + [8.686184, 50.13148, 133.4], + [8.68599, 50.131544, 133.6], + [8.685774, 50.131612, 133.7], + [8.685559, 50.131663, 133.7], + [8.68534, 50.13166, 133.8], + ], + "type": "LineString", + }, + } + ], + } + + provider = { + "ENV_VARS": { + "ORS_QUOTA": "X-Ratelimit-Limit", + "ORS_REMAINING": "X-Ratelimit-Remaining", + }, + "base_url": "https://api.openrouteservice.org", + "key": self.api_key, + "name": "openrouteservice", + "timeout": 60, + } + + params = { + "preference": "fastest", + "geometry": "true", + "instructions": "false", + "elevation": True, + "id": 1, + "coordinates": [[8.684101, 50.131613], [8.68534, 50.131651]], + } + agent = "QGIS_ORStools_testing" + profile = "driving-car" + clnt = client.Client(provider, agent) + response = clnt.request("/v2/directions/" + profile + "/geojson", {}, post_json=params) + self.assertAlmostEqual( + response["features"][0]["geometry"]["coordinates"][0][0], + test_response["features"][0]["geometry"]["coordinates"][0][0], + ) + + def test_output_feature_directions(self): + response = { + "type": "FeatureCollection", + "metadata": { + "id": "1", + "attribution": "openrouteservice.org | OpenStreetMap contributors", + "service": "routing", + "timestamp": 1708522371289, + "query": { + "coordinates": [ + [-68.199488, -16.518187], + [-68.199201, -16.517873], + [-68.198438, -16.518486], + [-68.198067, -16.518183], + ], + "profile": "driving-car", + "id": "1", + "preference": "fastest", + "format": "geojson", + "geometry": True, + "elevation": True, + }, + "engine": { + "version": "7.1.1", + "build_date": "2024-01-29T14:41:12Z", + "graph_date": "2024-02-18T14:05:28Z", + }, + "system_message": "Preference 'fastest' has been deprecated, using 'recommended'.", + }, + "bbox": [-68.199495, -16.518504, 4025.0, -68.198061, -16.51782, 4025.07], + "features": [ + { + "bbox": [-68.199495, -16.518504, 4025.0, -68.198061, -16.51782, 4025.07], + "type": "Feature", + "properties": { + "ascent": 0.1, + "descent": 0.0, + "transfers": 0, + "fare": 0, + "way_points": [0, 2, 6, 9], + "summary": {"distance": 222.4, "duration": 53.0}, + }, + "geometry": { + "coordinates": [ + [-68.199495, -16.518181, 4025.0], + [-68.199485, -16.51817, 4025.0], + [-68.199206, -16.517869, 4025.0], + [-68.199161, -16.51782, 4025.0], + [-68.198799, -16.518142, 4025.0], + [-68.198393, -16.518478, 4025.0], + [-68.198417, -16.518504, 4025.0], + [-68.198393, -16.518478, 4025.0], + [-68.198078, -16.518162, 4025.0], + [-68.198061, -16.518177, 4025.1], + ], + "type": "LineString", + }, + } + ], + } + profile = "driving-car" + preference = "fastest" + feature = directions_core.get_output_feature_directions(response, profile, preference) + coordinates = [(vertex.x(), vertex.y()) for vertex in feature.geometry().vertices()] + test_coords = [ + (-68.199495, -16.518181), + (-68.199485, -16.51817), + (-68.199206, -16.517869), + (-68.199161, -16.51782), + (-68.198799, -16.518142), + (-68.198393, -16.518478), + (-68.198417, -16.518504), + (-68.198393, -16.518478), + (-68.198078, -16.518162), + (-68.198061, -16.518177), + ] + + self.assertAlmostEqual(coordinates, test_coords) + + def test_output_features_optimization(self): + response = { + "code": 0, + "summary": { + "cost": 36, + "routes": 1, + "unassigned": 0, + "setup": 0, + "service": 0, + "duration": 36, + "waiting_time": 0, + "priority": 0, + "distance": 152, + "violations": [], + "computing_times": {"loading": 23, "solving": 0, "routing": 12}, + }, + "unassigned": [], + "routes": [ + { + "vehicle": 0, + "cost": 36, + "setup": 0, + "service": 0, + "duration": 36, + "waiting_time": 0, + "priority": 0, + "distance": 152, + "steps": [ + { + "type": "start", + "location": [-68.193407, -16.472978], + "setup": 0, + "service": 0, + "waiting_time": 0, + "arrival": 0, + "duration": 0, + "violations": [], + "distance": 0, + }, + { + "type": "job", + "location": [-68.192889, -16.472475], + "id": 0, + "setup": 0, + "service": 0, + "waiting_time": 0, + "job": 0, + "arrival": 18, + "duration": 18, + "violations": [], + "distance": 76, + }, + { + "type": "end", + "location": [-68.193407, -16.472978], + "setup": 0, + "service": 0, + "waiting_time": 0, + "arrival": 36, + "duration": 36, + "violations": [], + "distance": 152, + }, + ], + "violations": [], + "geometry": "lkpcBd_f_LuBiAtBhA", + } + ], + } + profile = "driving-car" + preference = "fastest" + feature = directions_core.get_output_features_optimization(response, profile, preference) + coordinates = [(vertex.x(), vertex.y()) for vertex in feature.geometry().vertices()] + + test_coords = [(-68.19331, -16.47303), (-68.19294, -16.47244), (-68.19331, -16.47303)] + self.assertAlmostEqual(coordinates, test_coords) + + def test_build_default_parameters(self): + preference, point_list, coordinates, options = ( + "fastest", + [ + QgsPointXY(-68.1934067732971414, -16.47297756153070125), + QgsPointXY(-68.19288936751472363, -16.47247452813111934), + ], + None, + {}, + ) + params = directions_core.build_default_parameters( + preference, point_list, coordinates, options + ) + test_params = { + "coordinates": [[-68.193407, -16.472978], [-68.192889, -16.472475]], + "preference": "fastest", + "geometry": "true", + "instructions": "false", + "elevation": True, + "id": None, + "options": {}, + "extra_info": None, + } + + self.assertDictEqual(params, test_params) + + def test_isochrones(self): + response = { + "type": "FeatureCollection", + "metadata": { + "attribution": "openrouteservice.org | OpenStreetMap contributors", + "service": "isochrones", + "timestamp": 1710421093483, + "query": { + "profile": "driving-car", + "locations": [[-112.594673, 43.554193]], + "location_type": "start", + "range": [60.0], + "range_type": "time", + "options": {}, + "attributes": ["total_pop"], + }, + "engine": { + "version": "7.1.1", + "build_date": "2024-01-29T14:41:12Z", + "graph_date": "2024-03-10T15:19:08Z", + }, + }, + "bbox": [-112.637014, 43.548994, -112.550441, 43.554343], + "features": [ + { + "type": "Feature", + "properties": { + "group_index": 0, + "value": 60.0, + "center": [-112.5946738217447, 43.55409137088865], + "total_pop": 0.0, + }, + "geometry": { + "coordinates": [ + [ + [-112.637014, 43.549342], + [-112.63692, 43.548994], + [-112.631205, 43.550527], + [-112.625496, 43.552059], + [-112.623482, 43.552518], + [-112.617781, 43.553548], + [-112.615319, 43.553798], + [-112.612783, 43.553937], + [-112.61154, 43.553971], + [-112.609679, 43.553977], + [-112.607819, 43.553983], + [-112.603711, 43.553958], + [-112.599603, 43.553932], + [-112.598575, 43.553928], + [-112.594187, 43.553909], + [-112.593002, 43.553904], + [-112.588772, 43.553886], + [-112.587429, 43.553881], + [-112.578142, 43.553673], + [-112.568852, 43.553464], + [-112.559651, 43.553232], + [-112.55045, 43.553], + [-112.550441, 43.55336], + [-112.559642, 43.553592], + [-112.568844, 43.553824], + [-112.578134, 43.554032], + [-112.587427, 43.554241], + [-112.58877, 43.554246], + [-112.593, 43.554264], + [-112.594186, 43.554269], + [-112.598573, 43.554288], + [-112.599601, 43.554292], + [-112.603709, 43.554318], + [-112.607817, 43.554343], + [-112.60968, 43.554337], + [-112.611541, 43.554331], + [-112.612793, 43.554297], + [-112.614041, 43.554262], + [-112.615348, 43.554157], + [-112.616646, 43.554052], + [-112.617826, 43.553905], + [-112.618998, 43.553758], + [-112.620272, 43.553544], + [-112.621537, 43.553331], + [-112.623562, 43.552869], + [-112.625576, 43.55241], + [-112.631298, 43.550875], + [-112.637014, 43.549342], + ] + ], + "type": "Polygon", + }, + } + ], + } + id_field_value = None + isochrones = isochrones_core.Isochrones() + isochrones.set_parameters("driving-car", "time", 60) + + feats = isochrones.get_features(response, id_field_value) + self.assertAlmostEqual(next(feats).geometry().area(), 3.176372365487623e-05) diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 00000000..17c97adf --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,68 @@ +from qgis.testing import unittest + +from qgis.PyQt.QtTest import QTest +from qgis.PyQt.QtCore import Qt, QEvent, QPoint +from qgis.PyQt.QtWidgets import QPushButton +from qgis.gui import QgsMapCanvas, QgsMapMouseEvent +from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsRectangle, +) +import pytest + +from tests.utils.utilities import get_qgis_app + +CANVAS: QgsMapCanvas +QGISAPP, CANVAS, IFACE, PARENT = get_qgis_app() + + +@pytest.mark.filterwarnings("ignore:.*imp module is deprecated.*") +class TestGui(unittest.TestCase): + def test_ORStoolsDialog(self): + from ORStools.gui.ORStoolsDialog import ORStoolsDialog + from ORStools.utils import maptools + + CRS = QgsCoordinateReferenceSystem.fromEpsgId(3857) + CANVAS.setExtent(QgsRectangle(258889, 7430342, 509995, 7661955)) + CANVAS.setDestinationCrs(CRS) + CANVAS.setFrameStyle(0) + CANVAS.resize(600, 400) + self.assertEqual(CANVAS.width(), 600) + self.assertEqual(CANVAS.height(), 400) + + dlg = ORStoolsDialog(IFACE) + dlg.open() + self.assertTrue(dlg.isVisible()) + + map_button: QPushButton = dlg.routing_fromline_map + # click 'routing_fromline_map' + QTest.mouseClick(map_button, Qt.LeftButton) + self.assertFalse(dlg.isVisible()) + self.assertIsInstance(CANVAS.mapTool(), maptools.LineTool) + + map_dclick = QgsMapMouseEvent( + CANVAS, + QEvent.MouseButtonDblClick, + QPoint(5, 5), # Relative to the canvas' dimensions + Qt.LeftButton, + Qt.LeftButton, + Qt.NoModifier, + ) + + map_click = QgsMapMouseEvent( + CANVAS, + QEvent.MouseButtonRelease, + QPoint(0, 0), # Relative to the canvas' dimensions + Qt.LeftButton, + Qt.LeftButton, + Qt.NoModifier, + ) + # click on canvas at [0, 0] + dlg.line_tool.canvasReleaseEvent(map_click) + # doubleclick on canvas at [5, 5] + dlg.line_tool.canvasDoubleClickEvent(map_dclick) + + self.assertTrue(dlg.isVisible()) + self.assertAlmostEqual( + dlg.routing_fromline_list.item(0).text(), "Point 0: -0.187575, 56.516620" + ) diff --git a/tests/test_proc.py b/tests/test_proc.py new file mode 100644 index 00000000..df4cd8d7 --- /dev/null +++ b/tests/test_proc.py @@ -0,0 +1,178 @@ +from qgis.core import ( + QgsPointXY, + QgsProcessingFeedback, + QgsProcessingContext, + QgsProcessingUtils, + QgsVectorLayer, + QgsFeature, + QgsGeometry, +) +from qgis.testing import unittest + +from ORStools.proc.directions_lines_proc import ORSDirectionsLinesAlgo +from ORStools.proc.directions_points_layer_proc import ORSDirectionsPointsLayerAlgo +from ORStools.proc.directions_points_layers_proc import ORSDirectionsPointsLayersAlgo +from ORStools.proc.isochrones_layer_proc import ORSIsochronesLayerAlgo +from ORStools.proc.isochrones_point_proc import ORSIsochronesPointAlgo +from ORStools.proc.matrix_proc import ORSMatrixAlgo + + +class TestProc(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + uri = "point?crs=epsg:4326" + cls.point_layer_1 = QgsVectorLayer(uri, "Scratch point layer", "memory") + points_of_interest = [QgsPointXY(-118.2394, 34.0739), QgsPointXY(-118.3215, 34.1399)] + for point in points_of_interest: + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromPointXY(point)) + cls.point_layer_1.dataProvider().addFeatures([feature]) + + cls.point_layer_2 = QgsVectorLayer(uri, "Scratch point layer", "memory") + points_of_interest = [QgsPointXY(-118.5, 34.2), QgsPointXY(-118.5, 34.3)] + for point in points_of_interest: + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromPointXY(point)) + cls.point_layer_2.dataProvider().addFeatures([feature]) + + cls.line_layer = QgsVectorLayer(uri, "Scratch point layer", "memory") + vertices = [(-118.2394, 34.0739), (-118.3215, 34.1341), (-118.4961, 34.5)] + line_geometry = QgsGeometry.fromPolylineXY([QgsPointXY(x, y) for x, y in vertices]) + feature = QgsFeature() + feature.setGeometry(line_geometry) + cls.line_layer.dataProvider().addFeatures([feature]) + + cls.feedback = QgsProcessingFeedback() + cls.context = QgsProcessingContext() + + def test_directions_lines(self): + parameters = { + "INPUT_AVOID_BORDERS": None, + "INPUT_AVOID_COUNTRIES": "", + "INPUT_AVOID_FEATURES": [], + "INPUT_AVOID_POLYGONS": None, + "INPUT_LAYER_FIELD": None, + "INPUT_LINE_LAYER": self.line_layer, + "INPUT_OPTIMIZE": None, + "INPUT_PREFERENCE": 0, + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_METRIC": 0, + "LOCATION_TYPE": 0, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + directions = ORSDirectionsLinesAlgo().create() + dest_id = directions.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) + + def test_directions_points_layer(self): + parameters = { + "INPUT_AVOID_BORDERS": None, + "INPUT_AVOID_COUNTRIES": "", + "INPUT_AVOID_FEATURES": [], + "INPUT_AVOID_POLYGONS": None, + "INPUT_LAYER_FIELD": None, + "INPUT_OPTIMIZE": None, + "INPUT_POINT_LAYER": self.point_layer_1, + "INPUT_PREFERENCE": 0, + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_SORTBY": None, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + directions = ORSDirectionsPointsLayerAlgo().create() + dest_id = directions.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) + + def test_directions_points_layers(self): + parameters = { + "INPUT_AVOID_BORDERS": None, + "INPUT_AVOID_COUNTRIES": "", + "INPUT_AVOID_FEATURES": [], + "INPUT_AVOID_POLYGONS": None, + "INPUT_END_FIELD": None, + "INPUT_END_LAYER": self.point_layer_1, + "INPUT_MODE": 0, + "INPUT_PREFERENCE": 0, + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_SORT_END_BY": None, + "INPUT_SORT_START_BY": None, + "INPUT_START_FIELD": None, + "INPUT_START_LAYER": self.point_layer_2, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + directions = ORSDirectionsPointsLayersAlgo().create() + dest_id = directions.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) + + def test_isochrones_layer(self): + parameters = { + "INPUT_AVOID_BORDERS": None, + "INPUT_AVOID_COUNTRIES": "", + "INPUT_AVOID_FEATURES": [], + "INPUT_AVOID_POLYGONS": None, + "INPUT_FIELD": None, + "INPUT_METRIC": 0, + "INPUT_POINT_LAYER": self.point_layer_1, + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_RANGES": "5, 10", + "INPUT_SMOOTHING": None, + "LOCATION_TYPE": 0, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + iso = ORSIsochronesLayerAlgo().create() + dest_id = iso.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) + + def test_isochrones_point(self): + parameters = { + "INPUT_AVOID_BORDERS": None, + "INPUT_AVOID_COUNTRIES": "", + "INPUT_AVOID_FEATURES": [], + "INPUT_AVOID_POLYGONS": None, + "INPUT_METRIC": 0, + "INPUT_POINT": "-12476269.994314,3961968.635469 [EPSG:3857]", + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_RANGES": "5, 10", + "INPUT_SMOOTHING": None, + "LOCATION_TYPE": 0, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + iso = ORSIsochronesPointAlgo().create() + dest_id = iso.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) + + def test_matrix(self): + parameters = { + "INPUT_END_FIELD": None, + "INPUT_END_LAYER": self.point_layer_1, + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "INPUT_START_FIELD": None, + "INPUT_START_LAYER": self.point_layer_2, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + matrix = ORSMatrixAlgo().create() + dest_id = matrix.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + + self.assertEqual(type(processed_layer), QgsVectorLayer) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..e7fccc87 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,59 @@ +from qgis.testing import unittest + +from qgis.core import QgsCoordinateReferenceSystem, QgsPointXY + +from ORStools.utils.transform import transformToWGS +from ORStools.utils.convert import decode_polyline +from ORStools.utils.processing import get_params_optimize + + +class TestUtils(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.WGS = QgsCoordinateReferenceSystem.fromEpsgId(4326) + cls.PSEUDO = QgsCoordinateReferenceSystem.fromEpsgId(3857) + + def test_to_wgs_pseudo(self): + point = QgsPointXY(1493761.05913532, 6890799.81730105) + transformer = transformToWGS(self.PSEUDO) + self.assertEqual( + transformer.transform(point), QgsPointXY(13.41868390243822162, 52.49867709045137332) + ) + + def test_polyline_convert(self): + polyline = "psvcBxg}~KAGUoBMo@Ln@TnB@F" + decoded = decode_polyline(polyline) + self.assertEqual( + decoded, + [ + [-68.14861, -16.50505], + [-68.14857, -16.50504], + [-68.14801, -16.50493], + [-68.14777, -16.50486], + [-68.14801, -16.50493], + [-68.14857, -16.50504], + [-68.14861, -16.50505], + ], + ) + + def test_get_params_optimize(self): + points = [ + QgsPointXY(-68.14860459410432725, -16.5050554680791457), + QgsPointXY(-68.14776841920792094, -16.50487191749212812), + ] + profile = "driving-car" + mode = 0 + + params = { + "jobs": [{"location": [-68.147768, -16.504872], "id": 0}], + "vehicles": [ + { + "id": 0, + "profile": "driving-car", + "start": [-68.148605, -16.505055], + "end": [-68.148605, -16.505055], + } + ], + "options": {"g": True}, + } + self.assertEqual(get_params_optimize(points, profile, mode), params) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/qgis_interface.py b/tests/utils/qgis_interface.py new file mode 100644 index 00000000..6b157f73 --- /dev/null +++ b/tests/utils/qgis_interface.py @@ -0,0 +1,237 @@ +"""QGIS plugin implementation. + +.. note:: This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + +.. note:: This source code was copied from the 'postgis viewer' application + with original authors: + Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk + Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org + Copyright (c) 2014 Tim Sutton, tim@linfiniti.com + +""" + +__author__ = "tim@linfiniti.com" +__revision__ = "$Format:%H$" +__date__ = "10/01/2011" +__copyright__ = ( + "Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk and " + "Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org" + "Copyright (c) 2014 Tim Sutton, tim@linfiniti.com" +) + +import logging +from typing import List +from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal, QSize +from qgis.PyQt.QtWidgets import QDockWidget +from qgis.core import QgsProject, QgsMapLayer +from qgis.gui import QgsMapCanvas, QgsMessageBar + +LOGGER = logging.getLogger("QGIS") + + +# noinspection PyMethodMayBeStatic,PyPep8Naming +# pylint: disable=too-many-public-methods +class QgisInterface(QObject): + """Class to expose QGIS objects and functions to plugins. + + This class is here for enabling us to run unit tests only, + so most methods are simply stubs. + """ + + currentLayerChanged = pyqtSignal(QgsMapLayer) + + def __init__(self, canvas: QgsMapCanvas): + """Constructor + :param canvas: + """ + QObject.__init__(self) + self.canvas = canvas + # Set up slots so we can mimic the behaviour of QGIS when layers + # are added. + LOGGER.debug("Initialising canvas...") + # noinspection PyArgumentList + QgsProject.instance().layersAdded.connect(self.addLayers) + # noinspection PyArgumentList + QgsProject.instance().layerWasAdded.connect(self.addLayer) + # noinspection PyArgumentList + QgsProject.instance().removeAll.connect(self.removeAllLayers) + + # For processing module + self.destCrs = None + + self.message_bar = QgsMessageBar() + + def addLayers(self, layers: List[QgsMapLayer]): + """Handle layers being added to the registry so they show up in canvas. + + :param layers: list list of map layers that were added + + .. note:: The QgsInterface api does not include this method, + it is added here as a helper to facilitate testing. + """ + # LOGGER.debug('addLayers called on qgis_interface') + # LOGGER.debug('Number of layers being added: %s' % len(layers)) + # LOGGER.debug('Layer Count Before: %s' % len(self.canvas.layers())) + current_layers = self.canvas.layers() + final_layers = [] + for layer in current_layers: + final_layers.append(layer) + for layer in layers: + final_layers.append(layer) + + self.canvas.setLayers(final_layers) + # LOGGER.debug('Layer Count After: %s' % len(self.canvas.layers())) + + def addLayer(self, layer: QgsMapLayer): + """Handle a layer being added to the registry so it shows up in canvas. + + :param layer: list list of map layers that were added + + .. note: The QgsInterface api does not include this method, it is added + here as a helper to facilitate testing. + + .. note: The addLayer method was deprecated in QGIS 1.8 so you should + not need this method much. + """ + pass # pylint: disable=unnecessary-pass + + @pyqtSlot() + def removeAllLayers(self): # pylint: disable=no-self-use + """Remove layers from the canvas before they get deleted.""" + self.canvas.setLayers([]) + + def newProject(self): # pylint: disable=no-self-use + """Create new project.""" + # noinspection PyArgumentList + QgsProject.instance().clear() + + # ---------------- API Mock for QgsInterface follows ------------------- + + def zoomFull(self): + """Zoom to the map full extent.""" + pass # pylint: disable=unnecessary-pass + + def zoomToPrevious(self): + """Zoom to previous view extent.""" + pass # pylint: disable=unnecessary-pass + + def zoomToNext(self): + """Zoom to next view extent.""" + pass # pylint: disable=unnecessary-pass + + def zoomToActiveLayer(self): + """Zoom to extent of active layer.""" + pass # pylint: disable=unnecessary-pass + + def addVectorLayer(self, path: str, base_name: str, provider_key: str): + """Add a vector layer. + + :param path: Path to layer. + :type path: str + + :param base_name: Base name for layer. + :type base_name: str + + :param provider_key: Provider key e.g. 'ogr' + :type provider_key: str + """ + pass # pylint: disable=unnecessary-pass + + def addRasterLayer(self, path: str, base_name: str): + """Add a raster layer given a raster layer file name + + :param path: Path to layer. + :type path: str + + :param base_name: Base name for layer. + :type base_name: str + """ + pass # pylint: disable=unnecessary-pass + + def activeLayer(self) -> QgsMapLayer: # pylint: disable=no-self-use + """Get pointer to the active layer (layer selected in the legend).""" + # noinspection PyArgumentList + layers = QgsProject.instance().mapLayers() + for item in layers: + return layers[item] + + def addToolBarIcon(self, action): + """Add an icon to the plugins toolbar. + + :param action: Action to add to the toolbar. + :type action: QAction + """ + pass # pylint: disable=unnecessary-pass + + def removeToolBarIcon(self, action): + """Remove an action (icon) from the plugin toolbar. + + :param action: Action to add to the toolbar. + :type action: QAction + """ + pass # pylint: disable=unnecessary-pass + + def addToolBar(self, name): + """Add toolbar with specified name. + + :param name: Name for the toolbar. + :type name: str + """ + pass # pylint: disable=unnecessary-pass + + def mapCanvas(self) -> QgsMapCanvas: + """Return a pointer to the map canvas.""" + return self.canvas + + def mainWindow(self): + """Return a pointer to the main window. + + In case of QGIS it returns an instance of QgisApp. + """ + pass # pylint: disable=unnecessary-pass + + def addDockWidget(self, area, dock_widget: QDockWidget): + """Add a dock widget to the main window. + + :param area: Where in the ui the dock should be placed. + :type area: + + :param dock_widget: A dock widget to add to the UI. + :type dock_widget: QDockWidget + """ + pass # pylint: disable=unnecessary-pass + + def removeDockWidget(self, dock_widget: QDockWidget): + """Remove a dock widget to the main window. + + :param area: Where in the ui the dock should be placed. + :type area: + + :param dock_widget: A dock widget to add to the UI. + :type dock_widget: QDockWidget + """ + pass # pylint: disable=unnecessary-pass + + def legendInterface(self): + """Get the legend.""" + return self.canvas + + def iconSize(self, dockedToolbar) -> int: # pylint: disable=no-self-use + """ + Returns the toolbar icon size. + :param dockedToolbar: If True, the icon size + for toolbars contained within docks is returned. + """ + if dockedToolbar: + return QSize(16, 16) + + return QSize(24, 24) + + def messageBar(self) -> QgsMessageBar: + """ + Return the message bar of the main app + """ + return self.message_bar diff --git a/tests/utils/utilities.py b/tests/utils/utilities.py new file mode 100644 index 00000000..54af22b5 --- /dev/null +++ b/tests/utils/utilities.py @@ -0,0 +1,101 @@ +"""Common functionality used by regression tests.""" + +import sys +import logging +import os +import atexit +from qgis.core import QgsApplication +from qgis.gui import QgsMapCanvas +from qgis.PyQt.QtCore import QSize +from qgis.PyQt.QtWidgets import QWidget +from qgis.utils import iface +from tests.utils.qgis_interface import QgisInterface + +LOGGER = logging.getLogger("QGIS") +QGIS_APP = None # Static variable used to hold hand to running QGIS app +CANVAS = None +PARENT = None +IFACE = None + + +def get_qgis_app(cleanup=True): + """Start one QGIS application to test against. + + :returns: Handle to QGIS app, canvas, iface and parent. If there are any + errors the tuple members will be returned as None. + :rtype: (QgsApplication, CANVAS, IFACE, PARENT) + + If QGIS is already running the handle to that app will be returned. + """ + + global QGIS_APP, PARENT, IFACE, CANVAS # pylint: disable=W0603 + + if iface: + QGIS_APP = QgsApplication + CANVAS = iface.mapCanvas() + PARENT = iface.mainWindow() + IFACE = iface + return QGIS_APP, CANVAS, IFACE, PARENT + + global QGISAPP # pylint: disable=global-variable-undefined + + try: + QGISAPP # pylint: disable=used-before-assignment + except NameError: + myGuiFlag = False # All test will run qgis not in gui mode + + # In python3 we need to convert to a bytes object (or should + # QgsApplication accept a QString instead of const char* ?) + try: + argvb = list(map(os.fsencode, sys.argv)) + except AttributeError: + argvb = sys.argv + + # Note: QGIS_PREFIX_PATH is evaluated in QgsApplication - + # no need to mess with it here. + QGISAPP = QgsApplication(argvb, myGuiFlag) + + QGISAPP.initQgis() + s = QGISAPP.showSettings() + LOGGER.debug(s) + + def debug_log_message(message, tag, level): + """ + Prints a debug message to a log + :param message: message to print + :param tag: log tag + :param level: log message level (severity) + :return: + """ + print(f"{tag}({level}): {message}") + + QgsApplication.instance().messageLog().messageReceived.connect(debug_log_message) + + if cleanup: + + @atexit.register + def exitQgis(): # pylint: disable=unused-variable + """ + Gracefully closes the QgsApplication instance + """ + try: + QGISAPP.exitQgis() # noqa: F823 + QGISAPP = None # noqa: F841 + except NameError: + pass + + if PARENT is None: + # noinspection PyPep8Naming + PARENT = QWidget() + + if CANVAS is None: + # noinspection PyPep8Naming + CANVAS = QgsMapCanvas(PARENT) + CANVAS.resize(QSize(400, 400)) + + if IFACE is None: + # QgisInterface is a stub implementation of the QGIS plugin interface + # noinspection PyPep8Naming + IFACE = QgisInterface(CANVAS) + + return QGISAPP, CANVAS, IFACE, PARENT