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/.gitignore b/.gitignore index e5668ccf..9aa64ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store tets/ docs/wiki/OSMtools.wiki/ .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c601b6e..c358d77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ RELEASING: ### Added - Processing algorithms for the Network Export endpoint ([#210](https://github.com/GIScience/orstools-qgis-plugin/issues/210)) +### Changed +- Use QgsSettings instead of config.yml file to avoid deletion of providers on update ([#108](https://github.com/GIScience/orstools-qgis-plugin/issues/108)) + +## [1.8.4] - 2024-07-29 + +### Fixed +- issue with missing locale value for non-default user([#271](https://github.com/GIScience/orstools-qgis-plugin/issues/271)) + ## [1.8.3] - 2024-05-29 ### Fixed @@ -81,6 +89,10 @@ RELEASING: - Improved type hints +# Unreleased +### Added +- Unit- and e2e-testing + ## [1.7.1] - 2024-01-15 ### Added @@ -261,7 +273,8 @@ RELEASING: - first working version of ORS Tools, after replacing OSM Tools plugin -[unreleased]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.3...HEAD +[unreleased]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.4...HEAD +[1.8.4]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.3...v1.8.4 [1.8.3]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.2...v1.8.3 [1.8.2]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.1...v1.8.2 [1.8.1]: https://github.com/GIScience/orstools-qgis-plugin/compare/v1.8.0...v1.8.1 diff --git a/LICENSE.md b/LICENSE.md index 3da1a70c..031c95c7 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2021 HeiGIT gGmbH +Copyright (c) 2017 Nils Nolde +Copyright (c) 2019 HeiGIT gGmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/ORStools/LICENSE b/ORStools/LICENSE new file mode 100644 index 00000000..031c95c7 --- /dev/null +++ b/ORStools/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2017 Nils Nolde +Copyright (c) 2019 HeiGIT gGmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ORStools/ORStoolsPlugin.py b/ORStools/ORStoolsPlugin.py index 30d8a9e6..33fa4b5c 100644 --- a/ORStools/ORStoolsPlugin.py +++ b/ORStools/ORStoolsPlugin.py @@ -29,7 +29,7 @@ from qgis.gui import QgisInterface from qgis.core import QgsApplication, QgsSettings -from qgis.PyQt.QtCore import QTranslator, qVersion, QCoreApplication +from qgis.PyQt.QtCore import QTranslator, qVersion, QCoreApplication, QLocale import os.path from .gui import ORStoolsDialog @@ -56,15 +56,24 @@ def __init__(self, iface: QgisInterface) -> None: self.plugin_dir = os.path.dirname(__file__) # initialize locale - locale = QgsSettings().value("locale/userLocale")[0:2] - locale_path = os.path.join(self.plugin_dir, "i18n", "orstools_{}.qm".format(locale)) + try: + locale = QgsSettings().value("locale/userLocale") + if not locale: + locale = QLocale().name() + locale = locale[0:2] - if os.path.exists(locale_path): - self.translator = QTranslator() - self.translator.load(locale_path) + locale_path = os.path.join(self.plugin_dir, "i18n", "orstools_{}.qm".format(locale)) - if qVersion() > "4.3.3": - QCoreApplication.installTranslator(self.translator) + if os.path.exists(locale_path): + self.translator = QTranslator() + self.translator.load(locale_path) + + if qVersion() > "4.3.3": + QCoreApplication.installTranslator(self.translator) + except TypeError: + pass + + self.add_default_provider_to_settings() def initGui(self) -> None: """Create the menu entries and toolbar icons inside the QGIS GUI.""" @@ -76,3 +85,23 @@ def unload(self) -> None: """remove menu entry and toolbar icons""" QgsApplication.processingRegistry().removeProvider(self.provider) self.dialog.unload() + + def add_default_provider_to_settings(self): + s = QgsSettings() + settings = s.value("ORStools/config") + if not settings: + def_settings = { + "providers": [ + { + "ENV_VARS": { + "ORS_QUOTA": "X-Ratelimit-Limit", + "ORS_REMAINING": "X-Ratelimit-Remaining", + }, + "base_url": "https://api.openrouteservice.org", + "key": "", + "name": "openrouteservice", + "timeout": 60, + } + ] + } + s.setValue("ORStools/config", def_settings) 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/config.yml b/ORStools/config.yml deleted file mode 100755 index a08cd7d1..00000000 --- a/ORStools/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -providers: -- ENV_VARS: - ORS_QUOTA: X-Ratelimit-Limit - ORS_REMAINING: X-Ratelimit-Remaining - base_url: https://api.openrouteservice.org - key: '' - name: openrouteservice - timeout: 60 diff --git a/ORStools/gui/ORStoolsDialog.py b/ORStools/gui/ORStoolsDialog.py index 49f6a445..4d6a4c70 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 @@ -50,7 +54,7 @@ from qgis.gui import QgsMapCanvasAnnotationItem from qgis.PyQt.QtCore import QSizeF, QPointF, QCoreApplication -from qgis.PyQt.QtGui import QIcon, QTextDocument +from qgis.PyQt.QtGui import QIcon, QTextDocument, QColor from qgis.PyQt.QtWidgets import ( QAction, QDialog, @@ -102,6 +106,8 @@ def on_help_click() -> None: def on_about_click(parent: QWidget) -> None: """Slot for click event of About button/menu entry.""" + # ruff will add trailing comma to last string line which breaks pylupdate5 + # fmt: off info = QCoreApplication.translate( "@default", 'ORS Tools provides access to None: 'Web: {2}
' 'Repo: ' "github.com/GIScience/orstools-qgis-plugin
" - "Version: {3}", + "Version: {3}" ).format(DEFAULT_COLOR, __email__, __web__, __version__) + # fmt: on QMessageBox.information( parent, QCoreApplication.translate("@default", "About {}").format(PLUGIN_NAME), info @@ -320,6 +327,27 @@ def run_gui_control(self) -> None: try: params = directions.get_parameters() if self.dlg.optimization_group.isChecked(): + # check for duplicate points + points = [ + self.dlg.routing_fromline_list.item(x).text() + for x in range(self.dlg.routing_fromline_list.count()) + ] + if len(points) != len(set(points)): + QMessageBox.warning( + self.dlg, + self.tr("Duplicates"), + self.tr( + """ + There are duplicate points in the input layer. Traveling Salesman Optimization does not allow this. + Either remove the duplicates or deselect Traveling Salesman. + """ + ), + ) + msg = self.tr("The request has been aborted!") + logger.log(msg, 0) + self.dlg.debug_text.setText(msg) + return + if len(params["jobs"]) <= 1: # Start/end locations don't count as job QMessageBox.critical( self.dlg, @@ -490,6 +518,14 @@ def __init__(self, iface: QgisInterface, parent=None) -> None: self.routing_fromline_list.model().rowsMoved.connect(self._reindex_list_items) self.routing_fromline_list.model().rowsRemoved.connect(self._reindex_list_items) + # Connect signals to the color_duplicate_items function + self.routing_fromline_list.model().rowsRemoved.connect( + lambda: self.color_duplicate_items(self.routing_fromline_list) + ) + self.routing_fromline_list.model().rowsInserted.connect( + lambda: self.color_duplicate_items(self.routing_fromline_list) + ) + self.annotation_canvas = self._iface.mapCanvas() def _save_vertices_to_layer(self) -> None: @@ -633,3 +669,19 @@ def _on_linetool_map_doubleclick(self) -> None: QApplication.restoreOverrideCursor() self._iface.mapCanvas().setMapTool(self.last_maptool) self.show() + + def color_duplicate_items(self, list_widget): + item_dict = {} + for index in range(list_widget.count()): + item = list_widget.item(index) + text = item.text() + if text in item_dict: + item_dict[text].append(index) + else: + item_dict[text] = [index] + + for indices in item_dict.values(): + if len(indices) > 1: + for index in indices: + item = list_widget.item(index) + item.setBackground(QColor("lightsalmon")) diff --git a/ORStools/i18n/orstools_de.ts b/ORStools/i18n/orstools_de.ts index a3bd32b9..4325a73a 100644 --- a/ORStools/i18n/orstools_de.ts +++ b/ORStools/i18n/orstools_de.ts @@ -3,16 +3,16 @@ @default - - - <b>ORS Tools</b> provides access to <a href="https://openrouteservice.org" style="color: {0}">openrouteservice</a> routing functionalities.<br><br><center><a href="https://heigit.org/de/willkommen"><img src=":/plugins/ORStools/img/logo_heigit_300.png"/></a><br><br></center>Author: HeiGIT gGmbH<br>Email: <a href="mailto:Openrouteservice <{1}>">{1}</a><br>Web: <a href="{2}">{2}</a><br>Repo: <a href="https://github.com/GIScience/orstools-qgis-plugin">github.com/GIScience/orstools-qgis-plugin</a><br>Version: {3} - <b>ORS Tools</b> bietet Zugriff auf <a href="https://openrouteservice.org" style="color: {0}">openrouteservice</a> Berechnungen.<br><br><center><a href="https://heigit.org/de/willkommen"><img src=":/plugins/ORStools/img/logo_heigit_300.png"/></a><br><br></center>Author: HeiGIT gGmbH<br>Email: <a href="mailto:Openrouteservice <{1}>">{1}</a><br>Web: <a href="{2}">{2}</a><br>Repo: <a href="https://github.com/GIScience/orstools-qgis-plugin">github.com/GIScience/orstools-qgis-plugin</a><br>Version: {3} - About {} Über {} + + + <b>ORS Tools</b> provides access to <a href="https://openrouteservice.org" style="color: {0}">openrouteservice</a> routing functionalities.<br><br><center><a href="https://heigit.org/de/willkommen"><img src=":/plugins/ORStools/img/logo_heigit_300.png"/></a><br><br></center>Author: HeiGIT gGmbH<br>Email: <a href="mailto:Openrouteservice <{1}>">{1}</a><br>Web: <a href="{2}">{2}</a><br>Repo: <a href="https://github.com/GIScience/orstools-qgis-plugin">github.com/GIScience/orstools-qgis-plugin</a><br>Version: {3} + <b>ORS Tools</b> bietet Zugriff auf <a href="https://openrouteservice.org" style="color: {0}">openrouteservice</a> Berechnungen.<br><br><center><a href="https://heigit.org/de/willkommen"><img src=":/plugins/ORStools/img/logo_heigit_300.png"/></a><br><br></center>Author: HeiGIT gGmbH<br>Email: <a href="mailto:Openrouteservice <{1}>">{1}</a><br>Web: <a href="{2}">{2}</a><br>Repo: <a href="https://github.com/GIScience/orstools-qgis-plugin">github.com/GIScience/orstools-qgis-plugin</a><br>Version: {3} + ORSBaseProcessingAlgorithm @@ -79,7 +79,7 @@ Wegpunktoptimierung (sonstige Konfiguration wird nicht berücksichtigt) - + Directions from 1 Polyline-Layer Routenberechnung aus einem Polyline-Layer @@ -104,34 +104,6 @@ Csv Spalte (benötigt Csv Faktor und csv in Extra Info) - - ORSDirectionsLinesAlgorithm - - - Input Line layer - Eingabelayer (Linien) - - - - Layer ID Field - ID-Attribut - - - - Travel preference - Routenpräferenz - - - - Traveling Salesman (omits other configurations) - Wegpunktoptimierung (sonstige Konfiguration wird nicht berücksichtigt) - - - - Directions from 1 Polyline-Layer - Routenberechnung aus einem Polyline-Layer - - ORSDirectionsPointsLayerAlgo @@ -155,7 +127,7 @@ Wegpunktoptimierung (sonstige Konfiguration wird nicht berücksichtigt) - + Directions from 1 Point-Layer Routenberechnung aus einem Punkt-Layer @@ -165,7 +137,7 @@ ID-Attribut (zum Beispiel für joins) - + Export order of jobs Reihenfolge exportieren @@ -175,15 +147,24 @@ Extra Info - + Csv Factor (needs Csv Column and csv in Extra Info) Csv Faktor (benötigt Csv Spalte und csv in Extra Info) - + Csv Column (needs Csv Factor and csv in Extra Info) Csv Spalte (benötigt Csv Faktor und csv in Extra Info) + + + + There are duplicate points in the input layer. Traveling Salesman Optimization does not allow this. + Either remove the duplicates or deselect Traveling Salesman. + + Das Eingabelayer enthält duplizierte Punkte. Dies ist mit der Wegpunktoptimierung nicht erlaubt. +Duplikate entfernen oder Wegpunktoptimierung abwählen. + ORSDirectionsPointsLayersAlgo @@ -228,7 +209,7 @@ Zuordnungs-Verfahren - + Directions from 2 Point-Layers Routenberechnung aus zwei Punkt-Layern @@ -350,12 +331,12 @@ ORStoolsDialog - + Apply Anwenden - + Close Schließen @@ -462,7 +443,7 @@ p, li { white-space: pre-wrap; } <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> <p style=" padding: 10px; -qt-block-indent:0; text-indent:0px ; background-color:#e7f2fa; color: #999999"><img stype="margin: 10px" src=":/plugins/ORStools/img/icon_about.png" width=16 height=16 /> Sämtliche Einstellungen werden überschrieben</p></body></html> @@ -729,5 +710,24 @@ p, li { white-space: pre-wrap; } About Über + + + Duplicates + Duplikate + + + + + There are duplicate points in the input layer. Traveling Salesman Optimization does not allow this. + Either remove the duplicates or deselect Traveling Salesman. + + Das Eingabelayer enthält duplizierte Punkte. Dies ist mit der Wegpunktoptimierung nicht erlaubt. +Duplikate entfernen oder Wegpunktoptimierung abwählen. + + + + The request has been aborted! + Die Anfrage wurde abgebrochen! + diff --git a/ORStools/metadata.txt b/ORStools/metadata.txt index 2aa2d214..cd6ec805 100644 --- a/ORStools/metadata.txt +++ b/ORStools/metadata.txt @@ -3,13 +3,17 @@ name=ORS Tools qgisMinimumVersion=3.4.8 description=openrouteservice routing, isochrones and matrix calculations for QGIS -version=1.8.3 +version=1.8.4 author=HeiGIT gGmbH -email=support@openrouteservice.heigit.org +email=support@smartmobility.heigit.org about=ORS Tools provides access to most of the functions of openrouteservice.org, based on OpenStreetMap. The tool set includes routing, isochrones and matrix calculations, either interactive in the map canvas or from point files within the processing framework. Extensive attributes are set for output files, incl. duration, length and start/end locations. -changelog=2024/05/29 v1.8.3 +changelog=2024/07/29 v1.8.4 + Fixed + - issue with missing locale value + + 2024/05/29 v1.8.3 Fixed - issues with extra_info in polylines/two point layer algorithms diff --git a/ORStools/proc/directions_points_layer_proc.py b/ORStools/proc/directions_points_layer_proc.py index 68f0e598..f3544b08 100644 --- a/ORStools/proc/directions_points_layer_proc.py +++ b/ORStools/proc/directions_points_layer_proc.py @@ -218,6 +218,15 @@ def sort(f): try: if optimization_mode is not None: + # check for duplicate points + if len(points) != len(set(points)): + raise exceptions.DuplicateError( + self.tr(""" + There are duplicate points in the input layer. Traveling Salesman Optimization does not allow this. + Either remove the duplicates or deselect Traveling Salesman. + """) + ) + params = get_params_optimize(points, profile, optimization_mode) response = ors_client.request("/optimization", {}, post_json=params) diff --git a/ORStools/utils/configmanager.py b/ORStools/utils/configmanager.py index e8d1c534..f7fa62d2 100644 --- a/ORStools/utils/configmanager.py +++ b/ORStools/utils/configmanager.py @@ -29,9 +29,7 @@ import os -import yaml - -from ORStools import CONFIG_PATH +from qgis.core import QgsSettings def read_config() -> dict: @@ -41,10 +39,10 @@ def read_config() -> dict: :returns: Parsed settings dictionary. :rtype: dict """ - with open(CONFIG_PATH) as f: - doc = yaml.safe_load(f) + s = QgsSettings() + config = s.value("ORStools/config") - return doc + return config def write_config(new_config: dict) -> None: @@ -54,8 +52,8 @@ def write_config(new_config: dict) -> None: :param new_config: new provider settings after altering in dialog. :type new_config: dict """ - with open(CONFIG_PATH, "w") as f: - yaml.safe_dump(new_config, f) + s = QgsSettings() + s.setValue("ORStools/config", new_config) def write_env_var(key: str, value: str) -> None: diff --git a/ORStools/utils/exceptions.py b/ORStools/utils/exceptions.py index ac8fe7d7..177a7b46 100644 --- a/ORStools/utils/exceptions.py +++ b/ORStools/utils/exceptions.py @@ -92,3 +92,11 @@ def __str__(self): return self.status else: return f"{self.status} ({self.message})" + + +class DuplicateError(Exception): + def __init__(self, message=None): + self.message = message + + def __str__(self): + return self.message diff --git a/README.md b/README.md index 90f21505..ad3f5675 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ORS Tools QGIS plugin +![Testing](https://github.com/GIScience/orstools-qgis-plugin/actions/workflows/test.yml/badge.svg) +![Ruff](https://github.com/GIScience/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..78dcd20f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +import os + +from qgis.core import QgsSettings + +from ORStools.ORStoolsPlugin import ORStools +from ORStools.utils.configmanager import read_config +from tests.utils.utilities import get_qgis_app + +QGISAPP, CANVAS, IFACE, PARENT = get_qgis_app() + +ORStools(IFACE).add_default_provider_to_settings() +s = QgsSettings() +data = s.value("ORStools/config") + +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") + s.setValue("ORStools/config", data) + 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. + """ + if not data["providers"][0]["key"] == "": + data['providers'][0]['key'] = '' # fmt: skip + s.setValue("ORStools/config", data) + 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..b9b0372b --- /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.assertEqual( + 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