From f4b1c39e6ca864c83b5d7f4c281249f38c32f9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Sp=C3=A1=C4=8Dil?= Date: Tue, 1 Sep 2026 23:43:33 +0200 Subject: [PATCH] =?UTF-8?q?test:=20kontrola=20zdroj=C3=A1k=C5=AF=20a=20smo?= =?UTF-8?q?ke=20test=20v=20QGIS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WhQyc1uQFcpkezLwM8if6T --- tests/check_sources.py | 85 +++++++++++++++++++++++++++ tests/smoke_test.py | 130 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/check_sources.py create mode 100644 tests/smoke_test.py diff --git a/tests/check_sources.py b/tests/check_sources.py new file mode 100644 index 0000000..fb960a2 --- /dev/null +++ b/tests/check_sources.py @@ -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ů") diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..8327eaf --- /dev/null +++ b/tests/smoke_test.py @@ -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")