Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
99 changes: 99 additions & 0 deletions tests/test_performance.py
Original file line number Diff line number Diff line change
@@ -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
Loading