test: kontrola zdrojáků a smoke test v QGIS

tests/check_sources.py hlídá pravidla, která nepotřebují QGIS a jejichž
porušení je tiché:

- UTF-8 BOM na začátku .py souboru. Pythonu ani QGISu nevadí, ale
  oficiální pyqgis4-checker na něm spadne a soubor vůbec nezkontroluje,
  takže rozbitý soubor vypadá čistě. Stalo se tady už dvakrát.
- přímý import z PyQt5/PyQt6 mimo shim qgis.PyQt
- spustitelná práva, skryté soubory a podezřelé typy souborů, které
  hlásí analýza souborů na plugins.qgis.org

tests/smoke_test.py načte plugin ve skutečném QGIS, přečte scoped
enumy, vytvoří UpdateCodelistsTask a všechny tři filtrační dialogy
a ověří doplňování hraničního data. Běží offline a bez X serveru
(QT_QPA_PLATFORM=offscreen), takže nezávisí na dostupnosti API.

Očekávaná hodnota filtru je v testu napsaná natvrdo, ne přes
DATE_OPEN_TO. Porovnání proti konstantě z modulu dokazuje jen to, že se
modul shodne sám se sebou, a prošlo i s rozbitým sentinelem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhQyc1uQFcpkezLwM8if6T
This commit is contained in:
david-spacilandClaude Opus 5 committed 2026-09-01 23:43:33 +02:00
1 parent bcb0bc2c86
commit f4b1c39e6c
2 files changed
+215

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""
Repository hygiene rules that need no QGIS and therefore run first.
Each rule guards a mistake that has already happened here at least once,
or one the plugins.qgis.org file analysis reports:
* a UTF-8 BOM makes the official pyqgis4-checker skip the file entirely,
so a broken file looks clean – it is silent, which is what makes it bad
* a direct PyQt5/PyQt6 import breaks the other Qt version
* an executable or hidden file in the package is reported on upload
Run it from the repository root:
python3 tests/check_sources.py
"""
import os
import re
import stat
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BALICEK = os.path.join(ROOT, "amcr_viewer")
# Files that belong in the plugin package even though the upload scanner
# would otherwise call them hidden
POVOLENE_SKRYTE = {".flake8", ".bandit", ".secrets.baseline"}
# Extensions that have no business inside a plugin package
PODEZRELE = {".exe", ".dll", ".so", ".dylib", ".sh", ".bat", ".cmd",
".pyc", ".pyd", ".jar", ".bin"}
PRIMY_IMPORT = re.compile(r"^\s*(?:from|import)\s+PyQt[56]\b", re.MULTILINE)
nalezy = []
def zdrojaky():
for adresar, _, soubory in os.walk(BALICEK):
for soubor in sorted(soubory):
if soubor.endswith(".py"):
yield os.path.join(adresar, soubor)
def vsechny_soubory():
for adresar, _, soubory in os.walk(BALICEK):
for soubor in sorted(soubory):
yield os.path.join(adresar, soubor)
def zkratka(cesta):
return os.path.relpath(cesta, ROOT)
for cesta in zdrojaky():
with open(cesta, "rb") as f:
zacatek = f.read(3)
if zacatek == b"\xef\xbb\xbf":
nalezy.append(f"{zkratka(cesta)}: UTF-8 BOM na začátku souboru")
with open(cesta, encoding="utf-8-sig") as f:
text = f.read()
for shoda in PRIMY_IMPORT.finditer(text):
radek = text[:shoda.start()].count("\n") + 1
nalezy.append(f"{zkratka(cesta)}:{radek}: přímý import z PyQt5/PyQt6, "
f"použij shim qgis.PyQt")
for cesta in vsechny_soubory():
jmeno = os.path.basename(cesta)
rezim = os.stat(cesta).st_mode
if rezim & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
nalezy.append(f"{zkratka(cesta)}: spustitelná práva "
f"({stat.filemode(rezim)})")
if jmeno.startswith(".") and jmeno not in POVOLENE_SKRYTE:
nalezy.append(f"{zkratka(cesta)}: skrytý soubor v balíčku pluginu")
if os.path.splitext(jmeno)[1].lower() in PODEZRELE:
nalezy.append(f"{zkratka(cesta)}: podezřelý typ souboru")
if nalezy:
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
sys.exit(1)
print("Kontrola zdrojáků: bez nálezů")
+130
View File
@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
"""
Smoke test: loads the plugin inside a real QGIS and exercises the parts
that differ between Qt5 and Qt6.
It is deliberately offline – no request ever leaves the machine, so the
test says nothing about the AMCR API, only about the plugin loading and
its widgets being constructible.
Run it from the repository root:
python3 tests/smoke_test.py
QGIS must be importable (inside the qgis/qgis Docker image it already is).
The exit code is 0 when everything passed, 1 otherwise.
"""
import os
import sys
import traceback
# Offscreen, otherwise the dialogs need an X server
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
selhani = []
def zkouska(nazev, funkce):
"""Runs one check and keeps going even when it raises."""
try:
detail = funkce()
except Exception:
selhani.append(nazev)
print(f" FAIL {nazev}")
print(traceback.format_exc().rstrip())
else:
print(f" OK {nazev}" + (f" – {detail}" if detail else ""))
from qgis.core import ( # noqa: E402
Qgis,
QgsApplication,
QgsTask,
QgsWkbTypes,
)
from qgis.PyQt import QtCore # noqa: E402
from qgis.PyQt.QtCore import QDate # noqa: E402
print(f"QGIS {Qgis.QGIS_VERSION.split('-')[0]} | Qt {QtCore.QT_VERSION_STR} "
f"| PyQt {QtCore.PYQT_VERSION_STR}")
QgsApplication.setPrefixPath(os.environ.get("QGIS_PREFIX_PATH", "/usr"), True)
qgs = QgsApplication([], True)
qgs.initQgis()
import amcr_viewer.amcr_codelists # noqa: E402,F401
import amcr_viewer.amcr_dialog as dialog # noqa: E402
import amcr_viewer.amcr_tools # noqa: E402,F401
import amcr_viewer.amcr_viewer # noqa: E402,F401
print(" OK import všech modulů pluginu")
def enumy():
"""
The scoped enum forms must exist. Unscoped aliases still resolve in
QGIS 4.2, so a plain import proves nothing – these are read explicitly.
"""
return (f"QgsTask.Flag.CanCancel={int(QgsTask.Flag.CanCancel)}, "
f"PointGeometry={int(QgsWkbTypes.GeometryType.PointGeometry)}, "
f"MessageLevel.Info={int(Qgis.MessageLevel.Info)}")
def uloha():
ukol = dialog.UpdateCodelistsTask("smoke")
assert ukol.canCancel() is True
return "canCancel=True"
def dialogy():
# A modal warning would block the offscreen run forever
dialog.QMessageBox.warning = staticmethod(lambda *a, **k: None)
popis = []
for typ in ("akce", "lokalita", "samostatny_nalez"):
okno = dialog.AmcrFilterDialog(typ)
okno.show()
QgsApplication.processEvents()
popis.append(f"{typ}: {len(okno.date_ranges)} rozmezí")
okno.close()
return ", ".join(popis)
def filtr_datumu():
"""
A half-filled range must be completed with the sentinel. The API
rejects a one-sided range, so this is the part worth guarding.
The expected value is written out on purpose – comparing against
dialog.DATE_OPEN_TO would only prove the module agrees with itself.
"""
okno = dialog.AmcrFilterDialog("samostatny_nalez")
pole, _, od, _do = okno.date_ranges[0]
od.setDate(QDate(2016, 1, 1))
hodnota = okno.get_filters()[pole]
assert hodnota == "2016-01-01,9999-12-31", hodnota
# A range left completely empty must add no filter at all
prazdne = dialog.AmcrFilterDialog("samostatny_nalez")
pole_prazdne = prazdne.date_ranges[0][0]
assert pole_prazdne not in prazdne.get_filters()
prazdne.close()
okno.close()
return hodnota
zkouska("scoped enumy", enumy)
zkouska("UpdateCodelistsTask", uloha)
zkouska("filtrační dialogy", dialogy)
zkouska("filtr podle data", filtr_datumu)
qgs.exitQgis()
if selhani:
print(f"\nNEPROŠLO: {', '.join(selhani)}")
sys.exit(1)
print("\nVše prošlo")