diff --git a/.github/instructions/stabilization.instructions.md b/.github/instructions/stabilization.instructions.md index 288e6961..57218095 100644 --- a/.github/instructions/stabilization.instructions.md +++ b/.github/instructions/stabilization.instructions.md @@ -11,7 +11,7 @@ Drive failing work to implementation DoD through controlled iterative fix cycles ## Iteration Loop 1. Reproduce failures and capture the exact failing signature. 2. Apply the smallest targeted fix. -3. Re-run the narrowest relevant tests (`pytest tests/test_.py -k "failing_test"`), then broader tests (`pytest tests/`). +3. Re-run the narrowest relevant tests (`agent-make test-functional`, `agent-make test-qgis`, or `agent-make test-ui`), then broader tests (`agent-make test`). 4. Repeat until green or a stop condition is reached. ## Breakpoints and Stop Conditions diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9a70938f..4e78957b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,8 +2,7 @@ name: GitlabSync on: - - push - - delete + workflow_dispatch: jobs: sync: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..a5d2bd78 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: tests + +on: + push: + branches: + - dev + - master + pull_request: + +jobs: + functional: + name: Functional tier + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run functional tests + run: make test-functional + + qgis: + name: QGIS tier + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run qgis tests + run: make test-qgis + + ui: + name: UI tier + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ui tests + # No UI tests authored yet — `make test-ui` treats pytest's + # exit-5 (no tests collected) as a pass. Real xvfb / image + # failures still fail the job. + run: make test-ui diff --git a/AGENTS.md b/AGENTS.md index 243114d4..980a44f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ - Use `agent-git` whenever possible for git operations. `agent-git` is expected to be in the "always allowed" command pool, so it should be the default path for agent workflows. - Use raw `git` only when `agent-git` cannot express the required operation. Such usage is **not recommended** and must be explicitly justified in the command text. +# BUILD COMMAND POLICY FOR AGENTS +- Use `agent-make` instead of plain `make` everywhere. +- Plain `make` is not allowed when `agent-make` can express the same target. + # PROJECT STRUCTURE AND ADDRESSING - /mapflow: QGIS plugin source code (Python, Qt/PyQGIS) - /mapflow/dialogs: Qt dialog classes and `.ui` files @@ -48,7 +52,7 @@ Execute it every time a session is initiated. - tests; - code; 10. stabilization.instructions.md: - - run tests; + - run tests (for example: `agent-make test`); - if tests pass, continue; - if tests fail, use delivery.instruction.md to iterate on code changes; and test execution until tests pass or you are blocked (`.github/instructions/stabilization.instructions.md`). - write discoveries to `WAL_.md` @@ -75,8 +79,8 @@ Execute it every time a session is initiated. - If user merged without prior approval signal: `agent-git checkout dev && agent-git pull --ff-only`, mark WAL step `[v]`, commit and push directly to dev using `agent-git push`. # IMPLEMENTATION DEFINITION OF DONE (PRE-MERGE) -- tests added/updated according to the feature specification -- `pytest tests/` runs successfully +- tests are written/updated according to the feature specification +- tests are executed locally and pass before moving to review (for example: `agent-make test`) - branch pushed and `[Draft]` MR created - WAL step is updated to `[ready-for-review]` with concise motivation @@ -110,14 +114,15 @@ limited connections to avoid server DDoS protection, issues can start around 40 ``` # COMMANDS TO RUN -`pytest tests/` to run the full test suite -`pytest tests/test_.py` to run a specific test file -`pytest tests/ -k "test_name"` to run a specific test by name +`agent-make test` to run the full test workflow +`agent-make test-functional` to run functional tests +`agent-make test-qgis` to run QGIS-runtime tests +`agent-make test-ui` to run UI tests +`agent-make ` to run Makefile targets (instead of plain `make`) # TEST EXECUTION MODES -- All tests run locally with pytest (no containers). -- QGIS-dependent tests may require a QGIS environment or mocking of `qgis.*` / `PyQt5.*` imports. -- Pure logic tests (data transforms, URL building, schema validation) should not depend on QGIS runtime. +- All tests are run locally via `agent-make`, which executes them in the configured Docker test image. +- Use tiered targets when iterating: `agent-make test-functional`, `agent-make test-qgis`, `agent-make test-ui`. # TERMINAL COMMAND BATCHING - Combine commands that both require user approval into a single `&&`-chained invocation to minimize approval prompts. diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 00000000..0cb84d42 --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,31 @@ +# Test runtime for the mapflow-qgis plugin. +# +# Pinned to qgis/qgis:release-3_28 — the LTR release, matches the +# 3.20+ minimum in spec/004_stack.md. Bump only when the plugin's +# minimum target changes. +# +# The repo is bind-mounted at /app at run time (see Makefile); we do +# not COPY sources here so an iteration cycle is just `docker run`, +# not rebuild. +FROM qgis/qgis:release-3_28 + +# xvfb-run is needed by the UI tier; the base image does not ship it. +# Run apt without recommends to keep the image lean. +RUN apt-get update \ + && apt-get install -y --no-install-recommends xvfb \ + && rm -rf /var/lib/apt/lists/* + +# QGIS images ship pytest-qgis hooks already; we only need pytest + +# pytest-qt (the latter for the UI tier). The base image's pip is old +# enough that PEP 668's externally-managed marker is not enforced. +RUN python3 -m pip install --no-cache-dir \ + pytest \ + pytest-qt + +# Run Qt without an X display by default; the UI tier overrides this +# by wrapping pytest in `xvfb-run` (provides a real X server). +ENV QT_QPA_PLATFORM=offscreen \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3168a07b --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Test runner for mapflow-qgis. +# +# All targets run inside the qgis/qgis:release-3_28 Docker image so +# host setup is irrelevant. See spec/004_stack.md and tests/README.md +# for tier definitions. + +IMAGE ?= mapflow-qgis-tests +DOCKERFILE ?= Dockerfile.tests +DOCKER_RUN = docker run --rm -v "$(CURDIR)":/app -w /app $(IMAGE) + +.PHONY: help docker-build test test-functional test-qgis test-ui clean + +help: + @echo "Targets:" + @echo " docker-build Build the test image ($(IMAGE))" + @echo " test-functional Run pure-logic tests under tests/functional/" + @echo " test-qgis Run QGIS-runtime tests under tests/qgis/" + @echo " test-ui Run UI tests under tests/ui/ (xvfb-run)" + @echo " test Run all three tiers" + @echo " clean Remove pytest cache + bytecode" + +docker-build: + docker build -f $(DOCKERFILE) -t $(IMAGE) . + +test-functional: docker-build + $(DOCKER_RUN) pytest tests/functional + +test-qgis: docker-build + $(DOCKER_RUN) pytest tests/qgis + +test-ui: docker-build + # pytest exits 5 when no tests are collected; the UI tier is an + # empty harness today, so treat that as a pass. Remove this guard + # once the first UI test lands. + $(DOCKER_RUN) bash -c 'xvfb-run -a pytest tests/ui; rc=$$?; [ $$rc -eq 0 ] || [ $$rc -eq 5 ]' + +test: test-functional test-qgis test-ui + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete + rm -rf .pytest_cache diff --git a/README.md b/README.md index 1be71cc9..c724ca16 100644 --- a/README.md +++ b/README.md @@ -28,39 +28,23 @@ To learn how to use the plugin, please, follow our [guide](https://docs.mapflow. ### Running tests -Tests run inside the QGIS Python environment. Use the Python bundled with your QGIS installation. - -**macOS** (QGIS-LTR): +Automated tests run inside the official `qgis/qgis:release-3_28` Docker +image — no host QGIS install needed, only Docker. ```bash -# Install test dependencies (once) -/Applications/QGIS-LTR.app/Contents/MacOS/bin/python3 -m pip install pytest-qt - -# Run tests -/Applications/QGIS-LTR.app/Contents/MacOS/bin/python3 -m pytest tests/ +make test-functional # pure-logic tests +make test-qgis # tests that touch real QGIS objects +make test-ui # UI tests under xvfb (harness only — no tests yet) +make test # all three tiers ``` -**Linux** (system package, e.g. `apt install qgis`): - -```bash -# QGIS Python is typically the system Python with QGIS packages available -python3 -m pip install pytest-qt - -python3 -m pytest tests/ -``` - -If you installed QGIS from a non-standard location, use the Python binary bundled with it (e.g. `/usr/bin/qgis_python3` or similar). - -**Windows** (OSGeo4W): - -```cmd -:: Open the OSGeo4W Shell, then: -pip install pytest-qt - -python -m pytest tests/ -``` +Test layout, fixtures, and the policy for adding a test live in +[`tests/README.md`](tests/README.md). -If using the standalone QGIS installer, open the **OSGeo4W Shell** shortcut that comes with QGIS — it sets up the correct Python environment automatically. +> **Coverage scope.** CI is pinned to **Linux + QGIS 3.28 LTR**. The +> `qgis/qgis` Docker image is Linux-only and we do not run a CI matrix +> across operating systems or QGIS versions. Verify macOS, Windows, and +> non-LTR QGIS versions by manual smoke testing before release. ## License diff --git a/WAL.md b/WAL.md index f658a8c8..339b6d9d 100644 --- a/WAL.md +++ b/WAL.md @@ -1,485 +1,28 @@ -# Journal for logging and planning of features to implement +# Journal for active implementation planning -## 1. Add AI setup -[v] -- Modify agents.md to meet this repo's structure (no makefile, no docker, etc.) -- Modify planning, implementation and stabilization instructions to address stack and UI component of this app (QT, PyQGis) -Adapted from generic Docker/Makefile/alembic template to QGIS plugin structure; UI instructions derived from existing dialog patterns (uic.loadUiType, signal/slot, separation of concerns) - -## 2. Generate spec -[v] -- Research existing codebase -- Populate spec with necessary documents; ask for API documentation if needed -All 5 specs populated from codebase analysis: goal (plugin purpose/constraints), API (all Mapflow/Maxar/Sentinel endpoints consumed), persistence (full QgsSettings key inventory from code), stack (PyQt5/QGIS/pytest + strict no-external-deps policy), interactions (all external systems; Maxar/Sentinel marked legacy) - -## 3. Plan test -[v] -- Unit tests on functional -- Integration tests on API calls -- UI testing -All tests require QGIS runtime — no value in partial testing without it. Tools: pytest + pytest-qt + unittest.mock (stdlib). QgsApplication bootstrapped via qgis.testing.start_app() in pytest_configure hook (must run before collection because plugin modules create QIcon at import time). conftest.py provides iface mock and http_mock fixtures. pytest-qt chosen over raw PyQt5.QtTest for signal-heavy architecture (waitSignal, auto widget cleanup via qtbot). - -## 4. Feature: download image from data-catalog -[v] -- Refactored 002_api.md into index + 4 sub-files (A: project, B: processing, C: my imagery, D: search) for maintainability -- Added `GET /rest/rasters/image/{image_id}/download` spec with presigned URL response model and error codes (404/403/409) -- Added `available_for_download` boolean to ImageReturnSchema (defaults True for backward compat via SkipDataClass.from_dict) -- Download uses two-step flow: authenticated GET for presigned URL, then unauthenticated direct S3 download — avoids routing large files through the backend -- Download button placed first in image cell layout for discoverability; disabled with tooltip change when `available_for_download=False` -- 6 tests added (schema parsing, default values, API URL construction); all pass on QGIS 3.28 Python 3.9 - -## 5. Feature: processings pagination -[v] -- Add arrow buttons (like projectsPreviousPageButton and projectsNextPageButton) to be able to show in processings table only 30 processings per page; -- Add 'sort by' combo box, filtering line edit already exists; -- Change get_processings function, use the following description for new api: -#### `POST /projects/{projectId}/processings/v2/page` - Returns paginated processings of project with filtering and sorting. - - Parameters: - - 'terms' (string) - Search term to filter by name, project name, workflow name, or email; - - 'limit' (integer) - Maximum number of results to return - - 'offset' (integer) - Number of results to skip - - sortBy (string) - Field to sort by: [ scenario, name, project, email, created, status, progress, completed, cost, area, provider ] - - sortOrder (string) - Sort direction [ ASC, DESC ] - - Response shape: - ```json - { - "results": List[Processing], - "total": integer, - "count": integer - } - ```; -- Implement pagination, sorting and new filtering (troug the request on text change, not though the table filtering). - -## 6. Add support of planned processing feature (processing templates) +## 1. Add support of planned processing feature (processing templates) [ ] -- Implement planned processings, using the templates_service, templates_api, templwtes_view logic - +- Implement planned processings using templates_service, templates_api, templates_view logic. - User should be able to: - - see created templates (as "planned processings") - - see all AOIs and connected processings in one layer with different colors for unprocessed/in-progress/processed aois - - navigate from template to a processing launched from this template - - create a new template (as "planned processing") from search results - - launch a processing when something is found, using one or several AOIs from the template - - delete template - - update template parameters (search params, aoi, name, etc) - -- UI: - - Show all processings from template in the same layer as template geometries (When we open template, we should immediately call [API] Get all processing ran from the template and add their AOIs as a separate layer with different style) - - Mark image/all as seen (button or context menu "already seen" for every line that is marked as "new" and button "seen all" for the table) - - Display template current results in the search table (After the template is selected in "processings" table and retrieved (see [API] Get template) we need to display it in search result table. Also, display at the map as "Planned processing" layer. Add UI elements for template editing. Button or menu entry to upload edited layer as new geometry. Same button acts for search parameters, model etc. Also, template activation/deactivation button + label) - - When provider is "Imagery search" and the search result table does not have selection, rename button "Start processing" → "Plan processing". When template is loaded AND image(s) are selected, we need option "Start planned processing" in the same button - - Display templates along with processings table (To the same table add labels (color? Or additional column with labels?) or sorting/filtering "show planned/ hide planned/show planned only") - -- Create 002_F_plan_processing_api with the help of this: - - 1) POST /processings/template - Creates a new processing template - Request body example: - { - "name": "string", - "searchParams": { - "aoi": {} - }, - "processingParams": {}, - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "activeUntil": "2026-04-06T11:03:37.721Z" - } - Response example: - { - "template": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:03:37.743Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:03:37.743Z", - "activeUntil": "2026-04-06T11:03:37.743Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - }, - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ] - } - - 2) GET /processings/template - Retrieves all templates for the authenticated user - Response example: - [ - { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:05:13.838Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:05:13.838Z", - "activeUntil": "2026-04-06T11:05:13.838Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - } - ] - - 3) GET /processings/template/{templateId} - Retrieves a specific template by ID - Parameter templateId (string(uuid)) - The id of the template - Response example: - { - "template": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:06:19.534Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:06:19.534Z", - "activeUntil": "2026-04-06T11:06:19.534Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - }, - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ] - } - - 4) POST /processings/template/{templateId} - Runs processing using a template - Request body example: - { - "name": "string", - "description": "string", - "wdName": "string", - "wdId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "geometry": {}, - "params": {}, - "meta": {}, - "blocks": [ - { - "name": "string", - "enabled": true, - "displayName": "string" - } - ], - "updateTemplateGeometry": true - } - - 5) PUT /processings/template/{templateId} - Updates an existing template - Request body example: - { - "name": "string", - "searchParams": { - "aoi": {} - }, - "processingParams": {}, - "activeUntil": "2026-04-06T11:44:33.912Z" - } - Response example: - { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:44:33.916Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:44:33.916Z", - "activeUntil": "2026-04-06T11:44:33.916Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - } - - 6) DELETE /processings/template/{templateId} - Marks template as deleted - Parameter templateId (string(uuid)) - The id of the template - - 7) POST /processings/template/{templateId}/v2 - Runs processing using a template with V2 params format - Request body example: - { - "name": "string", - "description": "string", - "wdName": "string", - "wdId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "geometry": {}, - "params": { - "sourceParams": { - "myImagery": { - "imageIds": [ - "string" - ], - "mosaicId": "string" - }, - "imagerySearch": { - "dataProvider": "orbview", - "imageIds": [ - "string" - ], - "zoom": 0 - }, - "dataProvider": { - "providerName": "string", - "zoom": 0 - }, - "userDefined": { - "sourceType": "XYZ", - "url": "string", - "zoom": 0, - "crs": "string", - "rasterLogin": "string", - "rasterPassword": "string" - } - }, - "inferenceParams": { - "key1": "value1", - "key2": "value2", - "keyN": "valueN" - } - }, - "meta": {}, - "blocks": [ - { - "name": "string", - "enabled": true, - "displayName": "string" - } - ], - "updateTemplateGeometry": true - } - - 8) POST /processings/template/{templateId}/pause - Changes template status to Inactive - Response example: - { - "template": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:52:29.861Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:52:29.861Z", - "activeUntil": "2026-04-06T11:52:29.861Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - }, - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ] - } - - 9) POST /processings/template/{templateId}/resume - Changes template status to Active. Note: Expired templates cannot be reactivated without first updating the activeUntil date. - Response example: - { - "template": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:54:46.588Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:54:46.588Z", - "activeUntil": "2026-04-06T11:54:46.588Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - }, - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ] - } - - 10) GET /processings/template/{templateId}/processings - Retrieves all processings associated with a template - Response example: - [ - { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "description": "string", - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "vectorLayer": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "tileJsonUrl": "string", - "tileUrl": "string" - }, - "rasterLayer": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "tileJsonUrl": "string", - "tileUrl": "string" - }, - "workflowDef": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "description": "string", - "created": "2026-04-06T11:55:47.335Z", - "updated": "2026-04-06T11:55:47.335Z", - "pricePerSqKm": 0, - "blocks": [ - { - "name": "string", - "description": "string", - "optional": 0, - "price": 0 - } - ] - }, - "aoiCount": 0, - "aoiArea": 0, - "area": 0, - "cost": 0, - "status": "UNPROCESSED", - "reviewStatus": { - "reviewStatus": "ACCEPTED", - "feedback": "2026-04-06T11:55:47.335Z" - }, - "rating": { - "rating": "string", - "feedback": "string" - }, - "percentCompleted": 0, - "params": { - "key": "string", - "value": "string" - }, - "blocks": [ - { - "name": "string", - "enabled": true, - "displayName": "string" - } - ], - "meta": { - "key": "string", - "value": "string" - }, - "messages": [ - { - "code": "string", - "parameters": { - "key": "string", - "value": "string" - } - } - ], - "created": "2026-04-06T11:55:47.335Z", - "updated": "2026-04-06T11:55:47.335Z" - } - ] - - 11) POST /processings/template/{templateId}/image/{imageId}/seen - Marks an image as seen in a template - Parameters: - templateId (string($uuid)) - The id of the template - imageId (string) - The id of the image - - 12) GET /processings/template/user/{userId} - Retrieves all templates for a specific user ID - Response example: - [ - { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:58:06.918Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:58:06.918Z", - "activeUntil": "2026-04-06T11:58:06.918Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - } - ] - - 13) GET /processings/template/project/{projectId} - Retrieves all templates for a specific project ID - Response example: - [ - { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "name": "string", - "status": "ACTIVE", - "createdAt": "2026-04-06T11:59:25.085Z", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "searchParams": {}, - "processingParams": {}, - "lastCheckedAt": "2026-04-06T11:59:25.085Z", - "activeUntil": "2026-04-06T11:59:25.085Z", - "searchResults": [ - { - "id": "string", - "metadata": {} - } - ], - "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "area": 0, - "newImagesCount": 0 - } - ] - - -## 7. Add new zoom-selector feature + - see created templates as planned processings + - see AOIs and connected processings in one layer with clear statuses + - navigate from template to processings launched from it + - create, launch, delete, and update template parameters + +- UI requirements: + - show processings from template in related map layers + - support mark image/all as seen flows + - display template current results in search table and on map + - expose template edit/activate/deactivate controls + - adapt Start processing button behavior for planned processing flow + - display templates together with processings table entries + +- API spec work: + - maintain and implement support for 002_F_plan_processing_api.md endpoints + +## 2. Add new zoom-selector feature [ ] -Use 002_E_zoom_selector_api.md -- Add small button near zoom selector comboBox to call for zoom selector api, active if selected source is Mapflow data provider -- On press of the button, call api and select zoom automatically depending on response -- on error, report to user with reasonable message \ No newline at end of file +- Use 002_E_zoom_selector_api.md +- Add a small button near zoom selector comboBox to call zoom-selector API, active when selected source is a Mapflow data provider. +- On button press, call API and select zoom automatically depending on response. +- On error, show a reasonable user-facing message. diff --git a/WAL_6.md b/WAL_6.md deleted file mode 100644 index 31fc8ed2..00000000 --- a/WAL_6.md +++ /dev/null @@ -1,18 +0,0 @@ -# WAL_6 implementation plan - -## Scope -- Add spec for planned processing API. -- Add template schema models and API methods. -- Add focused tests for new schema and API path/body behavior. - -## Steps -1. Add `spec/002_F_plan_processing_api.md`. -2. Update `spec/index.md` and `spec/002_api.md` to reference 002_F. -3. Extend `mapflow/schema/processing.py` with template dataclasses and request schemas. -4. Extend `mapflow/functional/api/processing_api.py` with template endpoint methods. -5. Add/update tests under `tests/`. -6. Run focused pytest on updated tests; then run full suite if feasible. - -## Assumptions -- Remote `origin/dev` fetch issue remains unresolved; work proceeds from local branch state. -- Existing local modifications in `WAL.md` and `mapflow/schema/processing.py` are user-owned and preserved. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..852b4cbc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +# Repo root must be on sys.path so `import mapflow` resolves. +# Per-tier conftests live in tests/{functional,qgis,ui}/. +pythonpath = . +testpaths = tests diff --git a/spec/004_stack.md b/spec/004_stack.md index 9ea42691..59b82813 100644 --- a/spec/004_stack.md +++ b/spec/004_stack.md @@ -20,12 +20,26 @@ GIS libraries: Build/deploy: `qgis-plugin-ci` for packaging and release. Plugin published to QGIS Plugin Repository. -Test tools: pytest (local execution, no containers). +Test tools: pytest, pytest-qt. Automated tests run in three tiers (functional / qgis / ui) — see **Test runtime** below. CI/CD: GitHub Actions (implied by `.github/` structure). External dependencies: Mapflow REST API backend (required for all functionality beyond UI). +## Test runtime + +Tests are organized by intent, one directory per tier. **All three tiers run inside the official `qgis/qgis:release-3_28` Docker container** with real PyQGIS — the directory split is taxonomy, not runtime separation. Mocking the QGIS / PyQt5 / GDAL surface portably is more brittle than running the real runtime in a container. + +- `tests/functional/` — pure-logic tests: schema parsing, string ops, dataclass behavior, anything that does not exercise real QGIS state. Bootstrap via `qgis.testing.start_app()`. +- `tests/qgis/` — tests that touch real QGIS objects (layers, projects, settings, network). Same bootstrap. +- `tests/ui/` — tests that open widgets / drive a Qt event loop. Same bootstrap, run under `xvfb-run`. Currently a harness only. + +Pinned image: **`qgis/qgis:release-3_28`** (LTR through 2026, satisfies the QGIS 3.20+ minimum). + +Coverage scope: automated tests cover **Linux + QGIS 3.28 LTR only**. macOS, Windows, and other QGIS versions are exercised by manual smoke testing — there is no CI matrix for them. This is a deliberate trade-off: the official QGIS Docker image is Linux-only, and a cross-OS conda-forge matrix is deferred until the Linux pipeline is stable. + +Entry points: `make test-functional` / `make test-qgis` / `make test-ui` / `make test`. CI runs the same targets. + ## Dependency policy **Only libraries bundled with the QGIS Python environment are allowed.** Adding third-party packages that are not part of the standard QGIS/PyQt5/GDAL distribution is strictly forbidden — the plugin must install and run without `pip install` on any QGIS 3.20+ installation. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..cbcaf453 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,48 @@ +# Tests + +The plugin's test suite runs inside the official `qgis/qgis:release-3_28` +Docker image so that no host setup beyond Docker is required. Driver: +the top-level `Makefile`. CI runs the same targets — see +`.github/workflows/tests.yml`. + +## Coverage scope + +Automated tests cover **Linux + QGIS 3.28 LTR only**. macOS, Windows, +and other QGIS versions are not exercised in CI — verify them by manual +smoke testing. The pin lives in the `FROM` line of `Dockerfile.tests` +and the spec in `spec/004_stack.md`. + +## Tiers + +Three directories, three intents. **All three run on real PyQGIS in the +container** — the split is taxonomy, not runtime separation. + +| Tier | Directory | Intent | Runner | +|------|-----------|--------|--------| +| Functional | `tests/functional/` | Pure logic: schema parsing, string ops, dataclass behavior. Should not exercise real QGIS state. | `make test-functional` | +| QGIS | `tests/qgis/` | Tests that exercise real QGIS objects (layers, projects, settings, network). | `make test-qgis` | +| UI | `tests/ui/` | Tests that open widgets / drive a Qt event loop. Run under `xvfb-run`. Currently a harness only — no tests yet. | `make test-ui` | + +Run everything with `make test`. + +## Adding a test + +1. Pick a tier by what the test *does*, not by what it *can* do: + * does it just call a pure function and assert the result? → functional + * does it construct a `QgsVectorLayer` or read `QgsSettings`? → qgis + * does it `dlg.show()` or rely on an event loop? → ui +2. Drop the file in the matching directory. Pytest collection picks it + up automatically. +3. Use the shared fixtures from the tier's `conftest.py` + (`iface`, `http_mock` for the qgis tier). + +## Local quickstart + +```bash +make docker-build # one-time, ~3 min on cold cache +make test-functional # fast pure-logic tier +make test-qgis # full QGIS-runtime tier +make test # all three +``` + +The image is cached — subsequent test runs are just `docker run`. diff --git a/tests/conftest.py b/tests/conftest.py index 93a9f6cc..6883a089 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,58 +1,7 @@ -"""Shared fixtures for mapflow plugin tests. +"""Shared fixtures for the mapflow test suite. -All tests run inside the QGIS Python environment. -QgsApplication is bootstrapped before test collection via pytest_configure -so that module-level Qt objects (QIcon etc.) can be created during import. +Tier-specific bootstrap (PyQGIS startup, sys.modules stubs, Xvfb wiring) +lives in tests/functional/conftest.py, tests/qgis/conftest.py, and +tests/ui/conftest.py. Keep this file empty unless a fixture is genuinely +needed by every tier. """ -import importlib -import pytest -from unittest.mock import MagicMock - - -def pytest_configure(config): - """Bootstrap QgsApplication before test collection. - - Must happen here (not in a fixture) because mapflow modules create - Qt objects (QIcon, etc.) at import time, which requires a living - QApplication before any test file is imported. - """ - from qgis.testing import start_app - start_app() - - # Pre-warm the mapflow module tree to survive the circular import on first load. - # The chain mapflow.schema.processing -> entity.provider -> functional.layer_utils - # -> dialogs -> mapflow.schema creates a circular dependency that fails on the - # first attempt but succeeds on retry because partial modules are cached. - for _ in range(2): - try: - importlib.import_module("mapflow.schema.processing") - break - except ImportError: - pass - - -@pytest.fixture() -def iface(): - """Mock QgisInterface for tests that need a plugin iface reference.""" - mock_iface = MagicMock() - mock_iface.mapCanvas.return_value = MagicMock() - mock_iface.mainWindow.return_value = MagicMock() - return mock_iface - - -@pytest.fixture() -def http_mock(): - """Mock Http client with pre-wired methods. - - Usage: - def test_something(http_mock): - api = ProjectApi(http=http_mock, server="https://example.com") - api.get_projects(callback=my_callback) - http_mock.get.assert_called_once() - """ - mock = MagicMock() - mock.get.return_value = MagicMock() - mock.post.return_value = MagicMock() - mock.put.return_value = MagicMock() - mock.delete.return_value = MagicMock() - return mock diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py new file mode 100644 index 00000000..b52f03cd --- /dev/null +++ b/tests/functional/conftest.py @@ -0,0 +1,32 @@ +"""Bootstrap for the functional-tier tests. + +Functional tests are pure-logic tests that don't exercise real QGIS +state or any UI surface. They still run inside the qgis/qgis Docker +image, because the plugin's leaf modules import qgis.* / PyQt5.* at +module load time and stubbing that surface portably is more brittle +than just running with the real runtime. + +Convention for adding tests here: +* test only pure-Python helpers (string ops, schema parsing, dataclass + validation) — anything that needs `QgsProject`, real layers, real + network access, or live signals belongs in `tests/qgis/`, +* anything that opens a widget / starts an event loop belongs in + `tests/ui/`. +""" +import importlib + +from qgis.testing import start_app + + +def pytest_configure(config): + start_app() + # Pre-warm the mapflow module tree to survive the circular import on first + # load. The chain mapflow.schema.processing -> entity.provider -> + # functional.layer_utils -> dialogs -> mapflow.schema fails on the first + # attempt but succeeds on retry because partial modules are cached. + for _ in range(2): + try: + importlib.import_module("mapflow.schema.processing") + break + except ImportError: + pass diff --git a/tests/test_layer_utils.py b/tests/functional/test_layer_utils.py similarity index 62% rename from tests/test_layer_utils.py rename to tests/functional/test_layer_utils.py index 6156928a..142e0eaa 100644 --- a/tests/test_layer_utils.py +++ b/tests/functional/test_layer_utils.py @@ -2,8 +2,8 @@ def test_xyz_no_creds(): - result = generate_xyz_layer_definition('https://xyz.tile.server/{z}/{x}/{y}.png', - "", "", 18, "xyz") + result = generate_xyz_layer_definition(url='https://xyz.tile.server/{z}/{x}/{y}.png', + username="", password="", max_zoom=18, source_type="xyz") assert result == 'type=xyz' \ '&url=https://xyz.tile.server/{z}/{x}/{y}.png' \ '&zmin=0' \ diff --git a/tests/qgis/conftest.py b/tests/qgis/conftest.py new file mode 100644 index 00000000..a9562169 --- /dev/null +++ b/tests/qgis/conftest.py @@ -0,0 +1,59 @@ +"""Bootstrap for the qgis-tier tests. + +Tests in this directory need a real PyQGIS runtime — they import plugin +modules that touch qgis.core / qgis.gui at module load time. Run inside +the qgis/qgis:release-3_28 Docker image (see Dockerfile.tests + Makefile). +""" +import importlib + +import pytest +from unittest.mock import MagicMock + + +def pytest_configure(config): + """Bootstrap QgsApplication before test collection. + + Must happen here (not in a fixture) because mapflow modules create + Qt objects (QIcon, etc.) at import time, which requires a living + QApplication before any test file is imported. + """ + from qgis.testing import start_app + start_app() + + # Pre-warm the mapflow module tree to survive the circular import on first load. + # The chain mapflow.schema.processing -> entity.provider -> functional.layer_utils + # -> dialogs -> mapflow.schema creates a circular dependency that fails on the + # first attempt but succeeds on retry because partial modules are cached. + for _ in range(2): + try: + importlib.import_module("mapflow.schema.processing") + break + except ImportError: + pass + + +@pytest.fixture() +def iface(): + """Mock QgisInterface for tests that need a plugin iface reference.""" + mock_iface = MagicMock() + mock_iface.mapCanvas.return_value = MagicMock() + mock_iface.mainWindow.return_value = MagicMock() + return mock_iface + + +@pytest.fixture() +def http_mock(): + """Mock Http client with pre-wired methods. + + Usage: + def test_something(http_mock): + api = ProjectApi(http=http_mock, server="https://example.com") + api.get_projects(callback=my_callback) + http_mock.get.assert_called_once() + """ + mock = MagicMock() + mock.get.return_value = MagicMock() + mock.post.return_value = MagicMock() + mock.put.return_value = MagicMock() + mock.delete.return_value = MagicMock() + return mock diff --git a/tests/test_data_catalog.py b/tests/qgis/test_data_catalog.py similarity index 100% rename from tests/test_data_catalog.py rename to tests/qgis/test_data_catalog.py diff --git a/tests/test_mapflow_user_role_guard.py b/tests/qgis/test_mapflow_user_role_guard.py similarity index 100% rename from tests/test_mapflow_user_role_guard.py rename to tests/qgis/test_mapflow_user_role_guard.py diff --git a/tests/test_processing_templates.py b/tests/qgis/test_processing_templates.py similarity index 100% rename from tests/test_processing_templates.py rename to tests/qgis/test_processing_templates.py diff --git a/tests/test_processings_pagination.py b/tests/qgis/test_processings_pagination.py similarity index 86% rename from tests/test_processings_pagination.py rename to tests/qgis/test_processings_pagination.py index f425613d..941b443e 100644 --- a/tests/test_processings_pagination.py +++ b/tests/qgis/test_processings_pagination.py @@ -12,16 +12,16 @@ class TestProcessingSortBy: def test_enum_values(self): - assert ProcessingSortBy.created.value == "created" - assert ProcessingSortBy.name.value == "name" - assert ProcessingSortBy.status.value == "status" - assert ProcessingSortBy.cost.value == "cost" - assert ProcessingSortBy.area.value == "area" - assert ProcessingSortBy.progress.value == "progress" + assert ProcessingSortBy.created.value == "CREATED" + assert ProcessingSortBy.name.value == "NAME" + assert ProcessingSortBy.status.value == "STATUS" + assert ProcessingSortBy.cost.value == "COST" + assert ProcessingSortBy.area.value == "AREA" + assert ProcessingSortBy.progress.value == "PROGRESS" def test_all_expected_members(self): - expected = {"scenario", "name", "project", "email", "created", - "status", "progress", "completed", "cost", "area", "provider"} + expected = {"SCENARIO", "NAME", "PROJECT", "EMAIL", "CREATED", + "STATUS", "PROGRESS", "COMPLETED", "COST", "AREA", "PROVIDER"} assert set(m.value for m in ProcessingSortBy) == expected @@ -61,7 +61,7 @@ def test_serialization_with_all_params(self): "limit": 30, "offset": 60, "terms": "my search", - "sortBy": "created", + "sortBy": "CREATED", "sortOrder": "DESC", } diff --git a/tests/test_project_role_resolution.py b/tests/qgis/test_project_role_resolution.py similarity index 100% rename from tests/test_project_role_resolution.py rename to tests/qgis/test_project_role_resolution.py diff --git a/tests/test_template_start_processing.py b/tests/qgis/test_template_start_processing.py similarity index 100% rename from tests/test_template_start_processing.py rename to tests/qgis/test_template_start_processing.py diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 00000000..6eb846c7 --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,31 @@ +"""Bootstrap for the ui-tier tests. + +UI tests need a real Qt event loop and pytest-qt's `qtbot` fixture. They +run inside the qgis/qgis:release-3_28 Docker image under `xvfb-run` +(which provides the headless X display Qt requires). + +This tier currently has no tests — only the harness. When adding the +first UI test: + +* mark each test with `@pytest.mark.ui` for grep-ability (not strictly + required since we select tests by directory), +* request the `qtbot` fixture from pytest-qt for interaction, +* keep dialog modal shows minimal — `qtbot.addWidget(dlg); dlg.show()`, + no `dlg.exec_()`, +* run via `make test-ui` (Makefile wires `xvfb-run -a pytest tests/ui`). +""" +import importlib + +from qgis.testing import start_app + + +def pytest_configure(config): + start_app() + # Same circular-import warmup as the qgis tier — UI tests touch the + # full plugin tree and hit the same issue. + for _ in range(2): + try: + importlib.import_module("mapflow.schema.processing") + break + except ImportError: + pass