From 96073beba1416387440c628fe8407f7afe3b602d Mon Sep 17 00:00:00 2001 From: Andrii Liekariev Date: Fri, 5 Jun 2026 06:11:59 +0300 Subject: [PATCH] Add performance smoke test for 1024-polygon workload Catches order-of-magnitude regressions in the per-feature pipeline. Marked `perf` and skipped by default; run via `make test-perf`. --- CLAUDE.md | 2 + DEVELOPMENT.md | 2 + Makefile | 5 +- pyproject.toml | 4 ++ tests/test_performance.py | 99 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/test_performance.py diff --git a/CLAUDE.md b/CLAUDE.md index cb87b8c..ecb54bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ Invariants: `tests/test_main_functionality.py` is the integration suite — builds in-memory `QgsVectorLayer`s from WKT, runs the pipeline via `processing.run(algOrName=Algorithm(), ...)`, compares output WKT. Extend the existing `@pytest.mark.parametrize` table for new cases rather than adding files. Other `tests/test_*.py` files are module unit tests and don't need a full processing run. +`tests/test_performance.py` is a perf smoke test marked `perf` — skipped by default via `addopts = "-m 'not perf'"` in `pyproject.toml`, run via `make test-perf`. Catches order-of-magnitude regressions in the per-feature pipeline. + ## Packaging & Remote Debugging See `DEVELOPMENT.md` for the plugin-portal zip command (must zip *only* `PolygonsParallelToLine/`, not the repo root) and the PyCharm `pydevd_pycharm.settrace` setup (port `53100`, commented stubs in `src/pptl.py` and `tests/test_main_functionality.py`). diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9d4099b..6298c1e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,6 +11,8 @@ make test ``` +Performance smoke tests are skipped by default. Run them with `make test-perf` to catch order-of-magnitude regressions in the per-feature pipeline. + ## Remote Debugging These instructions are specific to PyCharm. diff --git a/Makefile b/Makefile index 68d8a57..b0b116c 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ QGIS_VERSION ?= 4.0.0 IMAGE := qgis-for-pptl:$(QGIS_VERSION) CONTAINER := qgis_pptl -.PHONY: build run install install-dev test test-coverage stop clean +.PHONY: build run install install-dev test test-coverage test-perf stop clean build: DOCKER_SCAN_SUGGEST=false docker build -t $(IMAGE) -f Dockerfile . @@ -34,6 +34,9 @@ test-coverage: --cov-report=xml:/pptl/coverage.xml \ --junitxml=/pptl/junit.xml -o junit_family=legacy" +test-perf: + docker exec $(CONTAINER) sh -c "cd /pptl && uv run --no-dev pytest /pptl/tests -m perf --qgis_disable_gui" + stop: -docker stop $(CONTAINER) diff --git a/pyproject.toml b/pyproject.toml index 9162750..ccb218a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,10 @@ ignore_missing_imports = false namespace_packages = true warn_return_any = true +[tool.pytest.ini_options] +addopts = "-m 'not perf'" +markers = ["perf: performance smoke tests (skipped by default; run with `-m perf`)"] + [tool.qgis-plugin-ci] github_organization_slug = "Elfpkck" plugin_path = "PolygonsParallelToLine" diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..c7fbeb5 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import math +import time +from typing import TYPE_CHECKING + +import pytest +from qgis import processing +from qgis.core import ( + QgsFeature, + QgsGeometry, + QgsProcessingContext, + QgsProcessingOutputLayerDefinition, + QgsVectorLayer, +) + +from PolygonsParallelToLine.src import const +from PolygonsParallelToLine.src.algorithm import Algorithm + +if TYPE_CHECKING: + from qgis.core import QgsVectorDataProvider + +GRID_SIZE = 32 +BUDGET_SECONDS = 0.25 +SQUARE_HALF = 0.4 +SPACING = 2.0 +ROTATION_OFFSET_DEG = 15.0 + + +def _rotated_square_wkt(cx: float, cy: float, half: float, angle_deg: float) -> str: + theta = math.radians(angle_deg) + cos_t, sin_t = math.cos(theta), math.sin(theta) + offsets = [(-half, -half), (half, -half), (half, half), (-half, half), (-half, -half)] + pts = [(cx + dx * cos_t - dy * sin_t, cy + dx * sin_t + dy * cos_t) for dx, dy in offsets] + coords = ", ".join(f"{x} {y}" for x, y in pts) + return f"Polygon (({coords}))" + + +def _grid_polygons() -> list[str]: + return [ + _rotated_square_wkt(i * SPACING, j * SPACING, SQUARE_HALF, ROTATION_OFFSET_DEG) + for i in range(GRID_SIZE) + for j in range(GRID_SIZE) + ] + + +def _diagonal_lines() -> list[str]: + extent = (GRID_SIZE - 1) * SPACING + return [ + f"LineString (0 0, {extent} {extent})", + f"LineString (0 {extent}, {extent} 0)", + f"LineString (0 {extent / 2}, {extent} {extent / 2})", + f"LineString ({extent / 2} 0, {extent / 2} {extent})", + f"LineString (0 {extent / 4}, {extent} {3 * extent / 4})", + ] + + +@pytest.mark.perf +def test_perf_smoke_grid_1024_polygons(qgis_processing, add_features): + line_layer = QgsVectorLayer("linestring", "temp_line", "memory") + add_features(vector_layer=line_layer, wkt_geometries=tuple(_diagonal_lines())) + + poly_layer = QgsVectorLayer("polygon", "temp_poly", "memory") + add_features(vector_layer=poly_layer, wkt_geometries=tuple(_grid_polygons())) + + params = { + "LINE_LAYER": line_layer, + "POLYGON_LAYER": poly_layer, + "LONGEST": False, + "NO_MULTI": False, + "DISTANCE": 0.0, + "ANGLE": 89.9, + "OUTPUT": QgsProcessingOutputLayerDefinition("TEMPORARY_OUTPUT"), + } + context = QgsProcessingContext() + + start = time.perf_counter() + result = processing.run(algOrName=Algorithm(), parameters=params, context=context) + elapsed = time.perf_counter() - start + + output_layer = context.getMapLayer(result[Algorithm.OUTPUT_LAYER]) + rotated_flags = [x[const.COLUMN_NAME] for x in output_layer.getFeatures()] + assert len(rotated_flags) == GRID_SIZE * GRID_SIZE + # Guard against a no-op fast path silently making the budget meaningless. + assert any(rotated_flags) + + assert elapsed < BUDGET_SECONDS, f"Rotating {GRID_SIZE**2} polygons took {elapsed:.3f}s (budget {BUDGET_SECONDS}s)" + + +@pytest.fixture(scope="module") +def add_features(): + def add_wkt_features_to_layer(vector_layer: QgsVectorLayer, wkt_geometries: tuple[str, ...]) -> None: + data_provider: QgsVectorDataProvider = vector_layer.dataProvider() + for wkt_geometry in wkt_geometries: + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromWkt(wkt_geometry)) + data_provider.addFeature(feature) + + return add_wkt_features_to_layer