Files
aiscr-qgis-amcr-viewer/tests/smoke_test.py
T
david-spacil 6e95973296 fix: váha prvku komponent počítaná z komponent prošlých filtrem (#55) (#83)
* fix: váha prvku komponent počítaná z komponent prošlých filtrem (#55)

Váha prvku_vaha = 1/n se dosud počítala ze všech komponent dokumentační
jednotky ještě před filtrem na období a areál, takže při aktivním filtru
váhy prvků jedné DJ nedávaly v součtu 1 (DJ se 4 komponentami, filtru
vyhoví 1 → váha 0,25 místo 1).

- nová funkce _component_entries: nejdřív vyfiltruje komponenty, pak
  přidělí váhu 1/n z těch, které prošly; DJ bez komponent má váhu 1
- smoke test vaha_komponent (4×0,25; 1 ze 4 → 1; 2 ze 3 → 2×0,5; bez
  komponent → 1)
- README: pole prvek_vaha v tabulce atributů komponent
- changelog v2.2.0 doplněn (bez povýšení verze)
- OpenSpec změna openspec/changes/fix-component-feature-weight

Ověřeno: živá data (Praha, novověk) – 0 z 1204 DJ se součtem vah ≠ 1,
původní kód 921; check_sources, bandit, detect-secrets, flake8, ruff,
pyqgis4-checker, smoke test v qgis/qgis:ltr i :stable.

Implementace připravena AI (Claude, subagent), ověřena a zkontrolována.

* openspec: archivovat fix-component-feature-weight (#55)

Ruční test v QGIS ověřen správcem: akce i lokality s Načíst komponenty,
bez filtru i s filtrem období – váhy prvků jedné DJ dávají součet 1 a
počítají se jen z vyfiltrovaných komponent. Úkol 3.2 odškrtnut, změna
archivována přes openspec archive --skip-specs.

Připraveno s pomocí AI (Claude).
2026-10-02 17:17:28 +02:00

377 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
import requests
# 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
class FalesnaSession:
"""Offline stand-in for requests.Session: returns canned JSON
bodies for GET /api/user/islogged and counts the requests."""
def __init__(self, tela):
# tela: a list of (body, exception) pairs – one per GET call,
# consumed in order; None body means raise the exception
self.tela = list(tela)
self.get_volani = 0
def get(self, url, timeout=0):
self.get_volani += 1
polozka = self.tela.pop(0)
# A bare dict is a plain body; (None, exception) means raise
if isinstance(polozka, tuple):
tela, vyjimka = polozka
else:
tela, vyjimka = polozka, None
if tela is None and vyjimka is not None:
raise vyjimka
class Odpoved:
def __init__(self, tela):
self.telo = tela
self.text = str(tela)
def json(self):
if isinstance(self.telo, Exception):
raise self.telo
return self.telo
return Odpoved(tela)
def prihlasovaci_stav():
"""
_ensure_logged_in with a fake session and monkeypatched login /
credentials / _get_session – everything stays offline.
Each case: (name, expected status, islogged bodies of the current
session, islogged bodies after re-login, fake login result,
stored credentials, expected number of islogged GETs).
"""
pripady = [
# Valid session – no re-login, no extra request
("platná session", "logged_in",
[{"remaining": 3500}], [], None, ("", ""), 1),
# nologged + successful re-login, verified again
("expired + re-login", "relogged",
[{"error": "nologged"}], [{"remaining": 1800}],
"session", ("uzivatel", "heslo"), 1),
# nologged + failed re-login
("expired + selhaný re-login", "fallback",
[{"error": "nologged"}], [], None, ("uzivatel", "heslo"), 1),
# nologged + no stored credentials
("expired bez údajů", "fallback",
[{"error": "nologged"}], [], None, ("", ""), 1),
# No session and no credentials – no request at all
("anonym bez údajů", "anonymous",
[], [], None, ("", ""), 0),
# Network error during the check
("chyba sítě", "unknown",
[(None, requests.exceptions.ConnectionError("probe"))],
[], None, ("", ""), 1),
# 200 but invalid JSON
("neplatný JSON", "unknown",
[(None, ValueError("Invalid JSON"))],
[], None, ("", ""), 1),
]
tools = amcr_viewer.amcr_tools
puvodni = (tools.login_to_api, tools._get_session,
dialog.LoginDialog.__dict__["get_credentials"])
try:
return _prihlasovaci_stav(pripady, tools)
finally:
# Leave the modules as they were found, even when a case fails
(tools.login_to_api, tools._get_session,
dialog.LoginDialog.get_credentials) = puvodni
tools.AMCR_SESSION = None
def _prihlasovaci_stav(pripady, tools):
"""Runs the cases of prihlasovaci_stav()."""
vysledky = []
for (nazev, ocekavano, tela, tela_po_loginu, login_vysledek,
kredity, get_volani) in pripady:
# The current session (None = _get_session returns None);
# a successful fake re-login produces a new fake session
session = FalesnaSession(tela) if tela else None
login_hodnota = FalesnaSession(tela_po_loginu) \
if login_vysledek else None
tools.AMCR_SESSION = session
def fake_login(hodnota):
# Like the real login_to_api: stores the session globally
def login(uzivatel, heslo):
if hodnota is not None:
tools.AMCR_SESSION = hodnota
return hodnota
return login
tools.login_to_api = fake_login(login_hodnota)
tools._get_session = (lambda s: lambda: s)(session) \
if session else (lambda: None)
dialog.LoginDialog.get_credentials = staticmethod(
(lambda k: lambda: k)(kredity)
)
stav = tools._ensure_logged_in()
assert stav == ocekavano, f"{nazev}: {stav} != {ocekavano}"
# The old session object must have been used for the checks
if session is not None:
assert session.get_volani == get_volani, \
f"{nazev}: {session.get_volani} != {get_volani}"
# The returned fake login session must become the global one
# and its login state must have been verified as well
if ocekavano == "relogged":
assert login_hodnota is not None
assert tools.AMCR_SESSION is login_hodnota
assert login_hodnota.get_volani == 1, \
f"{nazev}: nová session nebyla ověřena"
vysledky.append(f"{nazev} → {stav}")
return ", ".join(vysledky)
def odhlaseni():
"""logout_from_api with a fake session – offline."""
tools = amcr_viewer.amcr_tools
puvodni = tools.AMCR_SESSION
try:
# Logged-in session: one GET to /logout, session dropped
session = FalesnaSession([{"msg": "logged out"}])
session_get = session.get
urls = []
def get(url, timeout=0):
urls.append(url)
odpoved = session_get(url, timeout)
odpoved.raise_for_status = lambda: None
return odpoved
session.get = get
tools.AMCR_SESSION = session
assert tools.logout_from_api() is True
assert tools.AMCR_SESSION is None
assert urls and urls[0].endswith("/api/user/logout"), urls
# Network error: session still dropped locally
chyba = FalesnaSession(
[(None, requests.exceptions.ConnectionError("probe"))]
)
tools.AMCR_SESSION = chyba
assert tools.logout_from_api() is False
assert tools.AMCR_SESSION is None
assert chyba.get_volani == 1
# No session: nothing to do, no request
assert tools.logout_from_api() is True
finally:
tools.AMCR_SESSION = puvodni
return "odhlášení, chyba sítě → zahozeno lokálně, bez session → nic"
def vaha_komponent():
"""
_component_entries: the weight is 1/n of the components that pass
the predicate, so the weights of one documentation unit sum to 1
even with a period/area filter active.
The cases come from the spec of the fix (issue #55); the sums are
compared with a tolerance because 1/3 weights add up to 0.999…
"""
tools = amcr_viewer.amcr_tools
def komponenta(ident, areal=None, obdobi=None):
return {
"ident_cely": ident,
"komponenta_areal": ({"id": areal} if areal else None),
"komponenta_obdobi": ({"id": obdobi} if obdobi else None),
}
dj_meta = {"dj_id": "X-M-000001"}
# 4 components, no filter: 4 features, each 0.25
komps = [komponenta(f"K{i}") for i in range(4)]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert len(zaznamy) == 4, len(zaznamy)
assert all(z["vaha"] == 0.25 for z in zaznamy), \
[z["vaha"] for z in zaznamy]
# Period filter keeps 1 of 4: single feature with weight 1
komps = [
komponenta("K0", obdobi="neolit"),
komponenta("K1"), komponenta("K2"), komponenta("K3"),
]
zaznamy = tools._component_entries(
dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None
)
assert len(zaznamy) == 1, len(zaznamy)
assert zaznamy[0]["vaha"] == 1.0, zaznamy[0]["vaha"]
# Filter keeps 2 of 3: 2 features, each 0.5, sum 1 within tolerance
komps = [
komponenta("K0", obdobi="neolit"), komponenta("K1", obdobi="bronz"),
komponenta("K2"),
]
zaznamy = tools._component_entries(
dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None
)
assert len(zaznamy) == 2, len(zaznamy)
assert all(z["vaha"] == 0.5 for z in zaznamy), \
[z["vaha"] for z in zaznamy]
assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9
# Sum with tolerance also for an indivisible split (1/3)
komps = [komponenta(f"K{i}") for i in range(3)]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9
# No components: one entry with empty component fields, weight 1
zaznamy = tools._component_entries(dj_meta, [], lambda k: True)
assert len(zaznamy) == 1, zaznamy
assert zaznamy[0]["vaha"] == 1, zaznamy[0]["vaha"]
assert zaznamy[0]["komponenta_id"] == ""
# The shared DJ metadata and component fields travel along
komps = [komponenta("K0", areal="sidelni", obdobi="neolit")]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert zaznamy[0]["dj_id"] == "X-M-000001"
assert zaznamy[0]["komponenta_id"] == "K0"
return "4×0.25; 1/4 → 1.0; 2/3 → 2×0.5; prázdné → 1"
zkouska("scoped enumy", enumy)
zkouska("UpdateCodelistsTask", uloha)
zkouska("filtrační dialogy", dialogy)
zkouska("filtr podle data", filtr_datumu)
zkouska("stav přihlášení", prihlasovaci_stav)
zkouska("odhlášení", odhlaseni)
zkouska("váha komponent", vaha_komponent)
qgs.exitQgis()
if selhani:
print(f"\nNEPROŠLO: {', '.join(selhani)}")
sys.exit(1)
print("\nVše prošlo")