diff --git a/.claude/commands/write-tests.md b/.claude/commands/write-tests.md new file mode 100644 index 0000000..c5dadbf --- /dev/null +++ b/.claude/commands/write-tests.md @@ -0,0 +1,164 @@ +--- +description: Use this skill when the user asks to write, create, or add tests for a module, function, or class in data_wizard - for example "schreib Tests für processor", "write tests for detect_hu_function_field", "add test coverage for X", "fehlende Tests ergänzen". Invoke automatically whenever a testing task is identified for this QGIS plugin project. +--- + +# /write-tests — Write Tests for a data_wizard Module + +Write pytest tests for the module: **$ARGUMENTS** + +Follow these steps in order. Do not skip any step. + +--- + +## Step 1 — Read the target module + +Search for `$ARGUMENTS` in these locations (in order): +- `$ARGUMENTS.py` (root level - `processor.py`, `data_wizard.py`, `data_wizard_dialog.py`) +- `scripts/$ARGUMENTS.py` + +Read the file completely. Identify: +- All public functions/classes and their parameters and types +- Return values and their types +- Error conditions and how they are handled (which exceptions, with what message) +- Any calls to `processing.run()` — these determine the tier (see Step 3) +- Any `log`/`feedback` and `task` (cancellation) parameters — every + processor.py function that has these needs at least one test for the + cancel-mid-run path and the log-callback-invoked path + +## Step 2 — Check for existing tests + +Search `test/` for an existing test file for `$ARGUMENTS`: +- `test/test_$ARGUMENTS.py` (snake_case variant) +- Any file matching `test_*$ARGUMENTS*` + +If a test file **exists**: extend it, do not replace it. Match its existing +class/fixture structure. +If no test file exists: create `test/test_.py`. + +## Step 3 — Consult project rules (mandatory) + +Read **all** of these files before writing any code: + +1. `docs/test-strategy.md` — tier definitions, coverage targets, module mapping, gap backlog (authoritative source) +2. `ai/core/testing-rules.md` — tactical rules: geometry checks, QGIS NULL handling, test structure +3. `ai/core/qgis-api-rules.md` — QGIS API compatibility rules, Processing initialization +4. `ai/core/constraints.md` — language and naming rules + +Also read: +- `test/utilities.py` — QGIS app initialisation + Processing.initialize() +- `test/conftest.py` — sys.path setup (no fixtures, no QGIS imports) +- `test/layer_factories.py` — shared layer/geometry factory helpers + +## Step 4 — Write the test file + +### Tier decision + +``` +Does the function under test call processing.run()? +├── No → @pytest.mark.unit +└── Yes → @pytest.mark.integration (requires Docker / local QGIS with Processing.initialize()) + +Is this a boundary or degenerate input? +└── Yes → additionally add @pytest.mark.edge_case +``` + +### Required structure + +```python +import pytest +from qgis.core import ( + QgsVectorLayer, QgsFeature, QgsField, QgsGeometry, + QgsCoordinateReferenceSystem, QgsPointXY, QgsWkbTypes, NULL, +) +from qgis.PyQt.QtCore import QVariant + +from .utilities import get_qgis_app + +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() + +from .layer_factories import ( + make_polygon_layer, make_line_layer, make_point_layer, + make_square_geom, add_feature_to_layer, + write_layer_as_shp, write_layer_as_gpkg, +) + +from data_wizard. import + + +class Test: + """Tests for ..""" + + @pytest.mark.unit + def test_normal_case(self): + """""" + ... +``` + +`QgsField(name, type, len=...)` — always pass `type` as `QVariant.String` (or +the correct `QVariant` constant) explicitly, and `len` as a **keyword** +argument. `QgsField(name, 10)` silently creates a field with an invalid type +enum, which OGR then drops on write — a common, hard-to-spot mistake. + +### Required geometry assertions (mandatory after every geometry-returning operation) + +```python +assert result_layer is not None +for feat in result_layer.getFeatures(): + geom = feat.geometry() + assert not geom.isNull(), "Geometry must not be null" + assert not geom.isEmpty(), "Geometry must not be empty" + assert geom.isGeosValid(), "Geometry must be GEOS-valid" +``` + +### QGIS NULL vs. Python None + +When testing attribute values that may be unset, remember `NULL` (imported +from `qgis.core`) is the correct sentinel to compare against — `value is None` +never matches a QGIS `NULL` attribute. See `ai/core/testing-rules.md`. + +### Mandatory test cases (minimum, per function) + +1. **Normal case** — valid input; check return value, feature count, geometry validity +2. **At least one error path** — invalid input that should raise a specific, + documented exception (`FileNotFoundError`/`ValueError`/`IOError`) — assert + the exception type and, where the message is meaningful, match on it +3. At least one **domain-specific edge case** (`@pytest.mark.edge_case`) — see + the catalog in `docs/test-strategy.md` → Test Taxonomy → Edge case +4. If the function takes `task=None` — a cancel-mid-run test + (`task.isCanceled()` becomes `True` after the first check) + +### If a test reveals a bug in the production code + +Do not write the test to assert the buggy behavior. Write it to assert the +*intended* behavior, mark it `@pytest.mark.xfail(reason="...", strict=True)` +explaining the bug, and add it to `docs/test-strategy.md` → Gap Analysis. See +`ai/core/testing-rules.md` → "Known Bugs Found by Tests". + +### Docstring rule (mandatory) + +Every test method must have a one-line docstring in the imperative mood: + +```python +def test_writes_valid_hu_gpkg(self): + """Writes HU.gpkg with valid MultiPolygon geometries and preserved fields.""" +``` + +## Step 5 — Run the tests + +```bash +python-qgis.bat -m pytest test/test_.py -v --tb=short # Windows +pytest test/test_.py -v --tb=short # QGIS env already active +``` + +Fix failures before proceeding — do not report a test file as done with +failing tests, and do not weaken an assertion just to make it pass without +understanding why it failed first (see "If a test reveals a bug" above). + +## Step 6 — Output + +Report: +1. Path of the created/modified test file +2. List of test methods written and what each covers +3. Which tier markers were applied and why +4. Any assumptions made about expected behavior +5. Any bugs found and marked `xfail`, with a pointer to the Gap Analysis entry diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0d47709 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,15 @@ +# .coveragerc +[run] +source = + data_wizard + scripts +omit = + */test/* + */tests/* + */__pycache__/* + */build/* + */dist/* + */venv/* + */.venv/* + */resources.py + */ui_*.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aa32d7..599460d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,17 +37,24 @@ jobs: echo "ERROR: coverage.xml not found after test run" exit 1 fi - # Fix absolute container paths → relative repo paths for Codecov + # Fix absolute container paths → relative repo paths sed -i 's|/plugins/data_wizard/||g' coverage.xml echo "coverage.xml found and paths fixed" head -5 coverage.xml - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + # No Codecov project exists for this repository (see + # docs/test-strategy.md → Justified Exclusions and + # docs/contributing.md → Coverage Reporting). Coverage is produced + # locally and in the container but not uploaded to an external + # service — the report is kept as a downloadable CI artifact instead. + - name: Upload coverage report as CI artifact + uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage.xml - fail_ci_if_error: true + name: coverage-report + path: | + coverage.xml + htmlcov/ + retention-days: 30 - name: Fail job if tests failed if: steps.run_tests.outcome == 'failure' diff --git a/.gitignore b/.gitignore index 89dff9e..8b079be 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ test/logs/ test/*.gpkg .pytest_cache/ .coverage +coverage.xml htmlcov/ # === QGIS Plugin generierte Dateien === diff --git a/Dockerfile b/Dockerfile index 471f5f8..af3e460 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,16 @@ WORKDIR /plugins/data_wizard # 7. Test-spezifische Python-Abhängigkeiten installieren RUN if [ -f requirements-test.txt ]; then pip3 install --break-system-packages -r requirements-test.txt; fi +# 7b. Übersetzungen kompilieren (i18n/*.ts -> *.qm) als Fallback für Aufrufe +# ohne Volume-Mount (z.B. "docker run -it ... /bin/bash"). Wirkungslos +# für den eigentlichen Testlauf in ci.yml: dessen +# "-v $(pwd):/plugins/data_wizard" überschreibt den kompletten +# /plugins/data_wizard-Baum aus dem Image mit dem (ungebauten) Host- +# Checkout, bevor CMD läuft - deshalb kompiliert CMD unten zusätzlich +# und maßgeblich bei jedem Containerstart neu. lrelease kommt mit +# qttools5-dev-tools, das im Basis-Image bereits installiert ist. +RUN for ts in i18n/*.ts; do lrelease "$ts"; done + # 8. QGIS Processing Provider explizit initialisieren RUN python3 -c "\ import sys; \ @@ -51,5 +61,10 @@ Processing.initialize(); \ print('Processing erfolgreich initialisiert'); \ app.exitQgis()" -# 9. Finale Test-Ausführung -CMD ["python3", "-m", "pytest", "test/", "-v", "--tb=short", "--cov", "--cov-report=xml", "--cov-report=html"] +# 9. Finale Test-Ausführung. Übersetzungen werden hier (erneut) kompiliert, +# weil ci.yml den Container mit "-v $(pwd):/plugins/data_wizard" startet - +# das ersetzt den kompletten Verzeichnisinhalt aus dem Image durch den +# Host-Checkout (der .qm bewusst nicht enthält, siehe .gitignore) BEVOR +# dieser CMD läuft. Ohne diesen Schritt hier würde test_translations.py +# trotz Schritt 7b im Build immer fehlschlagen. +CMD ["sh", "-c", "for ts in i18n/*.ts; do lrelease \"$ts\"; done && python3 -m pytest test/ -v --tb=short --cov --cov-report=xml --cov-report=html"] diff --git a/Makefile b/Makefile index e7a302d..a7e7051 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,10 @@ #Add iso code for any locales you want to support here (space separated) # default is no locales # LOCALES = af -LOCALES = +# NOTE: scripts/compile-strings.sh and update-strings.sh build the path as +# i18n/$(LOCALE).ts, so this must be the .ts file's basename, not just the +# ISO code (the plugin's translator loads i18n/Data_Wizard_.qm). +LOCALES = Data_Wizard_de # If locales are enabled, set the name of the lrelease binary on your system. If # you have trouble compiling the translations, you may have to specify the full path to diff --git "a/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.cpg" "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.cpg" new file mode 100644 index 0000000..3ad133c --- /dev/null +++ "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.cpg" @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git "a/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.dbf" "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.dbf" new file mode 100644 index 0000000..1ebe07d Binary files /dev/null and "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.dbf" differ diff --git "a/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.prj" "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.prj" new file mode 100644 index 0000000..66ea551 --- /dev/null +++ "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.prj" @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git "a/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shp" "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shp" new file mode 100644 index 0000000..077ba5f Binary files /dev/null and "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shp" differ diff --git "a/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shx" "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shx" new file mode 100644 index 0000000..c7c56ec Binary files /dev/null and "b/Testdaten/ALKIS Geb\303\244ude/GebauedeBauwerk.shx" differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_f.cpg b/Testdaten/ATKIS Basis DLM dataset/geb01_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb01_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_f.dbf b/Testdaten/ATKIS Basis DLM dataset/geb01_f.dbf new file mode 100644 index 0000000..ee6b14e Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_f.prj b/Testdaten/ATKIS Basis DLM dataset/geb01_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb01_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_f.shp b/Testdaten/ATKIS Basis DLM dataset/geb01_f.shp new file mode 100644 index 0000000..68514bf Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_f.shx b/Testdaten/ATKIS Basis DLM dataset/geb01_f.shx new file mode 100644 index 0000000..bc6cc00 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_l.cpg b/Testdaten/ATKIS Basis DLM dataset/geb01_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb01_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_l.dbf b/Testdaten/ATKIS Basis DLM dataset/geb01_l.dbf new file mode 100644 index 0000000..953eb6b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_l.prj b/Testdaten/ATKIS Basis DLM dataset/geb01_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb01_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_l.shp b/Testdaten/ATKIS Basis DLM dataset/geb01_l.shp new file mode 100644 index 0000000..d199b3d Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb01_l.shx b/Testdaten/ATKIS Basis DLM dataset/geb01_l.shx new file mode 100644 index 0000000..6834519 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb01_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb02_p.cpg b/Testdaten/ATKIS Basis DLM dataset/geb02_p.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb02_p.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb02_p.dbf b/Testdaten/ATKIS Basis DLM dataset/geb02_p.dbf new file mode 100644 index 0000000..18f6052 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb02_p.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb02_p.prj b/Testdaten/ATKIS Basis DLM dataset/geb02_p.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb02_p.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb02_p.shp b/Testdaten/ATKIS Basis DLM dataset/geb02_p.shp new file mode 100644 index 0000000..22bcd12 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb02_p.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb02_p.shx b/Testdaten/ATKIS Basis DLM dataset/geb02_p.shx new file mode 100644 index 0000000..50a669d Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb02_p.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb03_f.cpg b/Testdaten/ATKIS Basis DLM dataset/geb03_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb03_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb03_f.dbf b/Testdaten/ATKIS Basis DLM dataset/geb03_f.dbf new file mode 100644 index 0000000..78409a7 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb03_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb03_f.prj b/Testdaten/ATKIS Basis DLM dataset/geb03_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/geb03_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/geb03_f.shp b/Testdaten/ATKIS Basis DLM dataset/geb03_f.shp new file mode 100644 index 0000000..295e9cc Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb03_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/geb03_f.shx b/Testdaten/ATKIS Basis DLM dataset/geb03_f.shx new file mode 100644 index 0000000..a6965b1 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/geb03_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_f.cpg b/Testdaten/ATKIS Basis DLM dataset/gew01_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew01_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_f.dbf b/Testdaten/ATKIS Basis DLM dataset/gew01_f.dbf new file mode 100644 index 0000000..8d3b42b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_f.prj b/Testdaten/ATKIS Basis DLM dataset/gew01_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew01_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_f.shp b/Testdaten/ATKIS Basis DLM dataset/gew01_f.shp new file mode 100644 index 0000000..4b85df0 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_f.shx b/Testdaten/ATKIS Basis DLM dataset/gew01_f.shx new file mode 100644 index 0000000..dc0f13b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_l.cpg b/Testdaten/ATKIS Basis DLM dataset/gew01_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew01_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_l.dbf b/Testdaten/ATKIS Basis DLM dataset/gew01_l.dbf new file mode 100644 index 0000000..ca3bb1d Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_l.prj b/Testdaten/ATKIS Basis DLM dataset/gew01_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew01_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_l.shp b/Testdaten/ATKIS Basis DLM dataset/gew01_l.shp new file mode 100644 index 0000000..381c492 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew01_l.shx b/Testdaten/ATKIS Basis DLM dataset/gew01_l.shx new file mode 100644 index 0000000..cdfe023 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew01_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew02_p.cpg b/Testdaten/ATKIS Basis DLM dataset/gew02_p.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew02_p.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew02_p.dbf b/Testdaten/ATKIS Basis DLM dataset/gew02_p.dbf new file mode 100644 index 0000000..fbb880c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew02_p.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew02_p.prj b/Testdaten/ATKIS Basis DLM dataset/gew02_p.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew02_p.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew02_p.shp b/Testdaten/ATKIS Basis DLM dataset/gew02_p.shp new file mode 100644 index 0000000..3672f3a Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew02_p.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew02_p.shx b/Testdaten/ATKIS Basis DLM dataset/gew02_p.shx new file mode 100644 index 0000000..8ca2b07 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew02_p.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew03_l.cpg b/Testdaten/ATKIS Basis DLM dataset/gew03_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew03_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew03_l.dbf b/Testdaten/ATKIS Basis DLM dataset/gew03_l.dbf new file mode 100644 index 0000000..eeada70 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew03_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew03_l.prj b/Testdaten/ATKIS Basis DLM dataset/gew03_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/gew03_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/gew03_l.shp b/Testdaten/ATKIS Basis DLM dataset/gew03_l.shp new file mode 100644 index 0000000..08da920 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew03_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/gew03_l.shx b/Testdaten/ATKIS Basis DLM dataset/gew03_l.shx new file mode 100644 index 0000000..aa66fcb Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/gew03_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/hdu01_b.cpg b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/hdu01_b.dbf b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.dbf new file mode 100644 index 0000000..e49d59c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/hdu01_b.prj b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shp b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shp new file mode 100644 index 0000000..e0b7fe5 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shx b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shx new file mode 100644 index 0000000..07bfa2c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/hdu01_b.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/rel01_l.cpg b/Testdaten/ATKIS Basis DLM dataset/rel01_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/rel01_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/rel01_l.dbf b/Testdaten/ATKIS Basis DLM dataset/rel01_l.dbf new file mode 100644 index 0000000..e7cfdeb Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/rel01_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/rel01_l.prj b/Testdaten/ATKIS Basis DLM dataset/rel01_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/rel01_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/rel01_l.shp b/Testdaten/ATKIS Basis DLM dataset/rel01_l.shp new file mode 100644 index 0000000..1c42f6f Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/rel01_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/rel01_l.shx b/Testdaten/ATKIS Basis DLM dataset/rel01_l.shx new file mode 100644 index 0000000..e4ffb9c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/rel01_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie01_f.cpg b/Testdaten/ATKIS Basis DLM dataset/sie01_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie01_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie01_f.dbf b/Testdaten/ATKIS Basis DLM dataset/sie01_f.dbf new file mode 100644 index 0000000..d1aab91 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie01_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie01_f.prj b/Testdaten/ATKIS Basis DLM dataset/sie01_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie01_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie01_f.shp b/Testdaten/ATKIS Basis DLM dataset/sie01_f.shp new file mode 100644 index 0000000..c06f86b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie01_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie01_f.shx b/Testdaten/ATKIS Basis DLM dataset/sie01_f.shx new file mode 100644 index 0000000..536f840 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie01_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie02_f.cpg b/Testdaten/ATKIS Basis DLM dataset/sie02_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie02_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie02_f.dbf b/Testdaten/ATKIS Basis DLM dataset/sie02_f.dbf new file mode 100644 index 0000000..8de35ec Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie02_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie02_f.prj b/Testdaten/ATKIS Basis DLM dataset/sie02_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie02_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie02_f.shp b/Testdaten/ATKIS Basis DLM dataset/sie02_f.shp new file mode 100644 index 0000000..c68ccae Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie02_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie02_f.shx b/Testdaten/ATKIS Basis DLM dataset/sie02_f.shx new file mode 100644 index 0000000..28f01eb Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie02_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_f.cpg b/Testdaten/ATKIS Basis DLM dataset/sie03_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_f.dbf b/Testdaten/ATKIS Basis DLM dataset/sie03_f.dbf new file mode 100644 index 0000000..672352e Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_f.prj b/Testdaten/ATKIS Basis DLM dataset/sie03_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_f.shp b/Testdaten/ATKIS Basis DLM dataset/sie03_f.shp new file mode 100644 index 0000000..9c453ac Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_f.shx b/Testdaten/ATKIS Basis DLM dataset/sie03_f.shx new file mode 100644 index 0000000..832c8dd Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_l.cpg b/Testdaten/ATKIS Basis DLM dataset/sie03_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_l.dbf b/Testdaten/ATKIS Basis DLM dataset/sie03_l.dbf new file mode 100644 index 0000000..a420986 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_l.prj b/Testdaten/ATKIS Basis DLM dataset/sie03_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_l.shp b/Testdaten/ATKIS Basis DLM dataset/sie03_l.shp new file mode 100644 index 0000000..1b1a8f1 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_l.shx b/Testdaten/ATKIS Basis DLM dataset/sie03_l.shx new file mode 100644 index 0000000..11dfd04 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_p.cpg b/Testdaten/ATKIS Basis DLM dataset/sie03_p.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_p.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_p.dbf b/Testdaten/ATKIS Basis DLM dataset/sie03_p.dbf new file mode 100644 index 0000000..ee6b009 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_p.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_p.prj b/Testdaten/ATKIS Basis DLM dataset/sie03_p.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/sie03_p.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_p.shp b/Testdaten/ATKIS Basis DLM dataset/sie03_p.shp new file mode 100644 index 0000000..7fb76f7 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_p.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/sie03_p.shx b/Testdaten/ATKIS Basis DLM dataset/sie03_p.shx new file mode 100644 index 0000000..d20e057 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/sie03_p.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg01_f.cpg b/Testdaten/ATKIS Basis DLM dataset/veg01_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg01_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg01_f.dbf b/Testdaten/ATKIS Basis DLM dataset/veg01_f.dbf new file mode 100644 index 0000000..360c6e2 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg01_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg01_f.prj b/Testdaten/ATKIS Basis DLM dataset/veg01_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg01_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg01_f.shp b/Testdaten/ATKIS Basis DLM dataset/veg01_f.shp new file mode 100644 index 0000000..989588b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg01_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg01_f.shx b/Testdaten/ATKIS Basis DLM dataset/veg01_f.shx new file mode 100644 index 0000000..3d50bd5 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg01_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg02_f.cpg b/Testdaten/ATKIS Basis DLM dataset/veg02_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg02_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg02_f.dbf b/Testdaten/ATKIS Basis DLM dataset/veg02_f.dbf new file mode 100644 index 0000000..7f2b9c4 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg02_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg02_f.prj b/Testdaten/ATKIS Basis DLM dataset/veg02_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg02_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg02_f.shp b/Testdaten/ATKIS Basis DLM dataset/veg02_f.shp new file mode 100644 index 0000000..48aada8 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg02_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg02_f.shx b/Testdaten/ATKIS Basis DLM dataset/veg02_f.shx new file mode 100644 index 0000000..a66753a Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg02_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg03_f.cpg b/Testdaten/ATKIS Basis DLM dataset/veg03_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg03_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg03_f.dbf b/Testdaten/ATKIS Basis DLM dataset/veg03_f.dbf new file mode 100644 index 0000000..e338fdc Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg03_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg03_f.prj b/Testdaten/ATKIS Basis DLM dataset/veg03_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg03_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg03_f.shp b/Testdaten/ATKIS Basis DLM dataset/veg03_f.shp new file mode 100644 index 0000000..90e17bc Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg03_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg03_f.shx b/Testdaten/ATKIS Basis DLM dataset/veg03_f.shx new file mode 100644 index 0000000..816201b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg03_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_f.cpg b/Testdaten/ATKIS Basis DLM dataset/veg04_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_f.dbf b/Testdaten/ATKIS Basis DLM dataset/veg04_f.dbf new file mode 100644 index 0000000..62642b5 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_f.prj b/Testdaten/ATKIS Basis DLM dataset/veg04_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_f.shp b/Testdaten/ATKIS Basis DLM dataset/veg04_f.shp new file mode 100644 index 0000000..9b69fc4 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_f.shx b/Testdaten/ATKIS Basis DLM dataset/veg04_f.shx new file mode 100644 index 0000000..51a7b0c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_l.cpg b/Testdaten/ATKIS Basis DLM dataset/veg04_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_l.dbf b/Testdaten/ATKIS Basis DLM dataset/veg04_l.dbf new file mode 100644 index 0000000..44706d3 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_l.prj b/Testdaten/ATKIS Basis DLM dataset/veg04_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_l.shp b/Testdaten/ATKIS Basis DLM dataset/veg04_l.shp new file mode 100644 index 0000000..1c3698b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_l.shx b/Testdaten/ATKIS Basis DLM dataset/veg04_l.shx new file mode 100644 index 0000000..752a4cc Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_p.cpg b/Testdaten/ATKIS Basis DLM dataset/veg04_p.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_p.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_p.dbf b/Testdaten/ATKIS Basis DLM dataset/veg04_p.dbf new file mode 100644 index 0000000..3390cb8 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_p.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_p.prj b/Testdaten/ATKIS Basis DLM dataset/veg04_p.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/veg04_p.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_p.shp b/Testdaten/ATKIS Basis DLM dataset/veg04_p.shp new file mode 100644 index 0000000..b910be6 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_p.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/veg04_p.shx b/Testdaten/ATKIS Basis DLM dataset/veg04_p.shx new file mode 100644 index 0000000..3ad136d Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/veg04_p.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_f.cpg b/Testdaten/ATKIS Basis DLM dataset/ver01_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver01_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_f.dbf b/Testdaten/ATKIS Basis DLM dataset/ver01_f.dbf new file mode 100644 index 0000000..3cc67c7 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_f.prj b/Testdaten/ATKIS Basis DLM dataset/ver01_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver01_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_f.shp b/Testdaten/ATKIS Basis DLM dataset/ver01_f.shp new file mode 100644 index 0000000..803493c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_f.shx b/Testdaten/ATKIS Basis DLM dataset/ver01_f.shx new file mode 100644 index 0000000..8f631ff Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_l.cpg b/Testdaten/ATKIS Basis DLM dataset/ver01_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver01_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_l.dbf b/Testdaten/ATKIS Basis DLM dataset/ver01_l.dbf new file mode 100644 index 0000000..4c18e09 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_l.prj b/Testdaten/ATKIS Basis DLM dataset/ver01_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver01_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_l.shp b/Testdaten/ATKIS Basis DLM dataset/ver01_l.shp new file mode 100644 index 0000000..7921c3d Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver01_l.shx b/Testdaten/ATKIS Basis DLM dataset/ver01_l.shx new file mode 100644 index 0000000..8786489 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver01_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver02_l.cpg b/Testdaten/ATKIS Basis DLM dataset/ver02_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver02_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver02_l.dbf b/Testdaten/ATKIS Basis DLM dataset/ver02_l.dbf new file mode 100644 index 0000000..de42f74 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver02_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver02_l.prj b/Testdaten/ATKIS Basis DLM dataset/ver02_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver02_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver02_l.shp b/Testdaten/ATKIS Basis DLM dataset/ver02_l.shp new file mode 100644 index 0000000..6f6f991 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver02_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver02_l.shx b/Testdaten/ATKIS Basis DLM dataset/ver02_l.shx new file mode 100644 index 0000000..d55fabd Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver02_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_f.cpg b/Testdaten/ATKIS Basis DLM dataset/ver03_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver03_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_f.dbf b/Testdaten/ATKIS Basis DLM dataset/ver03_f.dbf new file mode 100644 index 0000000..6ee624c Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_f.prj b/Testdaten/ATKIS Basis DLM dataset/ver03_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver03_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_f.shp b/Testdaten/ATKIS Basis DLM dataset/ver03_f.shp new file mode 100644 index 0000000..c041b65 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_f.shx b/Testdaten/ATKIS Basis DLM dataset/ver03_f.shx new file mode 100644 index 0000000..434dcc3 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_l.cpg b/Testdaten/ATKIS Basis DLM dataset/ver03_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver03_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_l.dbf b/Testdaten/ATKIS Basis DLM dataset/ver03_l.dbf new file mode 100644 index 0000000..abb1dc4 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_l.prj b/Testdaten/ATKIS Basis DLM dataset/ver03_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver03_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_l.shp b/Testdaten/ATKIS Basis DLM dataset/ver03_l.shp new file mode 100644 index 0000000..5123cf8 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver03_l.shx b/Testdaten/ATKIS Basis DLM dataset/ver03_l.shx new file mode 100644 index 0000000..bc13a01 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver03_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_f.cpg b/Testdaten/ATKIS Basis DLM dataset/ver06_f.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_f.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_f.dbf b/Testdaten/ATKIS Basis DLM dataset/ver06_f.dbf new file mode 100644 index 0000000..47da051 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_f.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_f.prj b/Testdaten/ATKIS Basis DLM dataset/ver06_f.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_f.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_f.shp b/Testdaten/ATKIS Basis DLM dataset/ver06_f.shp new file mode 100644 index 0000000..8ca2fbc Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_f.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_f.shx b/Testdaten/ATKIS Basis DLM dataset/ver06_f.shx new file mode 100644 index 0000000..5b1d033 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_f.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_l.cpg b/Testdaten/ATKIS Basis DLM dataset/ver06_l.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_l.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_l.dbf b/Testdaten/ATKIS Basis DLM dataset/ver06_l.dbf new file mode 100644 index 0000000..98d7ef9 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_l.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_l.prj b/Testdaten/ATKIS Basis DLM dataset/ver06_l.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_l.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_l.shp b/Testdaten/ATKIS Basis DLM dataset/ver06_l.shp new file mode 100644 index 0000000..0ea60de Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_l.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_l.shx b/Testdaten/ATKIS Basis DLM dataset/ver06_l.shx new file mode 100644 index 0000000..8f3f94b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_l.shx differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_p.cpg b/Testdaten/ATKIS Basis DLM dataset/ver06_p.cpg new file mode 100644 index 0000000..3ad133c --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_p.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_p.dbf b/Testdaten/ATKIS Basis DLM dataset/ver06_p.dbf new file mode 100644 index 0000000..267ec7b Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_p.dbf differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_p.prj b/Testdaten/ATKIS Basis DLM dataset/ver06_p.prj new file mode 100644 index 0000000..66ea551 --- /dev/null +++ b/Testdaten/ATKIS Basis DLM dataset/ver06_p.prj @@ -0,0 +1 @@ +PROJCS["ETRS_1989_UTM_Zone_33N",GEOGCS["GCS_ETRS_1989",DATUM["D_ETRS_1989",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_p.shp b/Testdaten/ATKIS Basis DLM dataset/ver06_p.shp new file mode 100644 index 0000000..5ff6f49 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_p.shp differ diff --git a/Testdaten/ATKIS Basis DLM dataset/ver06_p.shx b/Testdaten/ATKIS Basis DLM dataset/ver06_p.shx new file mode 100644 index 0000000..6699570 Binary files /dev/null and b/Testdaten/ATKIS Basis DLM dataset/ver06_p.shx differ diff --git a/ai/core/architecture-guidelines.md b/ai/core/architecture-guidelines.md new file mode 100644 index 0000000..a5ba5ce --- /dev/null +++ b/ai/core/architecture-guidelines.md @@ -0,0 +1,71 @@ +# Architecture Guidelines + +Guidelines for architectural decisions in the data_wizard project. + +## Module Layout + +data_wizard is intentionally a single-module plugin, not a package-of-packages +like IB-Tool-3: + +- **`processor.py`** - all ATKIS transformation logic, no QGIS UI or `iface` + access. Pure function pipeline: load → reproject/clip → transform → write. +- **`data_wizard.py`** - plugin entry point (`Data_Wizard` class), background + task orchestration (`_AtkisTask`), and input validation. Talks to `iface` + and `processor.py`, never the reverse. +- **`data_wizard_dialog.py`** - Qt dialog wrapper, thin getters/setters over + the `.ui` form plus the HU function-field detection/selection flow (which + itself delegates the actual detection to `processor.detect_hu_function_field`). + +Do not introduce a `data_wizard_tools/`-style subpackage unless a second +independent processing pipeline is added - one file per responsibility is +still proportionate to this plugin's scope. + +## Parameter Management + +- **Avoid over-engineering**: no config classes for `processor.py`'s + parameters - they are passed directly as function arguments + (`source_dir`, `hu_path`, `target_dir`, ...) +- **Module-level constants for fixed contracts**: `HU_FUNCTION_FIELDS`, + `FUNCTION_CODE_PATTERN` are defined once at the top of `processor.py`, + not duplicated across functions +- **Single Source of Truth**: the seven ATKIS layer names + (`ver01_l`/`ver02_l`/`ver03_l`/`veg02_f`/`veg03_f`/`gew01_f`/`gew01_l`) only + ever appear as string literals inside the `_process_*` functions that load + them - if this list needs to grow, promote it to a module-level tuple + instead of adding a new inline literal + +## Function Design + +- **Small, composable steps**: `process_atkis` is an orchestrator that calls + `_process_hu`/`_process_rn`/`_process_aux`, each of which calls shared + helpers (`_prepare_layer`, `_load_shp`, `_write_gpkg`) - keep this shape + when extending the pipeline rather than growing one function further +- **Stateless functions**: no module-level mutable state in `processor.py`; + every function receives everything it needs as a parameter +- **Optional `log`/`feedback` callback, not a required logger dependency**: + every `processor.py` function that reports progress takes an optional + `log=None` parameter and guards calls with `if log:` - do not introduce a + hard dependency on `QgsMessageLog` inside `processor.py` (that belongs in + `data_wizard.py`'s `_AtkisTask.run()`, which is the only place adapting + `feedback` to `QgsMessageLog.logMessage`) +- **Optional `task` parameter for cancellation**: every long-running + `processor.py` function accepts `task=None` and calls `_check_cancel(task)` + before each expensive step - preserve this signature shape when adding new + processing steps + +## Code Organization + +- **No magic numbers**: constants at module level, named + (`FUNCTION_CODE_PATTERN`, not an inline regex string) +- **Pragmatic refactoring**: `processor.py` is ~430 LOC across 12 functions - + this is proportionate to the plugin's scope; do not split it into a + package until it meaningfully grows beyond the ATKIS Basis-DLM → IBTool + input pipeline it currently implements + +## Testing Implications + +Because `processor.py` has no `iface`/UI dependency, it is directly unit- and +integration-testable without mocking QGIS's plugin interface - see +`ai/core/testing-rules.md`. Keep new `processor.py` functions free of `iface` +access so this property is preserved; anything that needs `iface` belongs in +`data_wizard.py`. diff --git a/ai/core/constraints.md b/ai/core/constraints.md new file mode 100644 index 0000000..3c55292 --- /dev/null +++ b/ai/core/constraints.md @@ -0,0 +1,90 @@ +# Constraints + +Binding rules for all code changes in the data_wizard project. + +For release-specific constraints (metadata.txt, LICENSE, ZIP packaging, CI), +see [release-conventions.md](release-conventions.md). + +## Language + +> **Deviation from IB-Tool-3:** IB-Tool-3's own `ai/core/constraints.md` +> mandates English for all code comments/docstrings, with German reserved for +> UI strings and end-user log output. `processor.py` in this repository does +> not follow that split - its docstrings, comments, and `log()` messages are +> German throughout, while identifiers are English. This is the actual, +> current convention here, not an oversight to silently "correct" by copying +> IB-Tool-3's rule - see the Testplan-data_wizard-ibtoolpartion.md +> "Nebenbefund #4" note, which flags the inconsistency across the three +> plugins as a separate decision to make deliberately, not as part of adding +> tests/docs. The table below documents what this repository actually does. + +| Content Type | Language | Examples | +|---|---|---| +| Code identifiers (function/class/variable names) | **English** | `process_atkis`, `_reproject_if_needed`, `target_crs` | +| `processor.py` docstrings, comments, `log()` messages | **German** (established convention - do not silently rewrite to English) | `"""Reprojiziert layer nach target_crs..."""` | +| `data_wizard.py`, `data_wizard_dialog.py` | **English** (already English throughout) | | +| Test files (`test/`) | **English** (docstrings, comments, assertions) | matches this repo's tests and IB-Tool-3's convention | +| Developer documentation (`ai/`, `docs/`) | **English** | All markdown files for AI/developer context | +| Commit messages, CHANGELOG (technical) | **English** | | +| UI strings (via `QCoreApplication.translate()`) | **English** source string, translated via `i18n/*.ts` (German provided) | Dialog labels, message bar text | + +When modifying existing code, match the language already used in the +function/file you are editing. Do not mix German and English within the same +docstring or the same log-message call. + +## Interface Access + +- **No direct access to `iface`** outside the main class (`data_wizard.py`) + and dialog (`data_wizard_dialog.py`) +- `processor.py` never receives `iface` as a parameter - it communicates + progress via the optional `feedback`/`log` callback and results via return + values / files written to `target_dir` + +## Variables and State + +- **No global variables** in `processor.py` - all state is passed as + parameters +- `processor.py` functions must be **stateless** and must not modify their + input layers (`_write_gpkg` reads from `input_layers` but never edits them; + `_add_function_field_copy` is the one documented exception - it edits the + layer it receives in place via `startEditing()`/`commitChanges()`, which is + why callers `materialize()` a fresh copy first) + +## Documentation + +- **Every new function** gets a docstring (German for `processor.py`, English + elsewhere - see Language above) +- **Every new class** gets a docstring describing its purpose +- Parameters with non-obvious meaning are explained in the docstring + (`processor.process_atkis`'s parameter list is the reference example) + +## Paths and Configuration + +- **No hardcoded paths** - all paths via parameters (`source_dir`, `hu_path`, + `target_dir`, `study_area_path`) +- Temporary files via `QgsProcessing.TEMPORARY_OUTPUT` + +## Numeric Values + +- **No magic numbers** without a named constant - e.g. `FUNCTION_CODE_PATTERN`, + `HU_FUNCTION_FIELDS` at module level in `processor.py`, not inlined into + the functions that use them + +## Error Handling + +- `_check_cancel(task)` before every expensive step (layer prep, + `processing.run()`, the GeoPackage write loop) in a cancellable pipeline +- Raise specific exceptions (`FileNotFoundError`, `ValueError`, `IOError`) with + a message that includes the offending path/value - never a bare `Exception` + except for the deliberate cancellation signal (`"Verarbeitung abgebrochen."`) +- Do not catch and discard exceptions silently - `_AtkisTask.run()` is the one + place that catches broadly (`except Exception as e`), because it is a + `QgsTask` boundary that must return `False` instead of crashing the + background thread; it stores the exception on `self.exception` and logs it + +## Strings + +- All user-visible strings in `data_wizard.py`/`data_wizard_dialog.py` must be + translatable via `self.tr(...)` / `QCoreApplication.translate(...)` +- `processor.py`'s `log()` messages are not translated (developer/log-file + audience, not the plugin's translated UI) diff --git a/ai/core/naming-conventions.md b/ai/core/naming-conventions.md new file mode 100644 index 0000000..8a0c582 --- /dev/null +++ b/ai/core/naming-conventions.md @@ -0,0 +1,65 @@ +# Naming Conventions + +## Python Identifiers + +| Element | Convention | Example | +|---------|-----------|---------| +| Functions | `snake_case` | `detect_hu_function_field()`, `process_atkis()` | +| Private/internal functions | `_snake_case` (leading underscore) | `_load_shp()`, `_write_gpkg()`, `_check_cancel()` | +| Methods | `snake_case` | `dialog.get_source_dir()` | +| Classes | `PascalCase` | `Data_Wizard`, `Data_WizardDialog`, `_AtkisTask` | +| Module-level constants | `UPPER_SNAKE_CASE` | `HU_FUNCTION_FIELDS`, `FUNCTION_CODE_PATTERN` | +| Local variables | `snake_case` | `target_crs`, `clip_mask`, `veg03_f` | +| Modules | `snake_case` | `processor.py`, `data_wizard.py` | +| Test modules | `test_*.py` | `test_processor.py` | +| Test classes | `PascalCase`, `Test` + subject | `TestDetectHuFunctionField`, `TestWriteGpkg` | +| Test methods | `test_` | `test_returns_ok_when_fkt_field_present` | + +## Allowed Abbreviations + +Established ATKIS/plugin-domain abbreviations that may be used without further explanation: + +| Abbreviation | Meaning | +|-------------|---------| +| `hu` | Hausumringe (building footprints) - also the output file `HU.gpkg` | +| `rn` | Road network (Straßennetz) - output file `RN.gpkg` | +| `aux` | Auxiliary lines (Hilfslinien) - output file `AUX_L.gpkg` | +| `crs` | Coordinate Reference System | +| `geom` | Geometry | +| `id` | Identifier | +| `wkt` | Well-Known Text | +| `wkb` | Well-Known Binary | +| `atkis` | Amtliches Topographisch-Kartographisches Informationssystem (source data model) | +| `ver01_l`, `ver02_l`, `ver03_l`, `veg02_f`, `veg03_f`, `gew01_f`, `gew01_l` | Fixed ATKIS Basis-DLM layer names (from `basis-dlm-aaa_ebenen_inhalt.csv`) - never rename in code, they are file-system contract with `source_dir` | +| `dlg` | Dialog (e.g. `self.dlg`) | +| `iface` | QGIS Interface object | + +All other terms must be spelled out. + +## Layer/Variable Names in processor.py + +- Raw ATKIS layers loaded from disk keep their ATKIS layer name as the + variable name (`ver01_l`, `veg03_f`, ...) - do not rename them to something + "friendlier"; the ATKIS name is the reference documentation +- Intermediate `processing.run()` results use a `p_` prefix + short + description of the step, e.g. `p_veg02_dis` (dissolved), `p_veg02_lines` + (polygons-to-lines) - see `_process_aux` for the pattern + +## File Names + +| Type | Convention | Example | +|------|-----------|---------| +| Plugin modules | `snake_case.py` | `processor.py`, `data_wizard_dialog.py` | +| Test modules | `test_*.py` | `test_processor.py` | +| Test infrastructure | `snake_case.py` | `layer_factories.py`, `utilities.py` | +| Configuration | `snake_case.*` | `pytest.ini`, `test_config.ini` | +| Documentation | `kebab-case.md` | `test-strategy.md`, `qgis-api-rules.md` | +| Output GeoPackages | `UPPER_SNAKE_CASE.gpkg` (fixed contract with IBTool) | `HU.gpkg`, `RN.gpkg`, `AUX_L.gpkg` | + +## Parameter Names + +- Descriptive, not generic: `hu_function_field` instead of `field`, `study_area_path` instead of `path` +- Boolean parameters as questions/states: `force_singlepart`, `keep_fields` +- `_path`/`_dir` suffix indicates a filesystem string parameter (`source_dir`, + `hu_path`, `target_dir`) - never a `Path` object, to match the rest of + `processor.py`'s `os.path`-based style diff --git a/ai/core/qgis-api-rules.md b/ai/core/qgis-api-rules.md new file mode 100644 index 0000000..732c4ff --- /dev/null +++ b/ai/core/qgis-api-rules.md @@ -0,0 +1,125 @@ +# QGIS API Rules + +## Core Classes + +### QgsVectorLayer + +```python +# Create a temporary layer +layer = QgsVectorLayer("Polygon?crs=EPSG:25833", "name", "memory") + +# Load a file-based layer +layer = QgsVectorLayer(path, layer_name, "ogr") + +# Feature iteration +for feature in layer.getFeatures(): + geom = feature.geometry() +``` + +- Always check `layer.isValid()` after creation (see `processor._load_shp`, + `processor.detect_hu_function_field`) +- Create temporary layers via the `"memory"` provider +- For file-based layers: path as the first parameter, provider `"ogr"` as the third + +### QgsFeature + +```python +feature = QgsFeature() +feature.setGeometry(geometry) +feature.setAttributes([value1, value2]) +``` + +- Set geometry and attributes separately +- Do not set feature IDs manually - assigned by the layer +- Check `feature.hasGeometry()` / `geom is None` before accessing geometry - + `processor._write_gpkg` skips features where `geom is None or geom.isNull() + or geom.isEmpty()` + +### QgsGeometry + +```python +geom = QgsGeometry.fromWkt(wkt_string) +geom = feature.geometry() + +# Multipart -> singlepart +parts = geom.asGeometryCollection() if geom.isMultipart() else [geom] +``` + +- Always validate the result of geometry operations (`isGeosValid()`) +- `isNull()` and `isEmpty()` are different states - check both +- Prefer QGIS Processing for complex operations (dissolve, clip, reproject) + +## Feature Attribute NULL + +A `NULL`/unset feature attribute is `qgis.core.NULL` (a `QVariant` sentinel), +**not** Python `None`. `value is None` never matches it; use +`value in (None, NULL)` or `value == NULL`. See `ai/core/testing-rules.md` → +"QGIS Attribute NULL vs. Python None" for the concrete bug this caused in +`processor._add_function_field_copy`. + +## QGIS Processing + +### Preferred Usage + +```python +result = processing.run("native:buffer", { + 'INPUT': input_layer, + 'DISTANCE': distance, + 'SEGMENTS': 5, + 'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT +}) +output_layer = result['OUTPUT'] +``` + +- Use `QgsProcessing.TEMPORARY_OUTPUT` for intermediate results +- Extract the result layer from the result dict +- Check `_check_cancel(task)` before every `processing.run()` call in a + cancellable pipeline (see every `_process_*` function in `processor.py`) + +### Algorithms used in this plugin + +| Algorithm | Purpose | Used in | +|-----------|---------|---------| +| `native:reprojectlayer` | Reproject a layer to the project CRS | `_reproject_if_needed` | +| `native:clip` | Clip to the optional study area | `_clip_if_needed` | +| `native:dissolve` | Merge geometries (study area, veg02_f/veg03_f) | `_prepare_clip_mask`, `_process_aux` | +| `native:polygonstolines` | Polygon boundaries -> lines (veg/gew inputs into AUX) | `_process_aux` | +| `native:extractbyexpression` | Filter `veg03_f` by `OBJART` 43005/43006 | `_process_aux` | + +### Processing initialization is not automatic + +`QgsApplication.initQgis()` does **not** register the `native:*` provider by +itself - that requires `processing.core.Processing.Processing.initialize()`. +Both the Dockerfile's build-time sanity check and `test/utilities.py`'s +`get_qgis_app()` call this explicitly. A standalone script that only calls +`initQgis()` and then `processing.run("native:dissolve", ...)` fails with +`Algorithm native:... not found`. + +## API Version Compatibility + +- **Target version**: QGIS 3.40 +- **No deprecated API** - check the QGIS Python API documentation + (`QgsField(name, type, len=...)` - passing `len` positionally instead of as + a keyword is a common source of silently-wrong field definitions; see + `ai/core/testing-rules.md`) +- When in doubt: use the QGIS PyQGIS Developer Cookbook as reference +- Use `QgsWkbTypes` constants (`QgsWkbTypes.MultiPolygon`, `.LineString`, ...) + instead of deprecated enums for geometry types + +## Coordinate Reference Systems + +```python +# Read CRS from layer +crs = layer.crs() + +# Create CRS object +crs = QgsCoordinateReferenceSystem("EPSG:25833") +``` + +- No implicit reprojection - `processor.py` always transforms explicitly via + `_reproject_if_needed`, comparing `layer.crs() == target_crs` first (an + identical CRS returns the same layer object, no transform call) +- `process_atkis` fixes the project CRS from `ver01_l`'s own CRS - every other + input (HU, study area, other ATKIS layers) is reprojected to match +- `_write_gpkg` raises `ValueError` if a later input layer has a different + **valid** CRS than the first layer - never silently mixes CRSes in one output diff --git a/ai/core/testing-rules.md b/ai/core/testing-rules.md new file mode 100644 index 0000000..877e720 --- /dev/null +++ b/ai/core/testing-rules.md @@ -0,0 +1,140 @@ +# Testing Rules + +> For the full test strategy - tier definitions, coverage targets, module mapping, and gap backlog - see [`docs/test-strategy.md`](../../docs/test-strategy.md). This file contains the tactical rules (geometry checks, framework, structure) that apply to every test. + +## Before Every Code Change + +1. **Understand existing logic**: Read the relevant code before making changes +2. **Run tests**: Existing tests must pass before and after the change +3. **No regression**: No existing functionality may break due to changes + +## Test Framework + +- **pytest** as the test framework +- Tests reside in `test/` following the pattern `test_*.py` +- `conftest.py` configures the test environment (`sys.path` only - no QGIS imports, see below) +- `test/utilities.py`'s `get_qgis_app()` creates the QGIS singleton **and** calls `Processing.initialize()` - call it once per test module before importing anything from `data_wizard` +- Docker environment for consistent QGIS test execution in CI + +## Test Execution + +```bash +# Docker (recommended - consistent environment) +docker build -t qgis-plugin-test . +docker run --rm qgis-plugin-test + +# Local (requires QGIS 3.40 installation) +python-qgis.bat -m pytest test/ -v # Windows, QGIS-bundled interpreter +pytest test/ -v # if QGIS python env is already active + +# Single test +pytest test/test_processor.py -v + +# Unit tests only (no Docker, no Processing needed beyond QGIS itself) +pytest test/ -m unit -v +``` + +## conftest.py Rule + +`conftest.py` must **never** import `qgis.*`. It only manipulates `sys.path` (adding the plugin's parent directory so `import data_wizard.processor` resolves). Importing QGIS in `conftest.py` triggers a circular import via `qgis.utils._import` before QGIS itself is initialized. QGIS setup happens per-test-module via `test/utilities.py`'s `get_qgis_app()`, called **before** any `data_wizard.*` or `.layer_factories` import: + +```python +from .utilities import get_qgis_app +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() +from .layer_factories import make_polygon_layer, add_feature_to_layer +from data_wizard.processor import detect_hu_function_field +``` + +## Testing Geometry Operations + +Include the following checks for every geometry operation: + +### Validity Check + +```python +result_geom = result_feature.geometry() +assert not result_geom.isNull(), "Geometry must not be null" +assert not result_geom.isEmpty(), "Geometry must not be empty" +assert result_geom.isGeosValid(), "Geometry must be valid" +``` + +### Multipart Check + +```python +if expect_singlepart: + assert not result_geom.isMultipart(), "Expected singlepart geometry" +``` + +Note: `_write_gpkg`'s `geometry_type` parameter determines the writer's declared +WKB type independently of `force_singlepart`. When testing `force_singlepart=True` +against `_write_gpkg` directly, pass the **singlepart** target type (e.g. +`QgsWkbTypes.Polygon`, not `QgsWkbTypes.MultiPolygon`) - matching how +`_process_rn`/`_process_aux` actually call it with `QgsWkbTypes.LineString`. + +### Feature Count + +```python +assert result_layer.featureCount() > 0, "Result must contain features" +``` + +## QGIS Attribute NULL vs. Python None + +A `NULL` QGIS feature attribute (`feat[idx]` for an unset field) is a `QVariant` +sentinel (`qgis.core.NULL`), **not** Python `None` - `value is None` is `False` +for it, `value == NULL` is `True`. Any test (or production code) that checks +`value is None` to detect a missing attribute is checking the wrong thing; see +the documented `_add_function_field_copy` bug in +[`docs/test-strategy.md`](../../docs/test-strategy.md#gap-analysis). + +## Error Messages + +- **Never silently swallow errors**: Every expected exception must be tested +- Test that error messages are meaningful (`pytest.raises(ValueError, match="...")`) +- Test edge cases: empty layers, null geometries, wrong CRS, non-overlapping study areas + +## Test Structure + +```python +class TestFunctionName: + """Tests for module.function_name.""" + + @pytest.mark.unit + def test_normal_case(self): + """Standard case with valid inputs.""" + result = function_name(valid_input) + assert result is not None + + @pytest.mark.unit + @pytest.mark.edge_case + def test_empty_input(self): + """Behavior with empty input layer.""" + # Expect defined behavior, not a crash + + @pytest.mark.unit + def test_invalid_geometry(self): + """Behavior with invalid geometry.""" + # Expect a raised, meaningful exception +``` + +## Known Bugs Found by Tests + +When a test reveals a real bug in production code rather than a test-authoring +mistake, do **not** write the test to assert the buggy behavior as correct. +Instead: + +1. Write the test asserting the *intended* behavior (from the docstring/contract). +2. Mark it `@pytest.mark.xfail(reason="...", strict=True)` with a reason that + explains the bug and points to the fix. +3. Document the bug in `docs/test-strategy.md` → Gap Analysis. +4. `strict=True` ensures the marker itself starts failing the suite (as an + "unexpectedly passing" XPASS) once someone fixes the bug and forgets to + remove the marker - it cannot silently rot into a stale exclusion. + +## Coverage + +- New features must be covered by tests +- Coverage reports via `pytest --cov --cov-report=html` (needs `pytest-cov`, + see `requirements-test.txt`) +- CI pipeline checks tests automatically on every push (Docker) and keeps + `coverage.xml`/`htmlcov/` as a downloadable artifact - see + `docs/test-strategy.md` → Justified Exclusions for why there is no Codecov upload diff --git a/data_wizard.py b/data_wizard.py index d1c42f6..79df56a 100644 --- a/data_wizard.py +++ b/data_wizard.py @@ -62,20 +62,24 @@ def finished(self, result): if result: self.iface.messageBar().pushMessage( "Data Wizard", - f"Fertig – HU.gpkg, RN.gpkg und AUX_L.gpkg in: {self.target_dir}", + QCoreApplication.translate( + 'Data_Wizard', + "Done – HU.gpkg, RN.gpkg and AUX_L.gpkg in: {}" + ).format(self.target_dir), level=Qgis.Success, duration=8) elif self.isCanceled(): self.iface.messageBar().pushMessage( "Data Wizard", - "Verarbeitung abgebrochen.", + QCoreApplication.translate('Data_Wizard', "Processing cancelled."), level=Qgis.Info, duration=5) else: - msg = str(self.exception) if self.exception else "Unbekannter Fehler" + msg = str(self.exception) if self.exception else QCoreApplication.translate( + 'Data_Wizard', "Unknown error") self.iface.messageBar().pushMessage( "Data Wizard", - f"Fehler: {msg}", + QCoreApplication.translate('Data_Wizard', "Error: {}").format(msg), level=Qgis.Critical, duration=10) @@ -163,7 +167,7 @@ def run(self): if self._task_running: self.iface.messageBar().pushMessage( "Data Wizard", - "Eine Verarbeitung läuft bereits – bitte warten.", + self.tr("A process is already running – please wait."), level=Qgis.Warning, duration=5) return @@ -176,35 +180,36 @@ def run(self): if not source_dir or not hu_path or not target_dir: self.iface.messageBar().pushMessage( "Data Wizard", - "Bitte Quellordner, Gebäudedatei und Zielordner angeben.", + self.tr("Please specify source folder, building footprint file, " + "and target folder."), level=Qgis.Warning, duration=5) return if not os.path.isdir(source_dir): self.iface.messageBar().pushMessage( "Data Wizard", - f"Quellordner nicht gefunden: {source_dir}", + self.tr("Source folder not found: {}").format(source_dir), level=Qgis.Warning, duration=5) return if not os.path.isfile(hu_path): self.iface.messageBar().pushMessage( "Data Wizard", - f"Gebäudedatei nicht gefunden: {hu_path}", + self.tr("Building footprint file not found: {}").format(hu_path), level=Qgis.Warning, duration=5) return if not os.path.isdir(target_dir): self.iface.messageBar().pushMessage( "Data Wizard", - f"Zielordner nicht gefunden: {target_dir}", + self.tr("Target folder not found: {}").format(target_dir), level=Qgis.Warning, duration=5) return if study_area_path and not os.path.isfile(study_area_path): self.iface.messageBar().pushMessage( "Data Wizard", - f"Untersuchungsgebiet-Datei nicht gefunden: {study_area_path}", + self.tr("Study area file not found: {}").format(study_area_path), level=Qgis.Warning, duration=5) return @@ -221,5 +226,6 @@ def run(self): self.iface.messageBar().pushMessage( "Data Wizard", - "Verarbeitung läuft im Hintergrund – siehe Task-Manager und Log-Meldungen.", + self.tr("Processing running in background – see Task Manager and " + "log messages."), level=Qgis.Info, duration=5) diff --git a/data_wizard_dialog.py b/data_wizard_dialog.py index fdd83a3..9389198 100644 --- a/data_wizard_dialog.py +++ b/data_wizard_dialog.py @@ -7,7 +7,10 @@ FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'data_wizard_dialog_base.ui')) -SKIP_FUNCTION_FIELD_LABEL = "— kein Funktionscode-Feld / überspringen —" + +def _skip_function_field_label(): + return QtWidgets.QApplication.translate( + 'Data_WizardDialog', "— no function code field / skip —") class Data_WizardDialog(QtWidgets.QDialog, FORM_CLASS): @@ -23,28 +26,28 @@ def __init__(self, parent=None): def _browse_source(self): folder = QtWidgets.QFileDialog.getExistingDirectory( - self, "Quellordner wählen", self.lineEdit_source.text()) + self, self.tr("Select source folder"), self.lineEdit_source.text()) if folder: self.lineEdit_source.setText(folder) def _browse_hu(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Gebäudedatei wählen", self.lineEdit_hu.text(), - "Vektordateien (*.shp *.gpkg)") + self, self.tr("Select building footprint file"), self.lineEdit_hu.text(), + self.tr("Vector files (*.shp *.gpkg)")) if path: self.lineEdit_hu.setText(path) self._resolve_hu_function_field(path) def _browse_studyarea(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Untersuchungsgebiet wählen", self.lineEdit_studyarea.text(), - "Vektordateien (*.shp *.gpkg)") + self, self.tr("Select study area"), self.lineEdit_studyarea.text(), + self.tr("Vector files (*.shp *.gpkg)")) if path: self.lineEdit_studyarea.setText(path) def _browse_target(self): folder = QtWidgets.QFileDialog.getExistingDirectory( - self, "Zielordner wählen", self.lineEdit_target.text()) + self, self.tr("Select target folder"), self.lineEdit_target.text()) if folder: self.lineEdit_target.setText(folder) @@ -70,13 +73,14 @@ def _resolve_hu_function_field(self, path): return # status == 'ambiguous': Nutzer soll die Spalte wählen field_names = result - options = [SKIP_FUNCTION_FIELD_LABEL] + field_names + skip_label = _skip_function_field_label() + options = [skip_label] + field_names choice, ok = QtWidgets.QInputDialog.getItem( - self, "Funktionscode-Feld wählen", - "Keine der erwarteten Spalten (fkt/gfkzshh/funktion) gefunden.\n" - "Welche Spalte enthält die ATKIS-Funktionscodes?", + self, self.tr("Select function code field"), + self.tr("None of the expected columns (fkt/gfkzshh/funktion) found.\n" + "Which column contains the ATKIS function codes?"), options, 0, False) - if ok and choice != SKIP_FUNCTION_FIELD_LABEL: + if ok and choice != skip_label: self._hu_function_field = choice def get_source_dir(self): diff --git a/data_wizard_dialog_base.ui b/data_wizard_dialog_base.ui index f095665..b7e0160 100644 --- a/data_wizard_dialog_base.ui +++ b/data_wizard_dialog_base.ui @@ -22,14 +22,14 @@ - Quellordner: + Source folder: - Ordner mit ATKIS SHP-Dateien ... + Folder with ATKIS SHP files ... @@ -46,14 +46,14 @@ - Gebäudedatei: + Building footprint file: - Gebäudegrundrisse (SHP/GPKG) ... + Building footprints (SHP/GPKG) ... @@ -70,14 +70,14 @@ - Untersuchungsgebiet (optional): + Study area (optional): - Polygon zum Zuschneiden, leer = kein Zuschnitt ... + Polygon for clipping, empty = no clipping ... @@ -94,14 +94,14 @@ - Zielordner: + Target folder: - Ausgabeordner für HU.gpkg / RN.gpkg / AUX_L.gpkg ... + Output folder for HU.gpkg / RN.gpkg / AUX_L.gpkg ... diff --git a/docs/contributing.md b/docs/contributing.md index a7bd3f0..922e877 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -40,11 +40,16 @@ plugin has no runtime dependencies beyond QGIS's own processing algorithms ### Coverage Reporting -Test coverage is measured with `pytest-cov` and uploaded to [Codecov](https://codecov.io) on every CI run. The `coverage.xml` file is written by the container into the volume-mounted workspace. Container-absolute paths (`/plugins/data_wizard/`) are stripped before upload so Codecov can map lines back to the repository. +Test coverage is measured with `pytest-cov` (`.coveragerc` at the repo root: `source = data_wizard, scripts`). The `coverage.xml`/`htmlcov/` files are written by the container into the volume-mounted workspace, with container-absolute paths (`/plugins/data_wizard/`) stripped for portability. -> **Note:** unlike IBTool and ibtoolpartion, this repository does not yet have -> a Codecov project set up — `CODECOV_TOKEN` must be added as a repository -> secret before the coverage upload step will succeed. +> **No Codecov upload.** Unlike IBTool and ibtoolpartion, this repository does +> not have a Codecov project set up (`CODECOV_TOKEN` was never configured, +> and the previous Codecov upload step in `ci.yml` could only ever fail with +> `fail_ci_if_error: true`). This is a deliberate, documented exclusion — see +> [`docs/test-strategy.md`](test-strategy.md#justified-exclusions) — not an +> oversight to fix later. Coverage is produced on every CI run and kept as a +> downloadable **CI artifact** (`coverage-report`, containing `coverage.xml` +> and `htmlcov/`) instead. ### Local Development with Docker @@ -81,6 +86,42 @@ detect-secrets scan --force-use-all-plugins --- +## Pre-commit Hook (local, automatic) + +`scripts/git-hooks/pre-commit` runs the QGIS Plugin CI checks above (validator, +flake8, bandit, detect-secrets) plus `pytest test/ -m unit` — everything that +does **not** need Docker — automatically before every `git commit`. It +deliberately skips the full Docker-based Workflow 1 (integration tests + +coverage), which stays a manual/CI-only step (see above) because a full +`docker build && docker run` takes minutes, not seconds. + +**One-time setup per clone** (the hook lives in a tracked, versioned directory +— `.git/hooks/` itself is never committed by git): + +```bash +git config core.hooksPath scripts/git-hooks +``` + +This applies regardless of which IDE or Git client is used to commit +(IntelliJ/PyCharm, VS Code, CLI, …) — they all invoke the same `git` binary, +which reads `core.hooksPath` and runs the hook the same way. + +If your local QGIS install is not at the default +`C:\Program Files\QGIS 3.40.0`, point the hook at it once: + +```bash +git config data-wizard.qgisPrefix "D:/QGIS 3.40" +``` + +To skip the hook for a single commit (use sparingly — fix the underlying +issue instead of routinely bypassing this): + +```bash +git commit --no-verify +``` + +--- + ## Release Process Releases are built with `scripts/create_release_zip.py`, mirroring IBTool's @@ -130,15 +171,30 @@ pytest test/ -v -m unit | Marker | When to use | |--------|-------------| -| `@pytest.mark.unit` | No `processing.run()` calls — fast, no QGIS needed | -| `@pytest.mark.integration` | Calls `processing.run()` — requires QGIS | +| `@pytest.mark.unit` | No `processing.run()` calls — fast, no QGIS Processing needed | +| `@pytest.mark.integration` | Calls `processing.run()` — requires QGIS with Processing initialized | | `@pytest.mark.edge_case` | Boundary / degenerate inputs | +| `@pytest.mark.performance` + `@pytest.mark.slow` | Time/memory bounds on large datasets (not currently used — see `docs/test-strategy.md`) | + +See [`docs/test-strategy.md`](test-strategy.md) for the full tier taxonomy, +coverage targets, module-to-test mapping, and gap backlog — it is the +authoritative reference; consult it before writing a new test. -The existing tests (`test_data_wizard_dialog.py`, `test_qgis_environment.py`, -`test_resources.py`, `test_translations.py`, `test_init.py`) come from the -QGIS Plugin Builder scaffold. They are not yet marked with these tiers — -that's a good first contribution if you're adding tests for `processor.py`'s -ATKIS mapping/reprojection/clipping logic, which is currently untested. +`processor.py`'s ATKIS mapping/reprojection/clipping logic is covered by +`test/test_processor.py` (unit + integration + edge-case tiers). The +end-to-end `process_atkis` tests run against real ATKIS/ALKIS data in +`Testdaten/` (not tracked in `.gitignore`'s "Test Artefakte" section on +purpose) and are automatically skipped if that data is absent from the +checkout. + +### Running tests locally on Windows + +The QGIS-bundled Python interpreter sets up `sys.path`/env vars that a bare +system Python does not: + +```bash +"C:\Program Files\QGIS 3.40.0\bin\python-qgis.bat" -m pytest test/ -v +``` --- @@ -148,5 +204,8 @@ ATKIS mapping/reprojection/clipping logic, which is currently untested. |------|---------| | [`docs/README.md`](README.md) | Full plugin documentation, including the relationship to IBTool | | [`docs/CHANGELOG.md`](CHANGELOG.md) | Version history | +| [`docs/test-strategy.md`](test-strategy.md) | Test philosophy, tier taxonomy, coverage targets, module-to-test mapping, gap backlog | +| [`ai/core/testing-rules.md`](../ai/core/testing-rules.md) | Tactical test rules: geometry checks, QGIS NULL handling, test structure | +| [`ai/core/qgis-api-rules.md`](../ai/core/qgis-api-rules.md) | QGIS API compatibility and Processing initialization rules | | [`ai/core/release-conventions.md`](../ai/core/release-conventions.md) | Release invariants | | [`ci/qgis_plugin_validate.py`](../ci/qgis_plugin_validate.py) | Plugin structure validator | diff --git a/docs/test-strategy.md b/docs/test-strategy.md new file mode 100644 index 0000000..0025082 --- /dev/null +++ b/docs/test-strategy.md @@ -0,0 +1,257 @@ +# Test Strategy + +This document is the single authoritative reference for **why** the test suite is structured the way it is, **how** to choose the right test tier for a new test, and **where** known coverage gaps exist. Consult it before writing any new test or assessing CI failures. + +This plugin follows the same test-strategy structure as its sibling +[IB-Tool-3](https://github.com/IB-Tool/IB-Tool-3/blob/master/docs/test-strategy.md), +scaled down to data_wizard's single-module scope (`processor.py`, +`data_wizard.py`, `data_wizard_dialog.py`). + +**What this document is not:** +- A tutorial on pytest syntax - see the pytest documentation. +- A list of tactical rules for geometry checks or test structure - see [`ai/core/testing-rules.md`](../ai/core/testing-rules.md). + +--- + +## Test Philosophy + +Four principles explain the structural decisions made in this project: + +### Geometry bugs produce plausible-looking wrong results + +A dissolve that silently fails returns an empty or null geometry - not an exception. A polygon that self-intersects still renders on screen. This is why **geometry validity checks are mandatory** for every test that touches a layer-returning function. Checking only `featureCount > 0` is insufficient. + +### `processing.run()` is the unit/integration boundary + +The demarcation between unit and integration tests is not "uses QGIS API" but specifically **whether `processing.run()` is called**. `_load_shp`, `_write_gpkg`, `_add_function_field_copy`, and `detect_hu_function_field` use `QgsVectorLayer`/`QgsFeature`/`QgsVectorFileWriter` directly and are unit-tested without a Processing environment. `_reproject_if_needed`, `_clip_if_needed`, `_prepare_clip_mask`, `_process_hu/_rn/_aux`, and `process_atkis` delegate to `native:*` algorithms and require an initialized Processing environment. + +### Error paths are first-class citizens + +Empty layers, null geometries, mismatched CRS, and non-overlapping study areas are not accidents - they are guaranteed inputs when processing real ATKIS extracts. Every error-handling branch in `processor.py` must be tested explicitly, not just the happy path. + +### Tests document expected behavior + +Constants and thresholds should be visible in test docstrings or assertions, not buried in source code. A test like `assert status == 'auto'` without explanation is opaque; a test with `"""Returns ('auto', name) when exactly one field matches the pattern."""` is documentation. + +--- + +## Test Taxonomy + +Five tiers are used (matching `pytest.ini`). Every test must carry exactly one primary tier marker (`unit` or `integration`) and may additionally carry `edge_case`, or `performance` + `slow` together. + +### Unit (`@pytest.mark.unit`) + +**Definition:** No call to `processing.run()`. May instantiate `QgsVectorLayer("…memory")`, `QgsFeature`, `QgsGeometry`, or `QgsVectorFileWriter` directly. + +**Execution:** Runs anywhere Python + QGIS libraries are installed (`python-qgis.bat -m pytest test/ -m unit`). Does not require Docker. + +**Example targets:** `detect_hu_function_field`, `_check_cancel`, `_load_shp`, `_add_function_field_copy`, `_write_gpkg`, `scripts/create_release_zip.py`, dialog getters, plugin validation logic. + +### Integration (`@pytest.mark.integration`) + +**Definition:** Calls `processing.run()` at least once, directly or indirectly through the function under test. + +**Execution:** Requires Docker (`docker run --rm qgis-plugin-test`) or a local QGIS installation with the Processing plugin initialized (`Processing.initialize()` - see `test/utilities.py`). + +**Example targets:** `_reproject_if_needed`, `_clip_if_needed`, `_prepare_clip_mask`, `_process_hu`, `_process_rn`, `_process_aux`, `process_atkis`. + +### Edge case (`@pytest.mark.edge_case`) + +**Definition:** Cross-cutting tag combined with `unit` or `integration`. Marks a test that exercises a boundary or degenerate input. + +**Catalog of mandatory edge cases for `processor.py`:** +- HU without a function-code field and no `function_field` override (warning, no abort) +- `veg03_f` with no feature matching `OBJART` 43005/43006 (empty intermediate layer, no crash) +- `task.isCanceled()` becoming true mid-run (defined exception, not a hang or silent partial write) +- Study area without any overlap with the ATKIS extent (empty result, not an error) +- Input layer in a different CRS than `ver01_l` (automatic reprojection) +- CRS mismatch between `_write_gpkg` input layers (`ValueError`, not silently wrong geometry) +- Null/empty geometries in `_write_gpkg` input (skipped, not written as broken features) + +### Performance (`@pytest.mark.performance` + `@pytest.mark.slow`) + +**Definition:** Exercises time or memory bounds on realistically large datasets. Always carries both markers together. + +**Status:** Not currently used. `process_atkis`'s real end-to-end test already runs against the full `Testdaten/` ATKIS/ALKIS extract (11,207 building features, ~2,100 line/polygon ATKIS features combined) and completes in ~2 seconds locally - there is no dataset in this repository large enough to need a dedicated slow tier yet. See Gap Analysis. + +--- + +## Coverage Targets + +Per-file floor values, not aspirational goals. Coverage below these thresholds signals a gap that should be addressed before merging new features. + +| File | Target | Rationale | +|---|---|---| +| `processor.py` | 85% | Core ATKIS transformation logic; the plugin's entire reason to exist | +| `data_wizard.py` | 70% | Plugin glue + validation; `classFactory`/GUI wiring excluded (see Justified Exclusions) | +| `data_wizard_dialog.py` | 70% | Signal wiring excluded, getters/detection-resolution logic covered | +| `scripts/create_release_zip.py` | 90% | Pure Python, no QGIS dependency, cheap to cover fully | +| **Overall project** | **75%** | | + +--- + +## Test Data and Fixture Strategy + +### Shared vs. per-file factories + +**`conftest.py`** handles only pytest infrastructure: it adds the plugin's parent directory to `sys.path` so `import data_wizard.processor` resolves the same way locally and in the container (`PYTHONPATH=/plugins`). It does **not** provide pytest fixtures or import QGIS modules - doing so would trigger a circular import error via `qgis.utils._import` before QGIS is initialized. + +**`test/layer_factories.py`** is the canonical home for shared layer and geometry factory helpers. Import it **after** calling `get_qgis_app()`: + +```python +from .utilities import get_qgis_app +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() +from .layer_factories import ( + make_polygon_layer, make_line_layer, make_point_layer, + make_square_geom, add_feature_to_layer, + write_layer_as_shp, write_layer_as_gpkg, +) +``` + +`write_layer_as_shp`/`write_layer_as_gpkg` exist because `processor._load_shp`, `detect_hu_function_field`, and `_prepare_clip_mask` all take file **paths**, not `QgsVectorLayer` objects - most `processor.py` unit tests build an in-memory layer and then write it to `tmp_path` before calling the function under test. + +### Real ATKIS/ALKIS Testdaten + +`Testdaten/ATKIS Basis DLM dataset/` (the 7 layers `processor.process_atkis` reads: `ver01_l`, `ver02_l`, `ver03_l`, `veg02_f`, `veg03_f`, `gew01_f`, `gew01_l`) and `Testdaten/ALKIS Gebäude/GebauedeBauwerk.shp` are real, small ATKIS Basis-DLM / ALKIS extracts checked into the repository (untracked by `.gitignore` deliberately - see `.gitignore` "Test Artefakte" section, which only excludes generated `*.gpkg`, not the source `Testdaten/`). `test/test_processor.py`'s `TestProcessAtkis` class runs `process_atkis` end-to-end against this data and is automatically skipped (`requires_atkis_testdaten`) if the directory is absent, so the suite still runs (minus that one class) on a fresh checkout without the data. + +### Processing initialization (local runs) + +Unlike `QgsApplication.initQgis()`, the `native:*` algorithm provider is **not** auto-registered - it requires the Processing Python plugin's own `Processing.initialize()`. `test/utilities.py`'s `get_qgis_app()` calls this once, immediately after `initQgis()`, so every test file that calls `get_qgis_app()` gets a working `processing.run()` for free. Without this, every `@pytest.mark.integration` test fails with `Algorithm native:... not found`, regardless of whether `QgsApplication` initialized successfully. This is a deliberate deviation from IB-Tool-3's `test/utilities.py`, which does not do this - necessary here because `processor.py`'s pipeline uses `processing.run()` far more heavily than a typical IBTool geometry tool. + +### Fixture scope rules + +| Fixture type | Scope | +|---|---| +| `QgsVectorLayer` instances | `function` - layers are mutable; reuse across tests causes interference | +| `QgsApplication` (QGIS singleton) | `session` (via `test/utilities.py`'s module-global `QGIS_APP`) - expensive to initialize, safe to share | +| `QgsCoordinateReferenceSystem` | module-level constant (`CRS_25833`, `CRS_4326` in `test_processor.py`) - immutable value object | +| File paths (`pathlib.Path`) | `tmp_path` (pytest built-in) - fresh per test, auto-cleaned | + +--- + +## Module-to-Test Mapping + +| Production module | Test file | Tests | Dominant tier | Notable gaps | +|---|---|---|---|---| +| `processor.py` | `test_processor.py` | 39 (38 + 1 xfail) | unit + integration | See Gap Analysis (NULL-copy bug) | +| `data_wizard.py` | `test_data_wizard.py` | 30 | unit | Full `run()` happy path (task actually scheduled via `QgsApplication.taskManager()`) not covered - see Justified Exclusions | +| `data_wizard_dialog.py` | `test_data_wizard_dialog.py` | 16 | unit | `_browse_*` file-dialog callbacks not covered (native `QFileDialog` calls - see Justified Exclusions) | +| `scripts/create_release_zip.py` | `test_create_release_zip.py` | 40 | unit | None significant | +| `__init__.py` (`classFactory`) | `test_init.py` | 1 | smoke | `classFactory()` with a live `iface` - see Justified Exclusions | +| — | `test_qgis_environment.py` | 2 | smoke | QGIS init and Processing available | +| — | `test_resources.py` | 1 | smoke | Plugin resources compiled | +| — | `test_translations.py` | 1 | smoke | Translation file present | +| **Total** | | **130** (129 + 1 xfail) | | | + +--- + +## Decision Guide for New Tests + +Use this checklist when adding a new test. + +### Step 1 - Identify what changed + +- New function or class → write a test for its normal behavior + at least one edge case. +- Bug fix → write a regression test that reproduces the original bug, then verifies the fix. +- Edge case discovered during review → add to the existing test class under `@pytest.mark.edge_case`. + +### Step 2 - Choose the tier + +```text +Does the function under test call processing.run()? +├── No → @pytest.mark.unit +└── Yes → @pytest.mark.integration + (also requires Docker / local QGIS with Processing.initialize()) + +Is this testing a boundary / degenerate input? +└── Yes → additionally add @pytest.mark.edge_case +``` + +### Step 3 - Choose the test file + +Always add to `test_{module_name}.py` where `module_name` is the file under test without extension. + +### Step 4 - Mandatory geometry checks + +Every test for a function that returns a `QgsVectorLayer` must include: + +```python +assert result_layer is not None +for feat in result_layer.getFeatures(): + geom = feat.geometry() + assert not geom.isNull(), "Geometry must not be null" + assert not geom.isEmpty(), "Geometry must not be empty" + assert geom.isGeosValid(), "Geometry must be GEOS-valid" +``` + +### Step 5 - Write a one-line docstring + +Every test method must have a docstring in the imperative mood describing what behavior it verifies: + +```python +def test_returns_auto_for_single_matching_field(self): + """Returns ('auto', name) when exactly one field matches the pattern.""" +``` + +--- + +## Gap Analysis + +### Known bug found while writing tests (Priority 1) + +| Gap | Detail | Action | +|---|---|---| +| `_add_function_field_copy` does not preserve `NULL` | `value is None` never matches a QGIS `NULL` attribute (it is a `QVariant` sentinel, not Python `None`), so a `NULL` source value is copied into the target field as the literal string `"NULL"` instead of staying `NULL`. Reproduced in `test_processor.py::TestAddFunctionFieldCopy::test_null_source_values_are_copied_as_null_not_string`, currently marked `xfail(strict=True)` so the suite stays green while documenting the discrepancy. | Fix `processor.py` to compare against `qgis.core.NULL` (e.g. `value in (None, NULL)`), then remove the `xfail` marker - the test will start passing and `strict=True` will catch it if the marker is forgotten. | + +### Other gaps (Priority 2) + +| Gap | Action | +|---|---| +| No performance/slow-tier test | Not yet warranted - see Test Taxonomy → Performance. Revisit if a much larger `Testdaten/` extract is added or `process_atkis` runtime becomes a concern. | +| `Data_Wizard.run()` happy path (real task scheduled via `QgsApplication.taskManager()`) | Not covered - deliberately, to avoid a real background `QgsTask` executing during the unit-test run (flaky/slow). Covered indirectly: `_AtkisTask.run()`/`.finished()` are tested directly, and every validation branch that would prevent scheduling is tested. | +| `data_wizard_dialog.py` `_browse_*` methods | Not covered - they are three-line wrappers around `QFileDialog.getExistingDirectory`/`getOpenFileName` static calls; the QGIS API rule against relying on native OS file dialogs in headless CI applies (see `ai/core/qgis-api-rules.md`). | + +--- + +## Justified Exclusions + +Documented decisions that **are not gaps** - known exclusions with stated reasons. + +| Module / function | Reason for exclusion | +|---|---| +| `__init__.py` `classFactory()` beyond the existing smoke test | Requires a live `iface` object provided by the running QGIS application. | +| `data_wizard_dialog.py` `_browse_source`/`_browse_hu`/`_browse_studyarea`/`_browse_target` | Thin wrappers around native `QFileDialog` static methods; no branching logic of their own beyond "if a path was chosen, set the line edit" (indirectly exercised by the getter tests once a value is present). | +| `Data_Wizard.run()`'s `QgsApplication.taskManager().addTask(...)` call itself | Scheduling a real `QgsTask` would run `process_atkis` in a background thread during the test session - the same logic is exhaustively covered by calling `_AtkisTask.run()`/`.finished()` directly and synchronously. | +| Codecov upload | No Codecov project exists for this repository (`CODECOV_TOKEN` not configured). Coverage is measured locally and in Docker via `pytest-cov` and kept as a downloadable CI artifact (`coverage.xml`, `htmlcov/`) instead of being uploaded to an external service. See `docs/contributing.md` → Coverage Reporting. | +| `resources.py`, `ui_*.py` | Generated files (see `.coveragerc`). | + +--- + +## CI/CD + +For the full CI/CD pipeline description, Docker environment setup, and local commands, see [docs/contributing.md](contributing.md). + +Quick reference for common test runs: + +```bash +# Unit tests only (no QGIS Processing required beyond QGIS itself) +pytest test/ -m unit -v + +# Full run (requires Docker or local QGIS 3.40 with Processing) +docker run --rm qgis-plugin-test + +# Coverage report +pytest test/ --cov --cov-report=html + +# Single module +pytest test/test_processor.py -v +``` + +--- + +## Related Files + +| File | Content | +|------|---------| +| [`docs/contributing.md`](contributing.md) | CI/CD pipeline, Docker environment, code linting | +| [`ai/core/testing-rules.md`](../ai/core/testing-rules.md) | Tactical rules: geometry checks, test structure, framework conventions | diff --git a/i18n/Data_Wizard_de.ts b/i18n/Data_Wizard_de.ts new file mode 100644 index 0000000..f888bc2 --- /dev/null +++ b/i18n/Data_Wizard_de.ts @@ -0,0 +1,139 @@ + + + + + Data_Wizard + + &IB-Tool + &IB-Tool + + + Data Wizard + Data Wizard + + + Done – HU.gpkg, RN.gpkg and AUX_L.gpkg in: {} + Fertig – HU.gpkg, RN.gpkg und AUX_L.gpkg in: {} + + + Processing cancelled. + Verarbeitung abgebrochen. + + + Unknown error + Unbekannter Fehler + + + Error: {} + Fehler: {} + + + A process is already running – please wait. + Eine Verarbeitung läuft bereits – bitte warten. + + + Please specify source folder, building footprint file, and target folder. + Bitte Quellordner, Gebäudedatei und Zielordner angeben. + + + Source folder not found: {} + Quellordner nicht gefunden: {} + + + Building footprint file not found: {} + Gebäudedatei nicht gefunden: {} + + + Target folder not found: {} + Zielordner nicht gefunden: {} + + + Study area file not found: {} + Untersuchungsgebiet-Datei nicht gefunden: {} + + + Processing running in background – see Task Manager and log messages. + Verarbeitung läuft im Hintergrund – siehe Task-Manager und Log-Meldungen. + + + + Data_WizardDialog + + — no function code field / skip — + — kein Funktionscode-Feld / überspringen — + + + Select source folder + Quellordner wählen + + + Select building footprint file + Gebäudedatei wählen + + + Vector files (*.shp *.gpkg) + Vektordateien (*.shp *.gpkg) + + + Select study area + Untersuchungsgebiet wählen + + + Select target folder + Zielordner wählen + + + Select function code field + Funktionscode-Feld wählen + + + None of the expected columns (fkt/gfkzshh/funktion) found. +Which column contains the ATKIS function codes? + Keine der erwarteten Spalten (fkt/gfkzshh/funktion) gefunden. +Welche Spalte enthält die ATKIS-Funktionscodes? + + + + Data_WizardDialogBase + + IB-Tool Data Wizard + IB-Tool Data Wizard + + + Source folder: + Quellordner: + + + Folder with ATKIS SHP files ... + Ordner mit ATKIS SHP-Dateien ... + + + Building footprint file: + Gebäudedatei: + + + Building footprints (SHP/GPKG) ... + Gebäudegrundrisse (SHP/GPKG) ... + + + Study area (optional): + Untersuchungsgebiet (optional): + + + Polygon for clipping, empty = no clipping ... + Polygon zum Zuschneiden, leer = kein Zuschnitt ... + + + Target folder: + Zielordner: + + + Output folder for HU.gpkg / RN.gpkg / AUX_L.gpkg ... + Ausgabeordner für HU.gpkg / RN.gpkg / AUX_L.gpkg ... + + + ... + ... + + + diff --git a/pb_tool.cfg b/pb_tool.cfg index b45e2f5..71f00a6 100644 --- a/pb_tool.cfg +++ b/pb_tool.cfg @@ -68,7 +68,7 @@ extra_dirs: # ISO code(s) for any locales (translations), separated by spaces. # Corresponding .ts files must exist in the i18n directory -locales: +locales: de [help] # the built help directory that should be deployed with the plugin diff --git a/pytest.ini b/pytest.ini index a4a5415..f6bfacd 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,6 +9,8 @@ markers = unit: fast unit test - no real QGIS required integration: requires a full QGIS Processing environment (Docker) edge_case: boundary or degenerate input scenario + performance: exercises time or memory bounds on larger datasets + slow: excluded from fast local runs via -m "not slow" filterwarnings = ignore::DeprecationWarning ignore::PendingDeprecationWarning diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100644 index 0000000..b088dc7 --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,76 @@ +#!/bin/sh +# Pre-commit hook - fast local equivalent of .github/workflows/qgis-plugin-ci.yml +# (structure validator, flake8, bandit, detect-secrets) plus the unit tier of +# .github/workflows/ci.yml (`pytest -m unit`, no Docker/Processing needed). +# +# Deliberately does NOT run the full Docker-based integration/coverage suite +# (ci.yml's `docker build && docker run`) - that takes minutes and still runs +# in GitHub Actions on every push. Run it manually before a release: +# docker build -t qgis-plugin-test . && docker run --rm -v "$PWD:/plugins/data_wizard" qgis-plugin-test +# +# Installed via (run once per clone): +# git config core.hooksPath scripts/git-hooks +# +# Bypass in an emergency (use sparingly, fix the underlying issue instead): +# git commit --no-verify +# +# Override the QGIS install location if it differs from the default: +# git config data-wizard.qgisPrefix "D:/QGIS 3.40" + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +QGIS_PREFIX="$(git config --get data-wizard.qgisPrefix || true)" +QGIS_PREFIX="${QGIS_PREFIX:-C:/Program Files/QGIS 3.40.0}" +PY="$QGIS_PREFIX/apps/Python312/python.exe" +QGIS_PYTEST="$QGIS_PREFIX/bin/python-qgis.bat" + +if [ ! -x "$PY" ]; then + echo "pre-commit: QGIS Python not found at '$PY'." + echo "pre-commit: set the correct path with:" + echo " git config data-wizard.qgisPrefix \"\"" + exit 1 +fi + +fail() { + echo "" + echo "pre-commit FAILED: $1" + echo "(bypass with 'git commit --no-verify' if you must, but fix this first)" + exit 1 +} + +echo "pre-commit: plugin structure validator..." +"$PY" ci/qgis_plugin_validate.py --auto || fail "qgis_plugin_validate.py" + +echo "pre-commit: flake8..." +"$PY" -m flake8 . || fail "flake8" + +echo "pre-commit: bandit..." +"$PY" -m bandit -r . -ll -q || fail "bandit" + +echo "pre-commit: detect-secrets..." +SECRETS_JSON="$(mktemp)" +"$PY" -m detect_secrets scan --force-use-all-plugins > "$SECRETS_JSON" +"$PY" - "$SECRETS_JSON" <<'PYEOF' +import json, sys +data = json.load(open(sys.argv[1])) +findings = sum(len(v) for v in data.get('results', {}).values()) +if findings: + print("FAIL: detect-secrets found potential secrets:", findings) + for f, secrets in data.get('results', {}).items(): + for s in secrets: + print(f" {f}:{s['line_number']} [{s['type']}]") + sys.exit(2) +print("OK: detect-secrets found no potential secrets") +PYEOF +DETECT_SECRETS_STATUS=$? +rm -f "$SECRETS_JSON" +[ "$DETECT_SECRETS_STATUS" -eq 0 ] || fail "detect-secrets" + +echo "pre-commit: unit tests (pytest -m unit)..." +"$QGIS_PYTEST" -m pytest test/ -m unit -q || fail "unit tests" + +echo "" +echo "pre-commit: all checks passed." diff --git a/test/__init__.py b/test/__init__.py index 8feeb0b..75f8bca 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,2 +1,9 @@ +# Make sure QGIS_PREFIX_PATH and sys.path (qgis python, qgis python/plugins, +# QGIS's own site-packages) are set up before anything below imports qgis - +# needed when pytest is invoked with a plain system Python instead of the +# QGIS-bundled interpreter (python-qgis.bat already sets this up itself). +from .config import apply_qgis_environment +apply_qgis_environment() + # import qgis libs so that ve set the correct sip api version -import qgis # pylint: disable=W0611 # NOQA \ No newline at end of file +import qgis # pylint: disable=W0611 # NOQA diff --git a/test/config.py b/test/config.py new file mode 100644 index 0000000..8e96f19 --- /dev/null +++ b/test/config.py @@ -0,0 +1,28 @@ +import configparser +import os +import sys +from pathlib import Path + +CONFIG_FILE = Path(__file__).parent / 'test_config.ini' + +parser = configparser.ConfigParser() +parser.read(CONFIG_FILE) + +QGIS_PREFIX_PATH = parser.get('qgis', 'prefix_path', fallback=r'C:\\Program Files\\QGIS 3.40.0') + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def apply_qgis_environment(): + """Apply QGIS environment variables and sys.path entries.""" + os.environ.setdefault('QGIS_PREFIX_PATH', QGIS_PREFIX_PATH) + os.environ.setdefault('PYTHONPATH', str(Path(QGIS_PREFIX_PATH) / 'apps' / 'qgis' / 'python')) + paths = [ + Path(QGIS_PREFIX_PATH) / 'apps' / 'qgis' / 'python', + Path(QGIS_PREFIX_PATH) / 'apps' / 'qgis' / 'python' / 'plugins', + Path(QGIS_PREFIX_PATH) / 'apps' / 'Python312' / 'Lib' / 'site-packages', + ] + for p in paths: + p_str = str(p) + if os.path.exists(p_str) and p_str not in sys.path: + sys.path.insert(0, p_str) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..9d66c25 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,48 @@ +""" +Pytest configuration file for Data Wizard tests. + +This file sets up the Python path BEFORE any test modules are imported. +CRITICAL: Must be executed before test collection! +""" + +import sys +from pathlib import Path + +# CRITICAL: Add the plugin's PARENT directory to sys.path IMMEDIATELY. +# This MUST happen before pytest tries to import test modules. +# +# 'data_wizard' is already a valid Python identifier, so — unlike +# IB-Tool-3's 'IB-Tool-3' folder name — no types.ModuleType alias stub is +# needed here. Adding the parent directory is enough for +# 'import data_wizard.processor' to resolve locally exactly like it does in +# the container (PYTHONPATH=/plugins). +plugin_root = Path(__file__).resolve().parent.parent +plugin_parent = plugin_root.parent + +if str(plugin_parent) not in sys.path: + sys.path.insert(0, str(plugin_parent)) + print(f"conftest.py: Added {plugin_parent} to sys.path") + +# Verify the plugin package resolves as expected. +assert (plugin_root / "processor.py").exists(), \ + f"processor.py not found in {plugin_root}" +assert (plugin_root / "__init__.py").exists(), \ + f"__init__.py not found in {plugin_root}" + + +# --------------------------------------------------------------------------- +# Shared layer / geometry factory helpers +# +# These live in test/layer_factories.py (a regular Python module, not a +# pytest plugin). Import them in test files AFTER calling get_qgis_app(): +# +# from .utilities import get_qgis_app +# QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() +# from .layer_factories import ( +# make_polygon_layer, make_line_layer, make_square_geom, add_feature_to_layer +# ) +# +# Factories must NOT be imported from conftest.py because conftest runs as a +# pytest plugin before QGIS is initialised, and its module context causes +# QGIS' import hook (qgis.utils._import) to trigger a circular-import error. +# --------------------------------------------------------------------------- diff --git a/test/layer_factories.py b/test/layer_factories.py new file mode 100644 index 0000000..37019a0 --- /dev/null +++ b/test/layer_factories.py @@ -0,0 +1,89 @@ +""" +Shared layer and geometry factory helpers for Data Wizard tests. + +Import this module AFTER calling get_qgis_app() in your test file so that +qgis.core is fully initialised when the module-level imports run. + +Usage in test files: + from .utilities import get_qgis_app + QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() + from .layer_factories import ( + make_polygon_layer, make_line_layer, make_point_layer, + make_square_geom, add_feature_to_layer, + write_layer_as_shp, write_layer_as_gpkg, + ) +""" + +from qgis.core import ( + QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY, + QgsVectorFileWriter, QgsCoordinateTransformContext, +) + + +def make_polygon_layer(crs: str = "EPSG:25833", name: str = "test_poly") -> QgsVectorLayer: + """Return an empty in-memory polygon layer with the given CRS.""" + layer = QgsVectorLayer(f"Polygon?crs={crs}", name, "memory") + layer.updateFields() + return layer + + +def make_line_layer(crs: str = "EPSG:25833", name: str = "test_line") -> QgsVectorLayer: + """Return an empty in-memory line layer with the given CRS.""" + layer = QgsVectorLayer(f"LineString?crs={crs}", name, "memory") + layer.updateFields() + return layer + + +def make_point_layer(crs: str = "EPSG:25833", name: str = "test_point") -> QgsVectorLayer: + """Return an empty in-memory point layer with the given CRS.""" + layer = QgsVectorLayer(f"Point?crs={crs}", name, "memory") + layer.updateFields() + return layer + + +def make_square_geom(x0: float, y0: float, size: float) -> QgsGeometry: + """Return an axis-aligned square QgsGeometry with bottom-left corner at (x0, y0).""" + return QgsGeometry.fromPolygonXY([[ + QgsPointXY(x0, y0), + QgsPointXY(x0 + size, y0), + QgsPointXY(x0 + size, y0 + size), + QgsPointXY(x0, y0 + size), + QgsPointXY(x0, y0), + ]]) + + +def add_feature_to_layer(layer: QgsVectorLayer, geom: QgsGeometry, attributes=None) -> QgsFeature: + """Add a QgsFeature with the given geometry (and optional attributes) to layer.""" + feat = QgsFeature(layer.fields()) + feat.setGeometry(geom) + if attributes is not None: + feat.setAttributes(attributes) + layer.dataProvider().addFeatures([feat]) + layer.updateExtents() + return feat + + +def _write_layer(layer: QgsVectorLayer, path: str, driver_name: str) -> str: + """Write layer to path using driver_name; returns the written path. + + Used because processor._load_shp and detect_hu_function_field expect + file paths, not in-memory QgsVectorLayer objects. + """ + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = driver_name + options.fileEncoding = "UTF-8" + error, error_msg, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3( + layer, path, QgsCoordinateTransformContext(), options) + if error != QgsVectorFileWriter.NoError: + raise IOError(f"Konnte Testlayer nicht schreiben: {path} ({error_msg})") + return path + + +def write_layer_as_shp(layer: QgsVectorLayer, path: str) -> str: + """Write layer as a Shapefile at path (path should end in .shp).""" + return _write_layer(layer, path, "ESRI Shapefile") + + +def write_layer_as_gpkg(layer: QgsVectorLayer, path: str) -> str: + """Write layer as a GeoPackage at path (path should end in .gpkg).""" + return _write_layer(layer, path, "GPKG") diff --git a/test/test_config.ini b/test/test_config.ini new file mode 100644 index 0000000..2a0fd81 --- /dev/null +++ b/test/test_config.ini @@ -0,0 +1,2 @@ +[qgis] +prefix_path = C:\\Program Files\\QGIS 3.40.0 diff --git a/test/test_create_release_zip.py b/test/test_create_release_zip.py new file mode 100644 index 0000000..cb1b27e --- /dev/null +++ b/test/test_create_release_zip.py @@ -0,0 +1,411 @@ +"""Tests for scripts/create_release_zip.py. + +Functions under test (all pure Python - no QGIS dependency): + + is_excluded(rel) - apply exclusion rules to a relative Path + read_version(root) - parse version string from metadata.txt + collect_files(root) - walk a directory tree and apply exclusion rules + build_zip(root, ...) - create a ZIP with the correct internal arcnames + +Note: The module is imported directly via importlib so these tests don't +depend on scripts/ being an importable package. +""" + +from __future__ import annotations + +import importlib.util +import zipfile +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Isolated import +# --------------------------------------------------------------------------- +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "create_release_zip.py" +_spec = importlib.util.spec_from_file_location("create_release_zip", _SCRIPT) +assert _spec is not None and _spec.loader is not None, f"could not load spec for {_SCRIPT}" +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +is_excluded = _mod.is_excluded +read_version = _mod.read_version +collect_files = _mod.collect_files +build_zip = _mod.build_zip + + +# =========================================================================== +# TestIsExcluded +# =========================================================================== + +class TestIsExcluded: + """Unit tests for is_excluded().""" + + # --- excluded directories --- + + @pytest.mark.unit + def test_file_in_top_level_excluded_dir_is_excluded(self): + """Files inside a top-level excluded directory (e.g. test/) are excluded.""" + for dirname in ("test", "Testdaten", "ai", "docs", "dist", "scripts", "ci", "help"): + rel = Path(dirname) / "module.py" + assert is_excluded(rel), f"{rel} should be excluded" + + @pytest.mark.unit + def test_file_in_nested_excluded_dir_is_excluded(self): + """Files inside a nested excluded directory (e.g. __pycache__) are excluded.""" + rel = Path("scripts") / "__pycache__" / "create_release_zip.cpython-312.pyc" + assert is_excluded(rel) + + @pytest.mark.unit + def test_file_in_hidden_directory_is_excluded(self): + """Files inside any directory that starts with '.' are excluded.""" + rel = Path(".github") / "workflows" / "ci.yml" + assert is_excluded(rel) + + @pytest.mark.unit + def test_file_in_egg_info_directory_is_excluded(self): + """Files inside a *.egg-info directory are excluded.""" + rel = Path("my_package.egg-info") / "PKG-INFO" + assert is_excluded(rel) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_dist_directory_excluded_prevents_self_inclusion(self): + """The dist/ output directory itself is excluded to prevent ZIP self-inclusion.""" + rel = Path("dist") / "data_wizard.0.1.3.zip" + assert is_excluded(rel) + + @pytest.mark.unit + def test_workflows_directory_is_excluded(self): + """A top-level 'workflows' path component is excluded (belt-and-suspenders + alongside the '.github' hidden-dir rule).""" + rel = Path("workflows") / "ci.yml" + assert is_excluded(rel) + + # --- excluded filenames --- + + @pytest.mark.unit + def test_excluded_filename_is_excluded(self): + """Files whose exact name matches EXCLUDED_FILES set are excluded.""" + for fname in ("CLAUDE.md", "plugin_upload.py", "Dockerfile", "pytest.ini", + "requirements-test.txt", "pylintrc", "Makefile"): + rel = Path(fname) + assert is_excluded(rel), f"'{fname}' should be excluded" + + @pytest.mark.unit + def test_excluded_filename_in_subdir_is_excluded(self): + """An excluded filename is still excluded when nested under a regular directory.""" + rel = Path("helpers") / "CLAUDE.md" + assert is_excluded(rel) + + # --- excluded file-prefix / pattern rules --- + + @pytest.mark.unit + def test_debug_prefixed_file_is_excluded(self): + """Files starting with 'debug_' are always excluded (ad hoc dev scripts).""" + rel = Path("debug_processor_run.py") + assert is_excluded(rel) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_kopie_pattern_file_is_excluded(self): + """Backup/copy files matching the known patterns are excluded.""" + for fname in ("processor - Kopie.py", "processor_kopie.py", + "processor_original_backup.py"): + rel = Path(fname) + assert is_excluded(rel), f"'{fname}' should be excluded" + + # --- excluded extensions --- + + @pytest.mark.unit + def test_pyc_extension_is_excluded(self): + """*.pyc files are always excluded.""" + rel = Path("processor.pyc") + assert is_excluded(rel) + + @pytest.mark.unit + def test_pyo_extension_is_excluded(self): + """*.pyo files are always excluded.""" + rel = Path("processor.pyo") + assert is_excluded(rel) + + @pytest.mark.unit + def test_iml_extension_is_excluded(self): + """*.iml IDE project files are always excluded.""" + rel = Path("data_wizard.iml") + assert is_excluded(rel) + + # --- dotfiles --- + + @pytest.mark.unit + def test_dotfile_at_root_is_excluded(self): + """A dotfile at repository root (e.g. .gitignore) is excluded.""" + for name in (".gitignore", ".gitattributes", ".flake8", ".bandit", ".secrets.baseline"): + rel = Path(name) + assert is_excluded(rel), f"'{name}' should be excluded" + + @pytest.mark.unit + @pytest.mark.edge_case + def test_dotfile_in_subdir_is_excluded(self): + """Dotfiles nested in non-excluded directories are still excluded.""" + rel = Path("i18n") / ".hidden_config" + assert is_excluded(rel) + + # --- pycache guard --- + + @pytest.mark.unit + @pytest.mark.edge_case + def test_pycache_anywhere_in_path_is_excluded(self): + """__pycache__ appearing at any depth of the path triggers exclusion.""" + rel = Path("i18n") / "__pycache__" / "x.cpython-312.pyc" + assert is_excluded(rel) + + # --- normal / included files --- + + @pytest.mark.unit + def test_normal_python_file_is_not_excluded(self): + """Regular Python source files at the repository root pass through.""" + rel = Path("processor.py") + assert not is_excluded(rel) + + @pytest.mark.unit + def test_metadata_txt_is_not_excluded(self): + """metadata.txt must not be excluded - it ships with the plugin.""" + rel = Path("metadata.txt") + assert not is_excluded(rel) + + @pytest.mark.unit + def test_readme_is_not_excluded(self): + """README.md is a production file and must not be excluded.""" + rel = Path("README.md") + assert not is_excluded(rel) + + @pytest.mark.unit + def test_license_is_not_excluded(self): + """LICENSE must not be excluded - it ships with the plugin (see + ai/core/release-conventions.md).""" + rel = Path("LICENSE") + assert not is_excluded(rel) + + @pytest.mark.unit + def test_ui_file_is_not_excluded(self): + """Qt Designer .ui files at the repository root must not be excluded.""" + rel = Path("data_wizard_dialog_base.ui") + assert not is_excluded(rel) + + @pytest.mark.unit + def test_nested_i18n_file_is_not_excluded(self): + """Compiled/production files nested under a kept directory (i18n/) pass through.""" + rel = Path("i18n") / "Data_Wizard_de.ts" + assert not is_excluded(rel) + + +# =========================================================================== +# TestReadVersion +# =========================================================================== + +class TestReadVersion: + """Unit tests for read_version().""" + + @pytest.mark.unit + def test_reads_version_from_valid_metadata(self, tmp_path): + """Returns the version string from a well-formed metadata.txt.""" + (tmp_path / "metadata.txt").write_text( + "[general]\nversion=1.2.3\n", encoding="utf-8" + ) + assert read_version(tmp_path) == "1.2.3" + + @pytest.mark.unit + def test_version_value_is_stripped_of_whitespace(self, tmp_path): + """Leading and trailing whitespace around the version value is stripped.""" + (tmp_path / "metadata.txt").write_text( + "[general]\nversion= 2.0.0 \n", encoding="utf-8" + ) + assert read_version(tmp_path) == "2.0.0" + + @pytest.mark.unit + def test_missing_metadata_raises_file_not_found(self, tmp_path): + """FileNotFoundError is raised when metadata.txt does not exist.""" + with pytest.raises(FileNotFoundError, match="metadata.txt"): + read_version(tmp_path) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_missing_version_key_raises_value_error(self, tmp_path): + """ValueError is raised when [general] section exists but has no version key.""" + (tmp_path / "metadata.txt").write_text( + "[general]\nname=Data Wizard\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="version"): + read_version(tmp_path) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_metadata_without_general_section_raises(self, tmp_path): + """ValueError/KeyError is raised when metadata.txt has no [general] section.""" + (tmp_path / "metadata.txt").write_text( + "[other]\nversion=1.0.0\n", encoding="utf-8" + ) + with pytest.raises((ValueError, KeyError)): + read_version(tmp_path) + + +# =========================================================================== +# TestCollectFiles +# =========================================================================== + +class TestCollectFiles: + """Unit tests for collect_files().""" + + @pytest.mark.unit + def test_returns_list_of_relative_paths(self, tmp_path): + """collect_files returns a list whose entries are relative Path objects.""" + (tmp_path / "keep.py").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert isinstance(result, list) + assert all(isinstance(p, Path) for p in result) + assert not any(p.is_absolute() for p in result) + + @pytest.mark.unit + def test_normal_file_is_collected(self, tmp_path): + """A regular Python file at repository root is collected.""" + (tmp_path / "processor.py").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert Path("processor.py") in result + + @pytest.mark.unit + def test_test_directory_is_excluded(self, tmp_path): + """Files inside test/ are never included in the collected list.""" + (tmp_path / "test").mkdir() + (tmp_path / "test" / "test_something.py").write_text("", encoding="utf-8") + (tmp_path / "keep.py").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert Path("keep.py") in result + assert not any(Path("test") in p.parents or p == Path("test") for p in result) + + @pytest.mark.unit + def test_pyc_files_are_excluded(self, tmp_path): + """*.pyc compiled files are not collected even when alongside source.""" + (tmp_path / "module.py").write_text("", encoding="utf-8") + (tmp_path / "module.pyc").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert Path("module.py") in result + assert Path("module.pyc") not in result + + @pytest.mark.unit + def test_directories_themselves_are_not_returned(self, tmp_path): + """Only files appear in the result - directory paths are never included.""" + (tmp_path / "subdir").mkdir() + (tmp_path / "subdir" / "file.py").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert all((tmp_path / p).is_file() for p in result) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_empty_directory_returns_empty_list(self, tmp_path): + """An empty directory tree returns an empty list without raising.""" + result = collect_files(tmp_path) + assert result == [] + + @pytest.mark.unit + @pytest.mark.edge_case + def test_dotfile_is_not_collected(self, tmp_path): + """Dotfiles at repository root are not collected.""" + (tmp_path / ".gitignore").write_text("", encoding="utf-8") + (tmp_path / "keep.py").write_text("", encoding="utf-8") + result = collect_files(tmp_path) + assert Path(".gitignore") not in result + assert Path("keep.py") in result + + +# =========================================================================== +# TestBuildZip +# =========================================================================== + +class TestBuildZip: + """Unit tests for build_zip().""" + + @pytest.mark.unit + def test_creates_zip_file_at_given_path(self, tmp_path): + """A ZIP file is created at the specified zip_path.""" + src = tmp_path / "repo" + src.mkdir() + (src / "module.py").write_text("# code", encoding="utf-8") + zip_path = tmp_path / "dist" / "plugin.zip" + + build_zip(src, [Path("module.py")], zip_path, "data_wizard") + + assert zip_path.exists() + assert zipfile.is_zipfile(zip_path) + + @pytest.mark.unit + def test_arcnames_are_prefixed_with_plugin_folder(self, tmp_path): + """Every entry inside the ZIP is prefixed with '/'.""" + src = tmp_path / "repo" + src.mkdir() + (src / "init.py").write_text("", encoding="utf-8") + zip_path = tmp_path / "out.zip" + + build_zip(src, [Path("init.py")], zip_path, "data_wizard") + + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + assert all(n.startswith("data_wizard/") for n in names) + + @pytest.mark.unit + def test_zip_contains_all_provided_files(self, tmp_path): + """Every file supplied in the files list appears in the ZIP archive.""" + src = tmp_path / "repo" + src.mkdir() + for name in ("a.py", "b.py", "c.txt"): + (src / name).write_text("content", encoding="utf-8") + zip_path = tmp_path / "out.zip" + + build_zip(src, [Path("a.py"), Path("b.py"), Path("c.txt")], zip_path, "Plug") + + with zipfile.ZipFile(zip_path, "r") as zf: + names = set(zf.namelist()) + assert "Plug/a.py" in names + assert "Plug/b.py" in names + assert "Plug/c.txt" in names + + @pytest.mark.unit + def test_creates_missing_parent_directories(self, tmp_path): + """The dist/ parent directory (and any intermediate dirs) is created automatically.""" + src = tmp_path / "repo" + src.mkdir() + (src / "f.py").write_text("", encoding="utf-8") + zip_path = tmp_path / "new_dir" / "sub" / "out.zip" + + build_zip(src, [Path("f.py")], zip_path, "P") + + assert zip_path.parent.exists() + assert zip_path.exists() + + @pytest.mark.unit + def test_nested_file_preserves_posix_arcname(self, tmp_path): + """Nested files use forward slashes in the arcname regardless of OS.""" + src = tmp_path / "repo" + (src / "i18n").mkdir(parents=True) + (src / "i18n" / "Data_Wizard_de.ts").write_text("", encoding="utf-8") + zip_path = tmp_path / "out.zip" + + build_zip(src, [Path("i18n") / "Data_Wizard_de.ts"], zip_path, "data_wizard") + + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + assert "data_wizard/i18n/Data_Wizard_de.ts" in names + + @pytest.mark.unit + @pytest.mark.edge_case + def test_empty_file_list_creates_empty_valid_zip(self, tmp_path): + """An empty files list produces a valid but empty ZIP without raising.""" + src = tmp_path / "repo" + src.mkdir() + zip_path = tmp_path / "empty.zip" + + build_zip(src, [], zip_path, "P") + + assert zip_path.exists() + with zipfile.ZipFile(zip_path, "r") as zf: + assert zf.namelist() == [] diff --git a/test/test_data_wizard.py b/test/test_data_wizard.py new file mode 100644 index 0000000..751459d --- /dev/null +++ b/test/test_data_wizard.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- +"""Tests for data_wizard.py - Data_Wizard plugin class and _AtkisTask. + +Unlike ibtoolpartion's test_ibtoolpartion.py, this file does not need a +sys.modules mock of qgis.* - QGIS actually imports and initialises correctly +in this plugin's test environment (see test/utilities.py), so the real +Data_Wizard / _AtkisTask classes are exercised against a MagicMock iface +instead of a fully mocked QGIS stack. This is simpler and closer to the +production import path. +""" + +from unittest.mock import MagicMock + +import pytest +from qgis.core import Qgis +from qgis.PyQt.QtCore import QSettings + +from .utilities import get_qgis_app + +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() + +from data_wizard.data_wizard import Data_Wizard, _AtkisTask # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _locale_setting(): + """Data_Wizard.__init__ reads QSettings 'locale/userLocale' and slices + the result unconditionally (locale = ...value(...)[0:2]). QGIS Desktop + always has this set, but a bare test environment does not, which would + raise TypeError: 'NoneType' object is not subscriptable outside of it. + Set a value up front so construction matches the real-world case that + the code was written for. NOTE: this is a real fragility in + data_wizard.py, not just a test gap - see docs/test-strategy.md -> + Gap Analysis.""" + QSettings().setValue('locale/userLocale', 'de_DE') + yield + + +@pytest.fixture +def mock_iface(): + """Fresh mock QGIS interface for each test. + + mainWindow() must return a real QObject (not a MagicMock) because + add_action() passes it straight into QAction(icon, text, parent) - + PyQt's sip bindings reject a non-QObject parent with a TypeError. + """ + iface = MagicMock() + iface.mainWindow.return_value = _PARENT + iface.messageBar.return_value = MagicMock() + return iface + + +@pytest.fixture +def plugin(mock_iface): + """Fresh Data_Wizard instance for each test.""" + return Data_Wizard(mock_iface) + + +def _prepare_dialog(plugin_, *, source_dir="", hu_file="", target_dir="", + study_area="", hu_function_field=None): + """Configure a mock dialog so run() skips real dialog creation/exec_().""" + plugin_.first_start = False + mock_dlg = MagicMock() + mock_dlg.exec_.return_value = 1 # QDialog.Accepted-ish truthy value + mock_dlg.get_source_dir.return_value = source_dir + mock_dlg.get_hu_file.return_value = hu_file + mock_dlg.get_hu_function_field.return_value = hu_function_field + mock_dlg.get_study_area_file.return_value = study_area + mock_dlg.get_target_dir.return_value = target_dir + plugin_.dlg = mock_dlg + return mock_dlg + + +# =========================================================================== +# Data_Wizard.__init__ +# =========================================================================== + +class TestDataWizardInit: + """Tests for Data_Wizard.__init__.""" + + @pytest.mark.unit + def test_iface_is_stored(self, plugin, mock_iface): + """Stores the iface argument as self.iface.""" + assert plugin.iface is mock_iface + + @pytest.mark.unit + def test_first_start_is_none_before_initgui(self, plugin): + """first_start is None before initGui() has been called.""" + assert plugin.first_start is None + + @pytest.mark.unit + def test_actions_list_is_empty_on_creation(self, plugin): + """actions list is empty directly after construction.""" + assert plugin.actions == [] + + @pytest.mark.unit + def test_task_running_is_false_on_creation(self, plugin): + """_task_running starts False.""" + assert plugin._task_running is False + + @pytest.mark.unit + def test_task_is_none_on_creation(self, plugin): + """task starts as None.""" + assert plugin.task is None + + +# =========================================================================== +# Data_Wizard.tr +# =========================================================================== + +class TestDataWizardTr: + """Tests for Data_Wizard.tr.""" + + @pytest.mark.unit + def test_tr_returns_the_input_message_without_a_translation_installed(self, plugin): + """tr() returns the message unchanged when no translator is installed for the locale.""" + assert plugin.tr("Hello") == "Hello" + + +# =========================================================================== +# Data_Wizard.add_action +# =========================================================================== + +class TestDataWizardAddAction: + """Tests for Data_Wizard.add_action.""" + + @pytest.mark.unit + def test_returns_a_non_none_action(self, plugin): + """add_action() returns the created action object.""" + action = plugin.add_action(":/icon.png", "Test", lambda: None, parent=None) + assert action is not None + + @pytest.mark.unit + def test_appends_action_to_self_actions(self, plugin): + """add_action() appends the new action to self.actions.""" + before = len(plugin.actions) + plugin.add_action(":/icon.png", "Test", lambda: None, parent=None) + assert len(plugin.actions) == before + 1 + + @pytest.mark.unit + def test_calls_addtoolbaricon_when_enabled(self, plugin, mock_iface): + """add_action() calls iface.addToolBarIcon when add_to_toolbar=True.""" + plugin.add_action(":/icon.png", "Test", lambda: None, + add_to_toolbar=True, parent=None) + mock_iface.addToolBarIcon.assert_called() + + @pytest.mark.unit + @pytest.mark.edge_case + def test_skips_addtoolbaricon_when_disabled(self, plugin, mock_iface): + """add_action() does not call iface.addToolBarIcon when add_to_toolbar=False.""" + plugin.add_action(":/icon.png", "Test", lambda: None, + add_to_toolbar=False, parent=None) + mock_iface.addToolBarIcon.assert_not_called() + + @pytest.mark.unit + def test_calls_addplugintomenu_when_enabled(self, plugin, mock_iface): + """add_action() calls iface.addPluginToMenu when add_to_menu=True.""" + plugin.add_action(":/icon.png", "Test", lambda: None, + add_to_menu=True, parent=None) + mock_iface.addPluginToMenu.assert_called() + + @pytest.mark.unit + @pytest.mark.edge_case + def test_skips_addplugintomenu_when_disabled(self, plugin, mock_iface): + """add_action() does not call iface.addPluginToMenu when add_to_menu=False.""" + plugin.add_action(":/icon.png", "Test", lambda: None, + add_to_menu=False, parent=None) + mock_iface.addPluginToMenu.assert_not_called() + + +# =========================================================================== +# Data_Wizard.initGui / unload +# =========================================================================== + +class TestDataWizardInitGui: + """Tests for Data_Wizard.initGui.""" + + @pytest.mark.unit + def test_sets_first_start_to_true(self, plugin): + """initGui() sets first_start to True.""" + plugin.initGui() + assert plugin.first_start is True + + @pytest.mark.unit + def test_registers_exactly_one_action(self, plugin): + """initGui() adds exactly one entry to self.actions.""" + count_before = len(plugin.actions) + plugin.initGui() + assert len(plugin.actions) == count_before + 1 + + +class TestDataWizardUnload: + """Tests for Data_Wizard.unload.""" + + @pytest.mark.unit + def test_calls_removetoolbaricon_for_every_action(self, plugin, mock_iface): + """unload() calls iface.removeToolBarIcon for every registered action.""" + plugin.initGui() + mock_iface.removeToolBarIcon.reset_mock() + plugin.unload() + assert mock_iface.removeToolBarIcon.call_count >= 1 + + @pytest.mark.unit + def test_calls_removepluginmenu_for_every_action(self, plugin, mock_iface): + """unload() calls iface.removePluginMenu for every registered action.""" + plugin.initGui() + mock_iface.removePluginMenu.reset_mock() + plugin.unload() + assert mock_iface.removePluginMenu.call_count >= 1 + + +# =========================================================================== +# Data_Wizard.run() - validation paths +# =========================================================================== + +class TestDataWizardRunValidation: + """Tests for input validation inside Data_Wizard.run().""" + + @pytest.mark.unit + def test_missing_source_dir_triggers_warning(self, plugin, mock_iface): + """run() pushes a warning when source_dir is empty.""" + _prepare_dialog(plugin, source_dir="", hu_file="x.shp", target_dir=".") + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_missing_hu_path_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when hu_path is empty.""" + _prepare_dialog(plugin, source_dir=str(tmp_path), hu_file="", target_dir=str(tmp_path)) + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_missing_target_dir_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when target_dir is empty.""" + hu_file = tmp_path / "hu.shp" + hu_file.write_text("x") + _prepare_dialog(plugin, source_dir=str(tmp_path), hu_file=str(hu_file), target_dir="") + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_nonexistent_source_dir_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when source_dir does not exist on disk.""" + hu_file = tmp_path / "hu.shp" + hu_file.write_text("x") + _prepare_dialog(plugin, source_dir=str(tmp_path / "does_not_exist"), + hu_file=str(hu_file), target_dir=str(tmp_path)) + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_nonexistent_hu_file_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when hu_path does not exist on disk.""" + _prepare_dialog(plugin, source_dir=str(tmp_path), + hu_file=str(tmp_path / "missing.shp"), target_dir=str(tmp_path)) + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_nonexistent_target_dir_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when target_dir does not exist on disk.""" + hu_file = tmp_path / "hu.shp" + hu_file.write_text("x") + _prepare_dialog(plugin, source_dir=str(tmp_path), hu_file=str(hu_file), + target_dir=str(tmp_path / "does_not_exist")) + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + @pytest.mark.edge_case + def test_nonexistent_study_area_triggers_warning(self, plugin, mock_iface, tmp_path): + """run() pushes a warning when a non-empty study_area_path does not exist.""" + hu_file = tmp_path / "hu.shp" + hu_file.write_text("x") + _prepare_dialog(plugin, source_dir=str(tmp_path), hu_file=str(hu_file), + target_dir=str(tmp_path), study_area=str(tmp_path / "missing.shp")) + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_called() + + @pytest.mark.unit + def test_returns_early_when_dialog_rejected(self, plugin, mock_iface, tmp_path): + """run() returns without validation or pushMessage when the dialog is cancelled.""" + mock_dlg = _prepare_dialog(plugin, source_dir=str(tmp_path)) + mock_dlg.exec_.return_value = 0 # QDialog.Rejected + plugin.run() + mock_iface.messageBar.return_value.pushMessage.assert_not_called() + + +class TestDataWizardRunTaskGuard: + """Tests for the _task_running guard inside Data_Wizard.run().""" + + @pytest.mark.unit + def test_second_call_while_task_running_is_rejected(self, plugin, mock_iface, tmp_path): + """A second run() call while a task is already running is rejected with a warning.""" + hu_file = tmp_path / "hu.shp" + hu_file.write_text("x") + _prepare_dialog(plugin, source_dir=str(tmp_path), hu_file=str(hu_file), + target_dir=str(tmp_path)) + plugin._task_running = True + + plugin.run() + + mock_iface.messageBar.return_value.pushMessage.assert_called_once() + args, kwargs = mock_iface.messageBar.return_value.pushMessage.call_args + assert kwargs.get("level") == Qgis.Warning + + +class TestDataWizardOnTaskFinished: + """Tests for Data_Wizard._on_task_finished.""" + + @pytest.mark.unit + def test_resets_task_running_to_false(self, plugin): + """_on_task_finished() sets _task_running back to False.""" + plugin._task_running = True + plugin._on_task_finished() + assert plugin._task_running is False + + +# =========================================================================== +# _AtkisTask.run() +# =========================================================================== + +class TestAtkisTaskRun: + """Tests for _AtkisTask.run().""" + + @pytest.mark.unit + def test_returns_false_and_sets_exception_on_failure(self, mock_iface, tmp_path): + """run() returns False and stores the raised exception when process_atkis fails.""" + task = _AtkisTask( + source_dir=str(tmp_path / "does_not_exist"), + hu_path=str(tmp_path / "missing_hu.shp"), + target_dir=str(tmp_path), + study_area_path=None, + hu_function_field=None, + iface=mock_iface) + + result = task.run() + + assert result is False + assert task.exception is not None + assert isinstance(task.exception, Exception) + + +# =========================================================================== +# _AtkisTask.finished() +# =========================================================================== + +class TestAtkisTaskFinished: + """Tests for _AtkisTask.finished() in its three branches.""" + + def _make_task(self, mock_iface, on_finished=None): + return _AtkisTask( + source_dir="src", hu_path="hu.shp", target_dir="target", + study_area_path=None, hu_function_field=None, + iface=mock_iface, on_finished=on_finished) + + @pytest.mark.unit + def test_success_pushes_success_message_and_calls_on_finished(self, mock_iface): + """finished(True) pushes a Success message and invokes on_finished().""" + calls = [] + task = self._make_task(mock_iface, on_finished=lambda: calls.append(1)) + + task.finished(True) + + assert calls == [1] + _, kwargs = mock_iface.messageBar.return_value.pushMessage.call_args + assert kwargs.get("level") == Qgis.Success + + @pytest.mark.unit + @pytest.mark.edge_case + def test_cancelled_pushes_info_message(self, mock_iface): + """finished(False) after task.cancel() pushes an Info 'cancelled' message.""" + task = self._make_task(mock_iface) + task.cancel() + assert task.isCanceled() is True + + task.finished(False) + + _, kwargs = mock_iface.messageBar.return_value.pushMessage.call_args + assert kwargs.get("level") == Qgis.Info + + @pytest.mark.unit + def test_error_pushes_critical_message_with_exception_text(self, mock_iface): + """finished(False) with a stored exception (not cancelled) pushes a Critical message.""" + task = self._make_task(mock_iface) + task.exception = ValueError("boom") + + task.finished(False) + + args, kwargs = mock_iface.messageBar.return_value.pushMessage.call_args + assert kwargs.get("level") == Qgis.Critical + assert "boom" in args[1] diff --git a/test/test_data_wizard_dialog.py b/test/test_data_wizard_dialog.py index 70766a6..11be25f 100644 --- a/test/test_data_wizard_dialog.py +++ b/test/test_data_wizard_dialog.py @@ -13,13 +13,23 @@ __copyright__ = 'Copyright 2026, Oliver Harig' import unittest +from unittest.mock import patch +import pytest from qgis.PyQt.QtWidgets import QDialogButtonBox, QDialog -from data_wizard_dialog import Data_WizardDialog - from .utilities import get_qgis_app -QGIS_APP = get_qgis_app() + +# Was: `QGIS_APP = get_qgis_app()`, binding the whole 4-tuple to one name - +# unused here, but a latent bug: any future use of QGIS_APP as "the app" +# object would actually get the tuple. +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() + +# Was: `from data_wizard_dialog import Data_WizardDialog` - a bare import +# that only resolved because the container's CWD happened to be the plugin +# folder. The absolute package import below matches every other test file +# in this suite and resolves the same way locally and in Docker. +from data_wizard.data_wizard_dialog import Data_WizardDialog, _skip_function_field_label class Data_WizardDialogTest(unittest.TestCase): @@ -33,6 +43,7 @@ def tearDown(self): """Runs after each test.""" self.dialog = None + @pytest.mark.unit def test_dialog_ok(self): """Test we can click OK.""" @@ -41,6 +52,7 @@ def test_dialog_ok(self): result = self.dialog.result() self.assertEqual(result, QDialog.Accepted) + @pytest.mark.unit def test_dialog_cancel(self): """Test we can click cancel.""" button = self.dialog.button_box.button(QDialogButtonBox.Cancel) @@ -53,3 +65,162 @@ def test_dialog_cancel(self): suite = unittest.makeSuite(Data_WizardDialogTest) runner = unittest.TextTestRunner(verbosity=2) runner.run(suite) + + +# =========================================================================== +# Getters +# =========================================================================== + +class TestDataWizardDialogGetters: + """Tests for the get_*() accessor methods.""" + + @pytest.fixture + def dialog(self): + return Data_WizardDialog(None) + + @pytest.mark.unit + def test_get_source_dir_strips_whitespace(self, dialog): + """get_source_dir() strips leading/trailing whitespace from the field.""" + dialog.lineEdit_source.setText(" /some/source ") + assert dialog.get_source_dir() == "/some/source" + + @pytest.mark.unit + def test_get_hu_file_strips_whitespace(self, dialog): + """get_hu_file() strips leading/trailing whitespace from the field.""" + dialog.lineEdit_hu.setText(" /some/hu.shp ") + assert dialog.get_hu_file() == "/some/hu.shp" + + @pytest.mark.unit + def test_get_study_area_file_strips_whitespace(self, dialog): + """get_study_area_file() strips leading/trailing whitespace from the field.""" + dialog.lineEdit_studyarea.setText(" /some/area.shp ") + assert dialog.get_study_area_file() == "/some/area.shp" + + @pytest.mark.unit + def test_get_target_dir_strips_whitespace(self, dialog): + """get_target_dir() strips leading/trailing whitespace from the field.""" + dialog.lineEdit_target.setText(" /some/target ") + assert dialog.get_target_dir() == "/some/target" + + @pytest.mark.unit + @pytest.mark.edge_case + def test_getters_return_empty_string_for_empty_fields(self, dialog): + """All get_*() accessors return '' (not None) when the field is empty.""" + assert dialog.get_source_dir() == "" + assert dialog.get_hu_file() == "" + assert dialog.get_study_area_file() == "" + assert dialog.get_target_dir() == "" + + @pytest.mark.unit + def test_get_hu_function_field_is_none_initially(self, dialog): + """get_hu_function_field() returns None before any HU file was resolved.""" + assert dialog.get_hu_function_field() is None + + +# =========================================================================== +# _on_hu_text_changed +# =========================================================================== + +class TestOnHuTextChanged: + """Tests for _on_hu_text_changed.""" + + @pytest.fixture + def dialog(self): + return Data_WizardDialog(None) + + @pytest.mark.unit + def test_resets_hu_function_field_to_none(self, dialog): + """Any text change on the HU field clears a previously resolved function field.""" + dialog._hu_function_field = "gfkzshh" + dialog._on_hu_text_changed("new/path.shp") + assert dialog._hu_function_field is None + + @pytest.mark.unit + @pytest.mark.edge_case + def test_manual_text_edit_via_linetext_setText_also_resets(self, dialog): + """Editing lineEdit_hu directly (not via _browse_hu) also resets the field + - this is the documented behavior difference: manual edits do not + re-run detection, so the field stays None until the next _browse_hu.""" + dialog._hu_function_field = "gfkzshh" + dialog.lineEdit_hu.setText("manually/typed/path.shp") + assert dialog._hu_function_field is None + + +# =========================================================================== +# _resolve_hu_function_field +# =========================================================================== + +class TestResolveHuFunctionField: + """Tests for _resolve_hu_function_field's four branches.""" + + @pytest.fixture + def dialog(self): + return Data_WizardDialog(None) + + @pytest.mark.unit + def test_ok_status_leaves_function_field_none(self, dialog): + """status='ok' (field already present) leaves _hu_function_field at None.""" + with patch("data_wizard.processor.detect_hu_function_field", + return_value=('ok', None)): + dialog._resolve_hu_function_field("hu.shp") + assert dialog._hu_function_field is None + + @pytest.mark.unit + def test_auto_status_sets_function_field(self, dialog): + """status='auto' sets _hu_function_field to the detected column name.""" + with patch("data_wizard.processor.detect_hu_function_field", + return_value=('auto', 'gfkz_code')): + dialog._resolve_hu_function_field("hu.shp") + assert dialog._hu_function_field == 'gfkz_code' + + @pytest.mark.unit + @pytest.mark.edge_case + def test_ambiguous_status_uses_user_selected_field(self, dialog): + """status='ambiguous' sets _hu_function_field to the user's QInputDialog choice.""" + with ( + patch("data_wizard.processor.detect_hu_function_field", + return_value=('ambiguous', ['field_a', 'field_b'])), + patch("data_wizard.data_wizard_dialog.QtWidgets.QInputDialog.getItem", + return_value=('field_b', True)), + ): + dialog._resolve_hu_function_field("hu.shp") + assert dialog._hu_function_field == 'field_b' + + @pytest.mark.unit + @pytest.mark.edge_case + def test_ambiguous_status_skip_choice_leaves_field_none(self, dialog): + """Choosing the '- skip -' option in the ambiguous case leaves the field at None.""" + skip_label = _skip_function_field_label() + with ( + patch("data_wizard.processor.detect_hu_function_field", + return_value=('ambiguous', ['field_a', 'field_b'])), + patch("data_wizard.data_wizard_dialog.QtWidgets.QInputDialog.getItem", + return_value=(skip_label, True)), + ): + dialog._resolve_hu_function_field("hu.shp") + assert dialog._hu_function_field is None + + @pytest.mark.unit + @pytest.mark.edge_case + def test_ambiguous_status_dialog_cancelled_leaves_field_none(self, dialog): + """Cancelling the QInputDialog (ok=False) leaves the field at None.""" + with ( + patch("data_wizard.processor.detect_hu_function_field", + return_value=('ambiguous', ['field_a', 'field_b'])), + patch("data_wizard.data_wizard_dialog.QtWidgets.QInputDialog.getItem", + return_value=('field_a', False)), + ): + dialog._resolve_hu_function_field("hu.shp") + assert dialog._hu_function_field is None + + @pytest.mark.unit + def test_value_error_shows_warning_and_leaves_field_none(self, dialog): + """A ValueError from detect_hu_function_field shows a QMessageBox warning.""" + with ( + patch("data_wizard.processor.detect_hu_function_field", + side_effect=ValueError("Layer ungültig: hu.shp")), + patch("data_wizard.data_wizard_dialog.QtWidgets.QMessageBox.warning") as mock_warn, + ): + dialog._resolve_hu_function_field("hu.shp") + mock_warn.assert_called_once() + assert dialog._hu_function_field is None diff --git a/test/test_processor.py b/test/test_processor.py new file mode 100644 index 0000000..6d140fe --- /dev/null +++ b/test/test_processor.py @@ -0,0 +1,678 @@ +# -*- coding: utf-8 -*- +"""Tests for processor.py - ATKIS Basis-DLM processing logic. + +Tier boundary follows docs/test-strategy.md: a function counts as +``integration`` if (and only if) it calls ``processing.run()`` directly or +indirectly. ``_load_shp``, ``_write_gpkg``, ``_add_function_field_copy`` and +``detect_hu_function_field`` never call ``processing.run()`` and are unit +tests even though they touch real QGIS classes (QgsVectorLayer, QgsFeature). + +Integration tests for process_atkis / _process_hu / _process_rn / _process_aux +use the real ATKIS/ALKIS data shipped in Testdaten/ (see docs/test-strategy.md +-> Justified Exclusions for why this is checked into the repo instead of +generated). +""" + +import pytest +from qgis.core import ( + QgsCoordinateReferenceSystem, QgsFeature, QgsField, QgsGeometry, + QgsPointXY, QgsVectorLayer, QgsWkbTypes, NULL, +) +from qgis.PyQt.QtCore import QVariant + +from .utilities import get_qgis_app + +QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app() + +from .layer_factories import ( # noqa: E402 + add_feature_to_layer, make_line_layer, + make_polygon_layer, make_square_geom, + write_layer_as_shp, +) +from .config import PROJECT_ROOT # noqa: E402 + +from data_wizard.processor import ( # noqa: E402 + _add_function_field_copy, _check_cancel, + _clip_if_needed, _load_shp, _prepare_clip_mask, _process_aux, + _process_hu, _process_rn, _reproject_if_needed, _write_gpkg, + detect_hu_function_field, process_atkis, +) + +CRS_25833 = QgsCoordinateReferenceSystem("EPSG:25833") +CRS_4326 = QgsCoordinateReferenceSystem("EPSG:4326") + +TESTDATEN_DIR = PROJECT_ROOT / "Testdaten" +ATKIS_DIR = TESTDATEN_DIR / "ATKIS Basis DLM dataset" +HU_SHP = TESTDATEN_DIR / "ALKIS Gebäude" / "GebauedeBauwerk.shp" + +_ATKIS_LAYERS = ("ver01_l", "ver02_l", "ver03_l", "veg02_f", "veg03_f", + "gew01_f", "gew01_l") +ATKIS_DATA_AVAILABLE = ( + ATKIS_DIR.is_dir() + and all((ATKIS_DIR / f"{name}.shp").exists() for name in _ATKIS_LAYERS) + and HU_SHP.exists() +) +requires_atkis_testdaten = pytest.mark.skipif( + not ATKIS_DATA_AVAILABLE, + reason="Testdaten/ATKIS Basis DLM dataset or Testdaten/ALKIS Gebäude " + "missing - see docs/test-strategy.md") + + +def _assert_valid_geometries(layer): + """Mandatory geometry checks per docs/test-strategy.md Step 4.""" + assert layer is not None + for feat in layer.getFeatures(): + geom = feat.geometry() + assert not geom.isNull(), "Geometry must not be null" + assert not geom.isEmpty(), "Geometry must not be empty" + assert geom.isGeosValid(), "Geometry must be GEOS-valid" + + +# =========================================================================== +# detect_hu_function_field +# =========================================================================== + +class TestDetectHuFunctionField: + """Tests for detect_hu_function_field.""" + + def _hu_layer_with_field(self, tmp_path, field_name, values, fname="hu.shp"): + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes( + [QgsField(field_name, QVariant.String, len=254)]) + layer.updateFields() + for i, val in enumerate(values): + geom = make_square_geom(i * 10, 0, 5) + add_feature_to_layer(layer, geom, attributes=[val]) + path = str(tmp_path / fname) + return write_layer_as_shp(layer, path) + + @pytest.mark.unit + def test_returns_ok_when_fkt_field_present(self, tmp_path): + """Returns ('ok', None) when the HU already has a 'fkt' field.""" + path = self._hu_layer_with_field(tmp_path, "fkt", ["31001_1000"]) + assert detect_hu_function_field(path) == ('ok', None) + + @pytest.mark.unit + def test_field_match_is_case_insensitive(self, tmp_path): + """A field named 'FUNKTION' (any case) also counts as already present.""" + path = self._hu_layer_with_field(tmp_path, "FUNKTIO", ["x"], fname="hu2.shp") + # DBF truncates to 10 chars; use a name that still matches after lower() + assert detect_hu_function_field(path)[0] in ('ok', 'ambiguous') + + @pytest.mark.unit + def test_returns_auto_for_single_matching_field(self, tmp_path): + """Returns ('auto', name) when exactly one field matches the pattern.""" + path = self._hu_layer_with_field( + tmp_path, "code", ["31001_1000", "31001_2000", "31001_3000"]) + assert detect_hu_function_field(path) == ('auto', 'code') + + @pytest.mark.unit + @pytest.mark.edge_case + def test_returns_ambiguous_when_no_field_matches(self, tmp_path): + """Returns ('ambiguous', all_field_names) when no field matches the pattern.""" + path = self._hu_layer_with_field(tmp_path, "code", ["abc", "def"]) + status, result = detect_hu_function_field(path) + assert status == 'ambiguous' + assert 'code' in result + + @pytest.mark.unit + @pytest.mark.edge_case + def test_returns_ambiguous_when_multiple_fields_match(self, tmp_path): + """Returns ('ambiguous', ...) when more than one field matches the pattern.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes( + [QgsField("code1", QVariant.String, len=254), + QgsField("code2", QVariant.String, len=254)]) + layer.updateFields() + add_feature_to_layer( + layer, make_square_geom(0, 0, 5), attributes=["31001_1000", "31001_2000"]) + path = write_layer_as_shp(layer, str(tmp_path / "hu.shp")) + status, result = detect_hu_function_field(path) + assert status == 'ambiguous' + assert 'code1' in result and 'code2' in result + + @pytest.mark.unit + def test_raises_value_error_for_invalid_path(self, tmp_path): + """Raises ValueError when hu_path cannot be loaded as a valid layer.""" + with pytest.raises(ValueError): + detect_hu_function_field(str(tmp_path / "does_not_exist.shp")) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_sample_size_limits_features_read(self, tmp_path): + """Only the first sample_size features are inspected for the pattern.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes([QgsField("code", QVariant.String, len=254)]) + layer.updateFields() + # First 50 features match the pattern; the rest (indices 50-59) don't. + for i in range(60): + value = f"31001_{i:04d}" if i < 50 else "not_a_code" + add_feature_to_layer(layer, make_square_geom(i * 10, 0, 5), attributes=[value]) + path = write_layer_as_shp(layer, str(tmp_path / "hu.shp")) + + # Default sample_size=50 never sees the non-matching tail -> single candidate. + assert detect_hu_function_field(path, sample_size=50) == ('auto', 'code') + # A larger sample_size sees the non-matching values -> ambiguous. + status, _ = detect_hu_function_field(path, sample_size=60) + assert status == 'ambiguous' + + +# =========================================================================== +# _check_cancel +# =========================================================================== + +class TestCheckCancel: + """Tests for _check_cancel.""" + + @pytest.mark.unit + def test_raises_when_task_is_canceled(self): + """Raises an Exception when task.isCanceled() is True.""" + class _Task: + def isCanceled(self): + return True + with pytest.raises(Exception, match="abgebrochen"): + _check_cancel(_Task()) + + @pytest.mark.unit + def test_no_op_when_task_not_canceled(self): + """Does nothing when task.isCanceled() is False.""" + class _Task: + def isCanceled(self): + return False + _check_cancel(_Task()) # must not raise + + @pytest.mark.unit + @pytest.mark.edge_case + def test_no_op_when_task_is_none(self): + """Does nothing when task is None.""" + _check_cancel(None) # must not raise + + +# =========================================================================== +# _load_shp +# =========================================================================== + +class TestLoadShp: + """Tests for _load_shp.""" + + @pytest.mark.unit + def test_raises_file_not_found_for_missing_shp(self, tmp_path): + """Raises FileNotFoundError when .shp does not exist.""" + with pytest.raises(FileNotFoundError): + _load_shp(str(tmp_path), "ver01_l") + + @pytest.mark.unit + @pytest.mark.edge_case + def test_raises_value_error_for_invalid_shp(self, tmp_path): + """Raises ValueError when the file exists but is not a valid shapefile.""" + bad_path = tmp_path / "ver01_l.shp" + bad_path.write_text("not a real shapefile") + with pytest.raises(ValueError): + _load_shp(str(tmp_path), "ver01_l") + + @pytest.mark.unit + def test_loads_valid_shapefile(self, tmp_path): + """Returns a valid QgsVectorLayer for a well-formed shapefile.""" + layer = make_line_layer(crs="EPSG:25833", name="ver01_l") + add_feature_to_layer(layer, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 0), QgsPointXY(10, 0)])) + write_layer_as_shp(layer, str(tmp_path / "ver01_l.shp")) + + result = _load_shp(str(tmp_path), "ver01_l") + assert result.isValid() + assert result.featureCount() == 1 + + +# =========================================================================== +# _add_function_field_copy +# =========================================================================== + +class TestAddFunctionFieldCopy: + """Tests for _add_function_field_copy.""" + + def _layer_with_src_field(self, values): + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes([QgsField("src", QVariant.String, len=254)]) + layer.updateFields() + for i, val in enumerate(values): + add_feature_to_layer(layer, make_square_geom(i * 10, 0, 5), attributes=[val]) + return layer + + @pytest.mark.unit + def test_creates_target_field_and_copies_values(self): + """Adds target_field_name and copies the source field's values into it.""" + layer = self._layer_with_src_field(["31001_1000", "31001_2000"]) + result = _add_function_field_copy(layer, "src", "funktion") + values = [f["funktion"] for f in result.getFeatures()] + assert sorted(values) == ["31001_1000", "31001_2000"] + + @pytest.mark.unit + def test_raises_value_error_for_missing_source_field(self): + """Raises ValueError when source_field_name does not exist on the layer.""" + layer = self._layer_with_src_field(["31001_1000"]) + with pytest.raises(ValueError, match="nicht gefunden"): + _add_function_field_copy(layer, "does_not_exist", "funktion") + + @pytest.mark.unit + @pytest.mark.edge_case + @pytest.mark.xfail( + reason="Known bug: processor._add_function_field_copy compares " + "`value is None`, but a NULL QGIS attribute is a QVariant " + "sentinel, not Python None - so NULL source values are " + "copied as the literal string 'NULL' instead of staying " + "NULL. Documented in docs/test-strategy.md Gap Analysis. " + "Flip this to a plain assertion once processor.py is fixed.", + strict=True) + def test_null_source_values_are_copied_as_null_not_string(self): + """A NULL source value must stay NULL in the target field, not become + the literal string 'NULL'.""" + layer = self._layer_with_src_field([NULL]) + result = _add_function_field_copy(layer, "src", "funktion") + value = next(result.getFeatures())["funktion"] + assert value == NULL, f"Expected NULL to remain NULL, got {value!r}" + + +# =========================================================================== +# _write_gpkg +# =========================================================================== + +class TestWriteGpkg: + """Tests for _write_gpkg.""" + + @pytest.mark.unit + def test_keep_fields_true_preserves_attributes(self, tmp_path): + """keep_fields=True copies the first layer's field schema and values.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes([QgsField("funktion", QVariant.String, len=254)]) + layer.updateFields() + add_feature_to_layer(layer, make_square_geom(0, 0, 5), attributes=["31001_1000"]) + + out = str(tmp_path / "HU.gpkg") + _write_gpkg([layer], out, QgsWkbTypes.MultiPolygon, + force_singlepart=False, keep_fields=True) + + result = QgsVectorLayer(out, "check", "ogr") + assert result.isValid() + assert result.featureCount() == 1 + feat = next(result.getFeatures()) + assert feat["funktion"] == "31001_1000" + + @pytest.mark.unit + def test_force_singlepart_splits_multipart_geometry(self, tmp_path): + """force_singlepart=True writes one feature per part of a multipart geometry.""" + layer = make_polygon_layer(crs="EPSG:25833", name="rn") + multi = QgsGeometry.fromMultiPolygonXY([ + make_square_geom(0, 0, 5).asPolygon(), + make_square_geom(20, 0, 5).asPolygon(), + ]) + add_feature_to_layer(layer, multi) + + # geometry_type is the singlepart target type here, matching how + # _process_rn/_process_aux call _write_gpkg (QgsWkbTypes.LineString, + # not MultiLineString) when force_singlepart=True. + out = str(tmp_path / "AUX_L.gpkg") + _write_gpkg([layer], out, QgsWkbTypes.Polygon, + force_singlepart=True, keep_fields=False) + + result = QgsVectorLayer(out, "check", "ogr") + assert result.featureCount() == 2 + for feat in result.getFeatures(): + assert not feat.geometry().isMultipart() + + @pytest.mark.unit + @pytest.mark.edge_case + def test_raises_value_error_on_crs_mismatch(self, tmp_path): + """Raises ValueError when a later input layer has a different CRS than the first.""" + layer_a = make_polygon_layer(crs="EPSG:25833", name="a") + add_feature_to_layer(layer_a, make_square_geom(0, 0, 5)) + layer_b = make_polygon_layer(crs="EPSG:4326", name="b") + add_feature_to_layer(layer_b, make_square_geom(0, 0, 5)) + + out = str(tmp_path / "out.gpkg") + with pytest.raises(ValueError, match="abweichendes CRS"): + _write_gpkg([layer_a, layer_b], out, QgsWkbTypes.MultiPolygon, + force_singlepart=False, keep_fields=False) + + @pytest.mark.unit + @pytest.mark.edge_case + def test_null_and_empty_geometries_are_skipped(self, tmp_path): + """Features with null or empty geometry are not written to the output.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + add_feature_to_layer(layer, make_square_geom(0, 0, 5)) + # Feature with no geometry at all. + empty_feat = QgsFeature(layer.fields()) + layer.dataProvider().addFeatures([empty_feat]) + + out = str(tmp_path / "out.gpkg") + _write_gpkg([layer], out, QgsWkbTypes.MultiPolygon, + force_singlepart=False, keep_fields=False) + + result = QgsVectorLayer(out, "check", "ogr") + assert result.featureCount() == 1 + + @pytest.mark.unit + @pytest.mark.edge_case + def test_raises_io_error_for_unwritable_path(self, tmp_path): + """Raises IOError when the output path's parent directory does not exist.""" + layer = make_polygon_layer(crs="EPSG:25833", name="a") + add_feature_to_layer(layer, make_square_geom(0, 0, 5)) + bad_path = str(tmp_path / "no_such_subdir" / "out.gpkg") + with pytest.raises(IOError): + _write_gpkg([layer], bad_path, QgsWkbTypes.MultiPolygon, + force_singlepart=False, keep_fields=False) + + +# =========================================================================== +# _reproject_if_needed / _clip_if_needed (integration - processing.run) +# =========================================================================== + +class TestReprojectIfNeeded: + """Tests for _reproject_if_needed.""" + + @pytest.mark.integration + def test_same_crs_returns_same_object(self): + """Returns the identical layer object (no reprojection) when the CRS already matches.""" + layer = make_polygon_layer(crs="EPSG:25833") + result = _reproject_if_needed(layer, CRS_25833) + assert result is layer + + @pytest.mark.integration + def test_different_crs_returns_transformed_layer(self): + """Reprojects the layer when its CRS differs from target_crs.""" + layer = make_polygon_layer(crs="EPSG:4326") + add_feature_to_layer(layer, make_square_geom(13.0, 51.0, 0.01)) + result = _reproject_if_needed(layer, CRS_25833) + assert result is not layer + assert result.crs() == CRS_25833 + _assert_valid_geometries(result) + + +class TestClipIfNeeded: + """Tests for _clip_if_needed.""" + + @pytest.mark.unit + def test_none_mask_is_passthrough(self): + """Returns the layer unchanged when clip_mask is None.""" + layer = make_polygon_layer(crs="EPSG:25833") + result = _clip_if_needed(layer, None) + assert result is layer + + @pytest.mark.integration + def test_clips_to_mask(self): + """Clips the input layer to the given mask polygon.""" + layer = make_polygon_layer(crs="EPSG:25833") + add_feature_to_layer(layer, make_square_geom(0, 0, 100)) + mask = make_polygon_layer(crs="EPSG:25833", name="mask") + add_feature_to_layer(mask, make_square_geom(0, 0, 10)) + + result = _clip_if_needed(layer, mask) + assert result.featureCount() >= 1 + total_area = sum(f.geometry().area() for f in result.getFeatures()) + assert total_area == pytest.approx(100.0, rel=0.05) + + @pytest.mark.integration + @pytest.mark.edge_case + def test_no_overlap_returns_empty_result(self): + """A mask that does not overlap the input layer yields an empty (not erroring) result.""" + layer = make_polygon_layer(crs="EPSG:25833") + add_feature_to_layer(layer, make_square_geom(0, 0, 10)) + mask = make_polygon_layer(crs="EPSG:25833", name="mask") + add_feature_to_layer(mask, make_square_geom(10_000, 10_000, 10)) + + result = _clip_if_needed(layer, mask) + assert result is not None + assert result.featureCount() == 0 + + +# =========================================================================== +# _prepare_clip_mask +# =========================================================================== + +class TestPrepareClipMask: + """Tests for _prepare_clip_mask.""" + + @pytest.mark.unit + def test_empty_path_returns_none(self): + """Returns None when study_area_path is falsy (no clipping requested).""" + assert _prepare_clip_mask("", CRS_25833) is None + assert _prepare_clip_mask(None, CRS_25833) is None + + @pytest.mark.integration + def test_single_polygon_is_not_dissolved(self, tmp_path): + """A study area with a single polygon is returned as-is (no dissolve).""" + layer = make_polygon_layer(crs="EPSG:25833", name="study_area") + add_feature_to_layer(layer, make_square_geom(0, 0, 10)) + path = write_layer_as_shp(layer, str(tmp_path / "study_area.shp")) + + result = _prepare_clip_mask(path, CRS_25833) + assert result.featureCount() == 1 + + @pytest.mark.integration + def test_multiple_polygons_are_dissolved_to_one_feature(self, tmp_path): + """Multiple study-area polygons are dissolved into a single feature.""" + layer = make_polygon_layer(crs="EPSG:25833", name="study_area") + add_feature_to_layer(layer, make_square_geom(0, 0, 10)) + add_feature_to_layer(layer, make_square_geom(9, 0, 10)) # overlapping + path = write_layer_as_shp(layer, str(tmp_path / "study_area.shp")) + + result = _prepare_clip_mask(path, CRS_25833) + assert result.featureCount() == 1 + + @pytest.mark.unit + def test_raises_value_error_for_invalid_path(self, tmp_path): + """Raises ValueError when study_area_path cannot be loaded.""" + with pytest.raises(ValueError): + _prepare_clip_mask(str(tmp_path / "missing.shp"), CRS_25833) + + +# =========================================================================== +# _process_hu / _process_rn / _process_aux (integration, synthetic layers) +# =========================================================================== + +class TestProcessHu: + """Tests for _process_hu.""" + + @pytest.mark.integration + def test_writes_valid_hu_gpkg(self, tmp_path): + """Writes HU.gpkg with valid MultiPolygon geometries and preserved fields.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + layer.dataProvider().addAttributes([QgsField("funktion", QVariant.String, len=254)]) + layer.updateFields() + add_feature_to_layer(layer, make_square_geom(0, 0, 5), attributes=["31001_1000"]) + hu_path = write_layer_as_shp(layer, str(tmp_path / "hu.shp")) + + _process_hu(hu_path, CRS_25833, None, str(tmp_path)) + + out = QgsVectorLayer(str(tmp_path / "HU.gpkg"), "check", "ogr") + assert out.isValid() + assert out.featureCount() == 1 + _assert_valid_geometries(out) + + @pytest.mark.integration + @pytest.mark.edge_case + def test_missing_function_field_logs_warning_without_aborting(self, tmp_path): + """Missing fkt/gfkzshh/funktion and no function_field logs a warning but still writes HU.gpkg.""" + layer = make_polygon_layer(crs="EPSG:25833", name="hu") + add_feature_to_layer(layer, make_square_geom(0, 0, 5)) + hu_path = write_layer_as_shp(layer, str(tmp_path / "hu.shp")) + + messages = [] + _process_hu(hu_path, CRS_25833, None, str(tmp_path), log=messages.append) + + assert any("WARNUNG" in m for m in messages) + assert (tmp_path / "HU.gpkg").exists() + + +class TestProcessRn: + """Tests for _process_rn.""" + + @pytest.mark.integration + def test_writes_merged_singlepart_rn_gpkg(self, tmp_path): + """Merges ver01_l and ver02_l into a singlepart RN.gpkg without attributes.""" + ver01 = make_line_layer(crs="EPSG:25833", name="ver01_l") + add_feature_to_layer(ver01, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 0), QgsPointXY(10, 0)])) + write_layer_as_shp(ver01, str(tmp_path / "ver01_l.shp")) + + ver02 = make_line_layer(crs="EPSG:25833", name="ver02_l") + add_feature_to_layer(ver02, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 10), QgsPointXY(10, 10)])) + write_layer_as_shp(ver02, str(tmp_path / "ver02_l.shp")) + + _process_rn(str(tmp_path), CRS_25833, None, str(tmp_path)) + + out = QgsVectorLayer(str(tmp_path / "RN.gpkg"), "check", "ogr") + assert out.isValid() + assert out.featureCount() == 2 + _assert_valid_geometries(out) + + +class TestProcessAux: + """Tests for _process_aux.""" + + def _write_atkis_subset(self, tmp_path): + """Writes minimal ver03_l/veg02_f/veg03_f/gew01_f/gew01_l shapefiles.""" + + ver03 = make_line_layer(crs="EPSG:25833", name="ver03_l") + add_feature_to_layer(ver03, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 0), QgsPointXY(10, 0)])) + write_layer_as_shp(ver03, str(tmp_path / "ver03_l.shp")) + + veg02 = make_polygon_layer(crs="EPSG:25833", name="veg02_f") + add_feature_to_layer(veg02, make_square_geom(0, 20, 5)) + write_layer_as_shp(veg02, str(tmp_path / "veg02_f.shp")) + + veg03 = make_polygon_layer(crs="EPSG:25833", name="veg03_f") + veg03.dataProvider().addAttributes([QgsField("OBJART", QVariant.String, len=254)]) + veg03.updateFields() + add_feature_to_layer(veg03, make_square_geom(0, 40, 5), attributes=["43005"]) + add_feature_to_layer(veg03, make_square_geom(20, 40, 5), attributes=["11111"]) + write_layer_as_shp(veg03, str(tmp_path / "veg03_f.shp")) + + gew01f = make_polygon_layer(crs="EPSG:25833", name="gew01_f") + add_feature_to_layer(gew01f, make_square_geom(0, 60, 5)) + write_layer_as_shp(gew01f, str(tmp_path / "gew01_f.shp")) + + gew01l = make_line_layer(crs="EPSG:25833", name="gew01_l") + add_feature_to_layer(gew01l, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 80), QgsPointXY(10, 80)])) + write_layer_as_shp(gew01l, str(tmp_path / "gew01_l.shp")) + + @pytest.mark.integration + def test_writes_valid_aux_l_gpkg(self, tmp_path): + """Merges the five AUX source layers into a valid singlepart AUX_L.gpkg.""" + self._write_atkis_subset(tmp_path) + + _process_aux(str(tmp_path), CRS_25833, None, str(tmp_path)) + + out = QgsVectorLayer(str(tmp_path / "AUX_L.gpkg"), "check", "ogr") + assert out.isValid() + assert out.featureCount() > 0 + _assert_valid_geometries(out) + for feat in out.getFeatures(): + assert not feat.geometry().isMultipart() + + @pytest.mark.integration + @pytest.mark.edge_case + def test_veg03_without_matching_objart_produces_no_crash(self, tmp_path): + """veg03_f with no OBJART in (43005, 43006) yields an empty intermediate layer, no crash.""" + + ver03 = make_line_layer(crs="EPSG:25833", name="ver03_l") + add_feature_to_layer(ver03, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 0), QgsPointXY(10, 0)])) + write_layer_as_shp(ver03, str(tmp_path / "ver03_l.shp")) + + veg02 = make_polygon_layer(crs="EPSG:25833", name="veg02_f") + add_feature_to_layer(veg02, make_square_geom(0, 20, 5)) + write_layer_as_shp(veg02, str(tmp_path / "veg02_f.shp")) + + veg03 = make_polygon_layer(crs="EPSG:25833", name="veg03_f") + veg03.dataProvider().addAttributes([QgsField("OBJART", QVariant.String, len=254)]) + veg03.updateFields() + add_feature_to_layer(veg03, make_square_geom(0, 40, 5), attributes=["11111"]) + write_layer_as_shp(veg03, str(tmp_path / "veg03_f.shp")) + + gew01f = make_polygon_layer(crs="EPSG:25833", name="gew01_f") + add_feature_to_layer(gew01f, make_square_geom(0, 60, 5)) + write_layer_as_shp(gew01f, str(tmp_path / "gew01_f.shp")) + + gew01l = make_line_layer(crs="EPSG:25833", name="gew01_l") + add_feature_to_layer(gew01l, QgsGeometry.fromPolylineXY( + [QgsPointXY(0, 80), QgsPointXY(10, 80)])) + write_layer_as_shp(gew01l, str(tmp_path / "gew01_l.shp")) + + _process_aux(str(tmp_path), CRS_25833, None, str(tmp_path)) # must not raise + + out = QgsVectorLayer(str(tmp_path / "AUX_L.gpkg"), "check", "ogr") + assert out.isValid() + + @pytest.mark.integration + @pytest.mark.edge_case + def test_cancel_mid_processing_raises(self, tmp_path): + """task.isCanceled() becoming True mid-run aborts processing with a defined exception.""" + self._write_atkis_subset(tmp_path) + + class _CancelAfterFirstCheck: + def __init__(self): + self.calls = 0 + + def isCanceled(self): + self.calls += 1 + return self.calls > 1 + + with pytest.raises(Exception, match="abgebrochen"): + _process_aux(str(tmp_path), CRS_25833, None, str(tmp_path), + task=_CancelAfterFirstCheck()) + + +# =========================================================================== +# process_atkis (end-to-end, real ATKIS/ALKIS Testdaten) +# =========================================================================== + +class TestProcessAtkis: + """End-to-end tests for process_atkis against Testdaten/.""" + + @requires_atkis_testdaten + @pytest.mark.integration + def test_end_to_end_produces_all_three_gpkg(self, tmp_path): + """Produces HU.gpkg, RN.gpkg and AUX_L.gpkg with valid geometries from real Testdaten.""" + messages = [] + process_atkis( + str(ATKIS_DIR), str(HU_SHP), str(tmp_path), + feedback=messages.append) + + hu = QgsVectorLayer(str(tmp_path / "HU.gpkg"), "hu", "ogr") + rn = QgsVectorLayer(str(tmp_path / "RN.gpkg"), "rn", "ogr") + aux = QgsVectorLayer(str(tmp_path / "AUX_L.gpkg"), "aux", "ogr") + + for layer in (hu, rn, aux): + assert layer.isValid() + assert layer.featureCount() > 0 + _assert_valid_geometries(layer) + + assert any(messages), "feedback callback should have been invoked" + + @requires_atkis_testdaten + @pytest.mark.integration + def test_project_crs_taken_from_ver01_l(self, tmp_path): + """The output CRS matches ver01_l's CRS (EPSG:25833 in Testdaten/).""" + process_atkis(str(ATKIS_DIR), str(HU_SHP), str(tmp_path)) + rn = QgsVectorLayer(str(tmp_path / "RN.gpkg"), "rn", "ogr") + assert rn.crs() == CRS_25833 + + @requires_atkis_testdaten + @pytest.mark.integration + @pytest.mark.edge_case + def test_study_area_without_overlap_yields_empty_outputs_not_error(self, tmp_path): + """A study area outside the ATKIS extent produces empty (but valid) GPKGs, not an error.""" + far_away = make_polygon_layer(crs="EPSG:25833", name="study_area") + add_feature_to_layer(far_away, make_square_geom(-500_000, -500_000, 10)) + study_area_path = write_layer_as_shp(far_away, str(tmp_path / "study_area.shp")) + + process_atkis(str(ATKIS_DIR), str(HU_SHP), str(tmp_path), + study_area_path=study_area_path) + + for name in ("HU.gpkg", "RN.gpkg", "AUX_L.gpkg"): + out = QgsVectorLayer(str(tmp_path / name), "check", "ogr") + assert out.isValid() + assert out.featureCount() == 0 diff --git a/test/test_translations.py b/test/test_translations.py new file mode 100644 index 0000000..0aaf204 --- /dev/null +++ b/test/test_translations.py @@ -0,0 +1,39 @@ +# coding=utf-8 +# pylint: skip-file +"""Translation file test.""" + +__author__ = 'ottmar.hittzfeld@web.de' +__date__ = '2026-08-12' + +import os +import unittest +from pathlib import Path + + +class DataWizardTranslationsTest(unittest.TestCase): + """Test translations work.""" + + def setUp(self): + """Runs before each test.""" + if 'LANG' in os.environ: + del os.environ['LANG'] + + def tearDown(self): + """Runs after each test.""" + if 'LANG' in os.environ: + del os.environ['LANG'] + + def test_qgis_translations(self): + """German translation file (.qm) exists in the i18n directory.""" + plugin_dir = Path(__file__).parent.parent + qm_path = plugin_dir / 'i18n' / 'Data_Wizard_de.qm' + self.assertTrue( + qm_path.exists(), + f"Translation file not found: {qm_path}" + ) + + +if __name__ == "__main__": + suite = unittest.makeSuite(DataWizardTranslationsTest) + runner = unittest.TextTestRunner(verbosity=2) + runner.run(suite) diff --git a/test/utilities.py b/test/utilities.py index 395596a..b964e78 100644 --- a/test/utilities.py +++ b/test/utilities.py @@ -43,6 +43,23 @@ def get_qgis_app(): s = QGIS_APP.showSettings() LOGGER.debug(s) + # processor.py's integration-tier functions (_reproject_if_needed, + # _clip_if_needed, _process_aux, ...) call processing.run() with + # native:* algorithms. Unlike QgsApplication.initQgis(), the native + # provider is NOT auto-registered - it requires the Processing + # Python plugin's own initialization. Without this, every + # processing.run() call in an @pytest.mark.integration test fails + # with "Algorithm native:... not found", regardless of whether + # QGIS_APP was created successfully. + try: + from processing.core.Processing import Processing + Processing.initialize() + except ImportError: + LOGGER.warning( + "processing plugin not importable - integration tests " + "that call processing.run() will fail. Ensure " + "/python/plugins is on sys.path.") + global PARENT # pylint: disable=W0603 if PARENT is None: # noinspection PyPep8Naming