diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 923bdef3..5857e6fe 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -9,6 +9,6 @@ jobs: - uses: actions/checkout@v2 - uses: psf/black@stable with: - options: "--check --diff --verbose -l 120" + options: "--check --diff --verbose" version: 25.1.0 src: "./Mergin" \ No newline at end of file diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 53370408..c3f3feef 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -1,80 +1,89 @@ name: Run Mergin Plugin Tests -on: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: push: workflow_dispatch: inputs: PYTHON_API_CLIENT_VER: - description: 'python-api-client version: either a tag, release, or a branch' + description: "python-api-client version: either a tag, release, or a branch" required: true - default: 'master' + default: "master" type: string env: + CONDA_PKGS_DIRS: ~/conda_pkgs_dir + DEBIAN_FRONTEND: noninteractive + QT_QPA_PLATFORM: offscreen + XDG_RUNTIME_DIR: /tmp + QT_SCALE_FACTOR: 1 + QT_AUTO_SCREEN_SCALE_FACTOR: 0 + QT_FONT_DPI: 96 # Assign the version provided by 'workflow_dispatch' if available; otherwise, use the default. PYTHON_API_CLIENT_VER: ${{ inputs.PYTHON_API_CLIENT_VER != '' && inputs.PYTHON_API_CLIENT_VER || 'master' }} PLUGIN_NAME: Mergin - TEST_FUNCTION: suite.test_all - DOCKER_IMAGE: qgis/qgis -concurrency: - group: ci-${{github.ref}}-autotests - cancel-in-progress: true - jobs: run-tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: + fail-fast: false matrix: - docker_tags: [release-3_22, release-3_34] + qgis-version: ["3.22", "3.28", "3.34", "3.40", "3.44"] steps: + - name: Checkout plugin code + uses: actions/checkout@v6 + with: + path: plugin - - name: Checkout client code - uses: actions/checkout@v3 + - name: Checkout python-client + uses: actions/checkout@v6 with: repository: MerginMaps/python-api-client ref: ${{ env.PYTHON_API_CLIENT_VER }} path: client + - name: Cache conda packages + uses: actions/cache@v4 + with: + path: ~/conda_pkgs_dir + key: conda-pkgs-${{ runner.os }}-${{ matrix.qgis-version }} + + - name: Setup conda + uses: conda-incubator/setup-miniconda@v3 + with: + use-mamba: true + channels: conda-forge,defaults + + - name: Create environment and install QGIS + run: | + conda create -n qgis_env --no-default-packages --yes + conda install -n qgis_env qgis=${{ matrix.qgis-version }} pytest pytest-qt pytest-cov --yes + conda run -n qgis_env pip install --no-cache-dir pytest-qgis + # mergin cilent dependencies + conda run -n qgis_env pip install python-dateutil pytz wheel + - name: Install python-api-client dependencies run: | - pip3 install python-dateutil pytz wheel cd client mkdir -p mergin/deps - pip3 install pygeodiff --target=mergin/deps - python3 setup.py sdist bdist_wheel + conda run -n qgis_env pip install pygeodiff --target=mergin/deps + conda run -n qgis_env python setup.py sdist bdist_wheel # without __init__.py the deps dir may get recognized as "namespace package" in python # and it can break qgis plugin unloading mechanism - see #126 touch mergin/deps/__init__.py - pip3 wheel -r mergin_client.egg-info/requires.txt -w mergin/deps + conda run -n qgis_env pip wheel -r mergin_client.egg-info/requires.txt -w mergin/deps unzip -o mergin/deps/pygeodiff-*.whl -d mergin/deps - - name: Checkout plugin code - uses: actions/checkout@v3 - with: - path: plugin - - - name: Copy client files to the plugin directory - run: | - cp -r client/mergin plugin/Mergin - - - name: Docker pull and create qgis-testing-environment - run: | - docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} - docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE"/plugin:/tests_directory -e DISPLAY=:99 "$DOCKER_IMAGE":${{ matrix.docker_tags }} - # Wait for xvfb to finish starting - printf "Waiting for the docker...🐳..." - sleep 10 - echo " done 🥩" + - name: Copy client files to plugin directory + run: cp -r client/mergin plugin/${{ env.PLUGIN_NAME }} - - name: Docker set up QGIS - run: | - docker exec qgis-testing-environment sh -c "qgis_setup.sh $PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "rm -f /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "ln -s /tests_directory/$PLUGIN_NAME /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - - - name: Docker run plugin tests - run: | - docker exec qgis-testing-environment sh -c "cd /tests_directory/$PLUGIN_NAME/test && qgis_testrunner.sh $TEST_FUNCTION" + - name: Run tests + run: conda run -n qgis_env pytest ${{ github.workspace }}/plugin/tests --cov=Mergin --cov-report=term-missing:skip-covered -rP -vv -s + env: + PYTHONPATH: ${{ github.workspace }}/plugin diff --git a/.github/workflows/security_check.yml b/.github/workflows/security_check.yml index 4d781818..b7d833bd 100644 --- a/.github/workflows/security_check.yml +++ b/.github/workflows/security_check.yml @@ -19,7 +19,7 @@ jobs: run: | # Upgrade pip and install security/linting tools python -m pip install --upgrade pip - pip install bandit detect-secrets flake8 flake8-json + pip install bandit detect-secrets flake8 flake8-json flake8-pyproject - name: Run Bandit (Security Scan) # Scan the Mergin folder for vulnerabilities, excluding the test directory @@ -30,7 +30,4 @@ jobs: run: detect-secrets scan ./Mergin/ --all-files - name: Run Flake8 (Style Check) - # Style enforcement using MerginMaps standards - # Ignoring E501 (line length) and W503 (operator line breaks) - run: | - flake8 ./Mergin/ --max-line-length=120 --ignore=E501,W503 --exclude=test \ No newline at end of file + run: flake8 ./Mergin/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5d3ed02f..1b977a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Mergin/mergin .idea/ .DS_Store mergin-py-client +.qgis-settings diff --git a/Mergin/test/__init__.py b/Mergin/test/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/Mergin/test/suite.py b/Mergin/test/suite.py deleted file mode 100644 index 775298d0..00000000 --- a/Mergin/test/suite.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- - -# GPLv3 license -# Copyright Lutra Consulting Limited - - -import sys -import unittest - - -def _run_tests(test_suite, package_name): - count = test_suite.countTestCases() - print("########") - print("{} tests has been discovered in {}".format(count, package_name)) - print("########") - - unittest.TextTestRunner(verbosity=3, stream=sys.stdout).run(test_suite) - - -def test_all(package="."): - test_loader = unittest.defaultTestLoader - test_suite = test_loader.discover(package) - _run_tests(test_suite, package) - - -if __name__ == "__main__": - test_all() diff --git a/Mergin/test/test_help.py b/Mergin/test/test_help.py deleted file mode 100644 index 8d60d1a9..00000000 --- a/Mergin/test/test_help.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- - -# GPLv3 license -# Copyright Lutra Consulting Limited - - -import os -import urllib.request -from qgis.core import ( - QgsVectorLayer, -) -from qgis.testing import start_app, unittest -from Mergin.help import MerginHelp - -test_data_path = os.path.join(os.path.dirname(__file__), "data") - - -class test_help(unittest.TestCase): - @classmethod - def setUpClass(cls): - start_app() - - def test_help_urls(self): - mh = MerginHelp() - - req = urllib.request.Request(mh.howto_attachment_widget(), method="HEAD") - resp = urllib.request.urlopen(req) - self.assertEqual(resp.status, 200) - - req = urllib.request.Request(mh.howto_background_maps(), method="HEAD") - resp = urllib.request.urlopen(req) - self.assertEqual(resp.status, 200) - - -def create_mem_layer() -> QgsVectorLayer: - """ - Create a memory layer. - """ - layer = QgsVectorLayer("Point", "test", "memory") - - return layer - - -if __name__ == "__main__": - nose2.main() diff --git a/Mergin/test/test_packaging.py b/Mergin/test/test_packaging.py deleted file mode 100644 index 40776040..00000000 --- a/Mergin/test/test_packaging.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- - -# GPLv3 license -# Copyright Lutra Consulting Limited - - -import os -import tempfile - -from qgis.core import QgsRasterLayer, QgsVectorTileLayer, QgsProviderRegistry - -from qgis.testing import start_app, unittest -from Mergin.utils import package_layer - -test_data_path = os.path.join(os.path.dirname(__file__), "data") - - -class test_packaging(unittest.TestCase): - @classmethod - def setUpClass(cls): - start_app() - - def test_copy_raster(self): - test_data_raster_path = os.path.join(test_data_path, "dem.tif") - layer = QgsRasterLayer(test_data_raster_path, "test", "gdal") - self.assertTrue(layer.isValid()) - source_raster_uri = layer.dataProvider().dataSourceUri() - self.assertEqual(source_raster_uri, test_data_raster_path) - with tempfile.TemporaryDirectory() as tmp_dir: - package_layer(layer, tmp_dir) - for ext in ("tif", "wld", "tfw", "prj", "qpj", "tifw"): - expected_filepath = os.path.join(tmp_dir, f"dem.{ext}") - self.assertTrue(os.path.exists(expected_filepath)) - if ext == "tif": - # Check if raster data source was updated - destination_raster_uri = layer.dataProvider().dataSourceUri() - self.assertEqual(destination_raster_uri, expected_filepath) - - def test_mbtiles_packaging(self): - raster_tiles_path = os.path.join(test_data_path, "raster-tiles.mbtiles") - layer = QgsRasterLayer(f"url=file://{raster_tiles_path}&type=mbtiles", "test", "wms") - self.assertTrue(layer.isValid()) - with tempfile.TemporaryDirectory() as tmp_dir: - package_layer(layer, tmp_dir) - expected_path = os.path.join(tmp_dir, "raster-tiles.mbtiles") - self.assertTrue(os.path.exists(expected_path)) - uri = QgsProviderRegistry.instance().decodeUri("wms", layer.source()) - self.assertTrue("path" in uri, str(uri)) - self.assertEqual(uri["path"], expected_path) - - vector_tiles_path = os.path.join(test_data_path, "vector-tiles.mbtiles") - layer = QgsVectorTileLayer(f"url={vector_tiles_path}&type=mbtiles", "test") - self.assertTrue(layer.isValid()) - with tempfile.TemporaryDirectory() as tmp_dir: - package_layer(layer, tmp_dir) - expected_path = os.path.join(tmp_dir, "vector-tiles.mbtiles") - self.assertTrue(os.path.exists(expected_path)) - uri = QgsProviderRegistry.instance().decodeUri("vectortile", layer.source()) - self.assertTrue("path" in uri) - self.assertEqual(uri["path"], expected_path) - - -if __name__ == "__main__": - nose2.main() diff --git a/Mergin/test/test_utils.py b/Mergin/test/test_utils.py deleted file mode 100644 index 9c3b575a..00000000 --- a/Mergin/test/test_utils.py +++ /dev/null @@ -1,260 +0,0 @@ -# -*- coding: utf-8 -*- - -# GPLv3 license -# Copyright Lutra Consulting Limited - - -import os -import json -import copy -import tempfile - -from qgis.PyQt.QtCore import QVariant -from qgis.core import ( - QgsProject, - QgsDatumTransform, - QgsCoordinateReferenceSystem, - QgsCoordinateTransformContext, - QgsVectorLayer, - QgsWkbTypes, - QgsSymbolLayer, -) - -from qgis.testing import start_app, unittest -from Mergin.utils import ( - same_schema, - get_datum_shift_grids, - is_valid_name, - create_tracking_layer, - create_map_sketches_layer, -) - -test_data_path = os.path.join(os.path.dirname(__file__), "data") - - -class test_utils(unittest.TestCase): - @classmethod - def setUpClass(cls): - start_app() - - def tearDown(self): - del self.base_schema - del self.tables_schema - - def setUp(self): - with open(os.path.join(test_data_path, "schema_base.json")) as f: - self.base_schema = json.load(f).get("geodiff_schema") - - with open(os.path.join(test_data_path, "schema_two_tables.json")) as f: - self.tables_schema = json.load(f).get("geodiff_schema") - - def test_table_added_removed(self): - equal, msg = same_schema(self.base_schema, self.base_schema) - self.assertTrue(equal) - self.assertEqual(msg, "No schema changes") - - equal, msg = same_schema(self.base_schema, self.tables_schema) - self.assertFalse(equal) - self.assertEqual(msg, "Tables added/removed: added: hotels") - - equal, msg = same_schema(self.tables_schema, self.base_schema) - self.assertFalse(equal) - self.assertEqual(msg, "Tables added/removed: removed: hotels") - - def test_table_schema_changed(self): - modified_schema = copy.deepcopy(self.base_schema) - - # change column name from fid to id - modified_schema[0]["columns"][0]["name"] = "id" - equal, msg = same_schema(self.base_schema, modified_schema) - self.assertFalse(equal) - self.assertEqual( - msg, - "Fields in table 'Survey_points' added/removed: added: id; removed: fid", - ) - modified_schema[0]["columns"][0]["name"] = "fid" - - # change column type from datetime to date - modified_schema[0]["columns"][2]["type"] = "date" - equal, msg = same_schema(self.base_schema, modified_schema) - self.assertFalse(equal) - self.assertEqual(msg, "Definition of 'date' field in 'Survey_points' table is not the same") - - def test_datum_shift_grids(self): - grids = get_datum_shift_grids() - self.assertEqual(len(grids), 0) - - crs_a = QgsCoordinateReferenceSystem("EPSG:27700") - crs_b = QgsCoordinateReferenceSystem("EPSG:3857") - ops = QgsDatumTransform.operations(crs_a, crs_b) - self.assertTrue(len(ops) > 0) - proj_str = ops[0].proj - - context = QgsCoordinateTransformContext() - context.addCoordinateOperation(crs_a, crs_b, proj_str) - QgsProject.instance().setTransformContext(context) - - # if there are no layers which use datum transformtaions nothing - # should be returned - grids = get_datum_shift_grids() - self.assertEqual(len(grids), 0) - - layer = QgsVectorLayer("Point?crs=EPSG:27700", "", "memory") - QgsProject.instance().addMapLayer(layer) - QgsProject.instance().setCrs(crs_b) - - grids = get_datum_shift_grids() - self.assertEqual(len(grids), 1) - self.assertTrue("uk_os_OSTN15_NTv2_OSGBtoETRS.tif" in grids or "OSTN15_NTv2_OSGBtoETRS.gsb" in grids) - - def test_name_validation(self): - test_cases = [ - ("project", True), - ("ProJect", True), - ("Pro123ject", True), - ("123PROJECT", True), - ("PROJECT", True), - ("project ", True), - ("pro ject", True), - ("proj-ect", True), - ("-project", True), - ("proj_ect", True), - ("proj.ect", True), - ("proj!ect", True), - (" project", False), - (".project", False), - ("proj~ect", False), - (r"pro\ject", False), - ("pro/ject", False), - ("pro|ject", False), - ("pro+ject", False), - ("pro=ject", False), - ("pro>ject", False), - ("pro Path: + """Fixture for test data path.""" + return Path(__file__).parent / "data" + + +@pytest.fixture +def mem_layer() -> QgsVectorLayer: + """Fixture for an in-memory vector layer.""" + return QgsVectorLayer("Point", "test", "memory") + + +@pytest.fixture +def dem_tif_path(test_data_path: Path) -> Path: + """Fixture for DEM TIFF file path.""" + return test_data_path / "dem.tif" + + +@pytest.fixture +def raster_tiles_path(test_data_path: Path) -> Path: + """Fixture for raster tiles MBTiles file path.""" + return test_data_path / "raster-tiles.mbtiles" + + +@pytest.fixture +def vector_tiles_path(test_data_path: Path) -> Path: + """Fixture for vector tiles MBTiles file path.""" + return test_data_path / "vector-tiles.mbtiles" + + +@pytest.fixture +def base_schema(test_data_path: Path) -> Dict: + """Fixture for base schema used in tests.""" + with open(test_data_path / "schema_base.json") as f: + return json.load(f).get("geodiff_schema") + + +@pytest.fixture +def tables_schema(test_data_path: Path) -> Dict: + """Fixture for tables schema used in tests.""" + with open(test_data_path / "schema_two_tables.json") as f: + return json.load(f).get("geodiff_schema") + + +@pytest.fixture +def project_dir(mem_layer: QgsVectorLayer, tmp_path: Path) -> Path: + """Fixture for a QGIS project directory.""" + proj = QgsProject.instance() + proj.addMapLayer(mem_layer) + proj.setFileName(str(tmp_path / "test_project.qgz")) + yield tmp_path + proj.removeMapLayer(mem_layer.id()) diff --git a/Mergin/test/data/dem.prj b/tests/data/dem.prj similarity index 100% rename from Mergin/test/data/dem.prj rename to tests/data/dem.prj diff --git a/Mergin/test/data/dem.qpj b/tests/data/dem.qpj similarity index 100% rename from Mergin/test/data/dem.qpj rename to tests/data/dem.qpj diff --git a/Mergin/test/data/dem.tfw b/tests/data/dem.tfw similarity index 100% rename from Mergin/test/data/dem.tfw rename to tests/data/dem.tfw diff --git a/Mergin/test/data/dem.tif b/tests/data/dem.tif similarity index 100% rename from Mergin/test/data/dem.tif rename to tests/data/dem.tif diff --git a/Mergin/test/data/dem.tifw b/tests/data/dem.tifw similarity index 100% rename from Mergin/test/data/dem.tifw rename to tests/data/dem.tifw diff --git a/Mergin/test/data/dem.wld b/tests/data/dem.wld similarity index 100% rename from Mergin/test/data/dem.wld rename to tests/data/dem.wld diff --git a/Mergin/test/data/raster-tiles.mbtiles b/tests/data/raster-tiles.mbtiles similarity index 100% rename from Mergin/test/data/raster-tiles.mbtiles rename to tests/data/raster-tiles.mbtiles diff --git a/Mergin/test/data/schema_base.json b/tests/data/schema_base.json similarity index 100% rename from Mergin/test/data/schema_base.json rename to tests/data/schema_base.json diff --git a/Mergin/test/data/schema_two_tables.json b/tests/data/schema_two_tables.json similarity index 100% rename from Mergin/test/data/schema_two_tables.json rename to tests/data/schema_two_tables.json diff --git a/Mergin/test/data/transport_aerodrome.svg b/tests/data/transport_aerodrome.svg similarity index 100% rename from Mergin/test/data/transport_aerodrome.svg rename to tests/data/transport_aerodrome.svg diff --git a/Mergin/test/data/vector-tiles.mbtiles b/tests/data/vector-tiles.mbtiles similarity index 100% rename from Mergin/test/data/vector-tiles.mbtiles rename to tests/data/vector-tiles.mbtiles diff --git a/tests/test_help.py b/tests/test_help.py new file mode 100644 index 00000000..138a4cd9 --- /dev/null +++ b/tests/test_help.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +# GPLv3 license +# Copyright Lutra Consulting Limited + +import urllib.request + +from Mergin.help import MerginHelp + + +def test_help_urls(): + mh = MerginHelp() + + req = urllib.request.Request(mh.howto_attachment_widget(), method="HEAD") + resp = urllib.request.urlopen(req) + assert resp.status == 200 + + req = urllib.request.Request(mh.howto_background_maps(), method="HEAD") + resp = urllib.request.urlopen(req) + assert resp.status == 200 diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..2910c254 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# GPLv3 license +# Copyright Lutra Consulting Limited + + +import os +import tempfile +from pathlib import Path + +from qgis.core import QgsProviderRegistry, QgsRasterLayer, QgsVectorTileLayer + +from Mergin.utils import package_layer + +test_data_path = os.path.join(os.path.dirname(__file__), "data") + + +def test_copy_raster(dem_tif_path: Path): + """Test packaging of raster layer and updating data source.""" + + layer = QgsRasterLayer(dem_tif_path.as_posix(), "test", "gdal") + + assert layer.isValid() + + source_raster_uri = layer.dataProvider().dataSourceUri() + + assert source_raster_uri == dem_tif_path.as_posix() + + with tempfile.TemporaryDirectory() as tmp_dir: + package_layer(layer, tmp_dir) + for ext in ("tif", "wld", "tfw", "prj", "qpj", "tifw"): + expected_filepath = Path(tmp_dir) / f"dem.{ext}" + assert expected_filepath.exists() + + if ext == "tif": + # Check if raster data source was updated + destination_raster_uri = layer.dataProvider().dataSourceUri() + assert destination_raster_uri == expected_filepath.as_posix() + + +def test_mbtiles_packaging_raster_layer(raster_tiles_path: Path): + """Test packaging of raster and vector tiles layers and updating data source.""" + + rlayer = QgsRasterLayer(f"url=file://{raster_tiles_path.as_posix()}&type=mbtiles", "test", "wms") + + assert rlayer.isValid() + + with tempfile.TemporaryDirectory() as tmp_dir: + package_layer(rlayer, tmp_dir) + expected_path = Path(tmp_dir) / "raster-tiles.mbtiles" + assert expected_path.exists() + + uri = QgsProviderRegistry.instance().decodeUri("wms", rlayer.source()) + assert str(uri) + assert "path" in uri + assert uri["path"] == expected_path.as_posix() + + +def test_mbtiles_packaging_vector_tile_layer(vector_tiles_path: Path): + + vlayer = QgsVectorTileLayer(f"url=file://{vector_tiles_path.as_posix()}&type=mbtiles", "test") + assert vlayer.isValid() + + with tempfile.TemporaryDirectory() as tmp_dir: + package_layer(vlayer, tmp_dir) + expected_path = Path(tmp_dir) / "vector-tiles.mbtiles" + + assert expected_path.exists() + + uri = QgsProviderRegistry.instance().decodeUri("vectortile", vlayer.source()) + assert "path" in uri + assert uri["path"] == expected_path.as_posix() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..ce09e809 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +# GPLv3 license +# Copyright Lutra Consulting Limited + + +import copy +import tempfile +from pathlib import Path +from typing import Dict + +from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsCoordinateTransformContext, + QgsDatumTransform, + QgsProject, + QgsSymbolLayer, + QgsVectorLayer, + QgsWkbTypes, +) +from qgis.PyQt.QtCore import QVariant + +from Mergin.utils import ( + create_map_sketches_layer, + create_tracking_layer, + get_datum_shift_grids, + is_valid_name, + same_schema, +) + + +def test_table_added_removed(base_schema: Dict, tables_schema: Dict): + equal, msg = same_schema(base_schema, base_schema) + assert equal + assert msg == "No schema changes" + + equal, msg = same_schema(base_schema, tables_schema) + assert not equal + assert msg == "Tables added/removed: added: hotels" + + equal, msg = same_schema(tables_schema, base_schema) + assert not equal + assert msg == "Tables added/removed: removed: hotels" + + +def test_table_schema_changed(base_schema: Dict): + modified_schema = copy.deepcopy(base_schema) + + # change column name from fid to id + modified_schema[0]["columns"][0]["name"] = "id" + equal, msg = same_schema(base_schema, modified_schema) + assert not equal + assert msg == "Fields in table 'Survey_points' added/removed: added: id; removed: fid" + modified_schema[0]["columns"][0]["name"] = "fid" + + # change column type from datetime to date + modified_schema[0]["columns"][2]["type"] = "date" + equal, msg = same_schema(base_schema, modified_schema) + assert not equal + assert msg == "Definition of 'date' field in 'Survey_points' table is not the same" + + +def test_datum_shift_grids(): + grids = get_datum_shift_grids() + assert len(grids) == 0 + + crs_a = QgsCoordinateReferenceSystem("EPSG:27700") + crs_b = QgsCoordinateReferenceSystem("EPSG:3857") + ops = QgsDatumTransform.operations(crs_a, crs_b) + assert len(ops) > 0 + proj_str = ops[0].proj + + context = QgsCoordinateTransformContext() + context.addCoordinateOperation(crs_a, crs_b, proj_str) + QgsProject.instance().setTransformContext(context) + + # if there are no layers which use datum transformations nothing should be returned + grids = get_datum_shift_grids() + assert len(grids) == 0 + + layer = QgsVectorLayer("Point?crs=EPSG:27700", "", "memory") + QgsProject.instance().addMapLayer(layer) + QgsProject.instance().setCrs(crs_b) + + grids = get_datum_shift_grids() + assert len(grids) == 1 + assert "uk_os_OSTN15_NTv2_OSGBtoETRS.tif" in grids or "OSTN15_NTv2_OSGBtoETRS.gsb" in grids + + QgsProject.instance().removeMapLayer(layer.id()) + + +def test_name_validation(): + test_cases = [ + ("project", True), + ("ProJect", True), + ("Pro123ject", True), + ("123PROJECT", True), + ("PROJECT", True), + ("project ", True), + ("pro ject", True), + ("proj-ect", True), + ("-project", True), + ("proj_ect", True), + ("proj.ect", True), + ("proj!ect", True), + (" project", False), + (".project", False), + ("proj~ect", False), + (r"pro\ject", False), + ("pro/ject", False), + ("pro|ject", False), + ("pro+ject", False), + ("pro=ject", False), + ("pro>ject", False), + ("pro