7242e815f8
- lib/dxf_scan_components.py: erkennt ILS-/Omniflo-Komponenten in fremden
Projektzeichnungen und schreibt sie im Testdaten-JSON-Schema aus
- lib/dxf_abbild.py: getreue Abbildung der Bauteile einer Fremdzeichnung
(Attribute, Weltkoordinaten, Unterkomponenten) ohne Interpretation
- tests/test_hundm05.{lsp,py}, testdata/hundm05.json, conftest-Fixtures und
alltests.json-Eintrag fuer den Kreisel-Abschnitt aus ST500592_05.dxf
- menu: TEST_HUNDM05 im Testmenue, Connection_Insert/Edit in SSG_LIB.cui
- Doku: hartkodierte Pfade durch (getenv "DXFMAKRO") ersetzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
Python
261 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
test_hundm05.py - Validiert den HundM-Kreisel-Abschnitt.
|
|
|
|
Prueft die von TEST_HUNDM05 (test_hundm05.lsp) erzeugten Ergebnisse:
|
|
1. Alle Eintraege aus hundm05.json wurden verarbeitet (results.json)
|
|
2. Statuswerte (executed / placeholder fuer Sensoren / unsupported Omniflo)
|
|
3. Kreisel-Attribute (HOEHE, KREISELART, Block-Praefix)
|
|
4. Vario: VF_-Block, L_GF gleichmaessig verteilt (kein L_GF1 in den Daten)
|
|
5. DXF-Geometrie: Block-Anzahlen je Typ (ezdxf)
|
|
|
|
Die Testdaten stammen aus lib/dxf_scan_components.py (Fenster
|
|
216000 144000 228000 156000 der Zeichnung results/ST500592_05.dxf) und nicht
|
|
aus Handarbeit - anders als bei mubea.json.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
# ============================================================
|
|
# Klassifikation der hundm05.json-Eintraege
|
|
# ============================================================
|
|
# Reihenfolge wie im Dispatcher von test_hundm05.lsp: Omniflo VOR Kreisel,
|
|
# weil Omniflo-Eintraege sowohl "id" als auch "type" tragen.
|
|
|
|
def _is_sensor(item):
|
|
return "block" in item
|
|
|
|
|
|
def _is_omniflo(item):
|
|
return "type" in item and "block" not in item
|
|
|
|
|
|
def _is_kreisel(item):
|
|
return "id" in item and "type" not in item and "block" not in item
|
|
|
|
|
|
def _hat_prefix(item, prefix):
|
|
return str(item.get("test_id", "")).startswith(prefix)
|
|
|
|
|
|
def _is_vario(item):
|
|
return _hat_prefix(item, "VF_")
|
|
|
|
|
|
def _is_gefaelle(item):
|
|
return _hat_prefix(item, "GF_")
|
|
|
|
|
|
def _is_gerade(item):
|
|
return _hat_prefix(item, "GERADE_")
|
|
|
|
|
|
def _expected_counts(testdata):
|
|
return {
|
|
"kreisel": sum(1 for t in testdata if _is_kreisel(t)),
|
|
"vario": sum(1 for t in testdata if _is_vario(t)),
|
|
"gefaellestrecke": sum(1 for t in testdata if _is_gefaelle(t)),
|
|
"gerade": sum(1 for t in testdata if _is_gerade(t)),
|
|
"sensor": sum(1 for t in testdata if _is_sensor(t)),
|
|
"omniflo": sum(1 for t in testdata if _is_omniflo(t)),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Testdaten-Pruefung (hundm05.json selbst)
|
|
# ============================================================
|
|
|
|
class TestHundM05Testdaten:
|
|
|
|
def test_jeder_eintrag_klassifizierbar(self, hundm05_testdata):
|
|
"""Kein Eintrag darf durch alle Zweige des Dispatchers fallen."""
|
|
for i, t in enumerate(hundm05_testdata):
|
|
erkannt = (_is_sensor(t) or _is_omniflo(t) or _is_kreisel(t)
|
|
or _is_vario(t) or _is_gefaelle(t) or _is_gerade(t))
|
|
assert erkannt, \
|
|
f"Eintrag {i} ist keinem Typ zuzuordnen: {sorted(t)}"
|
|
|
|
def test_kreisel_vorhanden(self, hundm05_testdata):
|
|
"""Der Abschnitt wurde wegen des Kreisels gewaehlt - er muss drin sein."""
|
|
kreisel = [t for t in hundm05_testdata if _is_kreisel(t)]
|
|
assert kreisel, "kein Kreisel in hundm05.json"
|
|
|
|
def test_vario_hat_hoehendifferenz(self, hundm05_testdata):
|
|
"""VarioFoerderer brauchen deltaH > 0 - der Winkel-Solver findet sonst
|
|
keinen Bogenwinkel. Waagrechte Paare gehoeren als 'gerade' ins JSON
|
|
(siehe GERADE_MAX_DELTA_H in lib/dxf_scan_components.py)."""
|
|
for t in hundm05_testdata:
|
|
if _is_vario(t):
|
|
assert float(t.get("deltaH", 0)) > 0, \
|
|
f'{t["test_id"]}: deltaH={t.get("deltaH")} - gehoert als ' \
|
|
f'"gerade" klassifiziert'
|
|
|
|
def test_gerade_hat_laenge(self, hundm05_testdata):
|
|
for t in hundm05_testdata:
|
|
if _is_gerade(t):
|
|
assert float(t.get("laenge", 0)) > 0, \
|
|
f'{t["test_id"]}: laenge={t.get("laenge")}'
|
|
|
|
def test_arrays_einzeilig(self):
|
|
"""Jedes Array muss komplett in EINER Zeile stehen.
|
|
|
|
ssg-parse-json-array (Lisp/ssg_core.lsp) liest die Datei zeilenweise,
|
|
und ssg-cfg-parse-array braucht das ganze Array in einem String. Ein
|
|
von json.dump(indent=2) umgebrochenes Array wird zu nil - der Treiber
|
|
baut das Element dann nicht an seiner Position. Das ist genau der
|
|
Fehler, der die Anlage einmal komplett am Ursprung aufgebaut hat, und
|
|
er ist in der Zeichnung nicht als Datenfehler zu erkennen.
|
|
"""
|
|
import os
|
|
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"testdata", "hundm05.json")
|
|
with open(pfad, encoding="utf-8") as fh:
|
|
for nr, zeile in enumerate(fh, start=1):
|
|
offen = zeile.count("[") - zeile.count("]")
|
|
# Die aeusserste Klammer des Dokuments darf allein stehen.
|
|
if zeile.strip() in ("[", "]"):
|
|
continue
|
|
assert offen == 0, \
|
|
f"Zeile {nr}: Array ueber mehrere Zeilen umgebrochen - " \
|
|
f"ssg-cfg-parse-array kann das nicht lesen: {zeile.strip()!r}"
|
|
|
|
|
|
# ============================================================
|
|
# Ergebnis-Pruefung (results.json vs. testdata)
|
|
# ============================================================
|
|
|
|
class TestHundM05Results:
|
|
|
|
def test_alle_verarbeitet(self, hundm05_testdata, hundm05_results):
|
|
"""Je Kind muss die erwartete Anzahl an Ergebnissen vorliegen."""
|
|
expected = _expected_counts(hundm05_testdata)
|
|
got = {}
|
|
for r in hundm05_results:
|
|
got[r["kind"]] = got.get(r["kind"], 0) + 1
|
|
for kind, n in expected.items():
|
|
assert got.get(kind, 0) == n, \
|
|
f'{kind}: erwartet {n}, verarbeitet {got.get(kind, 0)}'
|
|
|
|
def test_status_ok(self, hundm05_results):
|
|
"""Sensoren duerfen 'placeholder', Omniflo ist 'unsupported'
|
|
(omni:insert-dxf ist nicht scriptbar, siehe object_data.md 4.1),
|
|
alles andere muss 'executed' sein."""
|
|
for r in hundm05_results:
|
|
if r["kind"] == "sensor":
|
|
erlaubt = ("executed", "placeholder")
|
|
elif r["kind"] == "omniflo":
|
|
erlaubt = ("unsupported",)
|
|
else:
|
|
erlaubt = ("executed",)
|
|
assert r["status"] in erlaubt, \
|
|
f'{r["test_id"]} ({r["kind"]}): Status={r["status"]}, ' \
|
|
f'erlaubt {erlaubt}'
|
|
|
|
def test_kreisel_attribute(self, hundm05_testdata, hundm05_results):
|
|
"""Kreisel: KREISELART und HOEHE stimmen mit der Vorgabe, Blockname
|
|
traegt das Praefix KREISEL_. HOEHE wird von kreisel-connect-script aus
|
|
'hoehe' bzw. dem Z des Startpunkts gebildet (rtos ... 2 0, also
|
|
ganzzahlig)."""
|
|
by_id = {r["test_id"]: r for r in hundm05_results}
|
|
for t in hundm05_testdata:
|
|
if not _is_kreisel(t):
|
|
continue
|
|
r = by_id.get(t["id"])
|
|
assert r, f'Kreisel {t["id"]} fehlt im Ergebnis'
|
|
assert r["block_name"].startswith("KREISEL_"), \
|
|
f'{t["id"]}: Block "{r["block_name"]}" ohne Praefix KREISEL_'
|
|
assert r["actual_attributes"].get("KREISELART") == \
|
|
t.get("typ", "STANDARD"), \
|
|
f'{t["id"]}: KREISELART ist=' \
|
|
f'{r["actual_attributes"].get("KREISELART")}'
|
|
soll = t.get("hoehe", t.get("start_z", 0.0))
|
|
assert r["actual_attributes"].get("HOEHE") == str(int(round(soll))), \
|
|
f'{t["id"]}: HOEHE ist={r["actual_attributes"].get("HOEHE")}, ' \
|
|
f'erwartet={int(round(soll))}'
|
|
|
|
def test_kreisel_abstand(self, hundm05_testdata, hundm05_results):
|
|
"""Bei 'connect' berechnet kreisel-connect-script den Abstand als
|
|
Distanz(Start,Ende) - Kreiseldurchmesser (800mm). Der Block muss also
|
|
auf dem Startpunkt sitzen."""
|
|
by_id = {r["test_id"]: r for r in hundm05_results}
|
|
for t in hundm05_testdata:
|
|
if not _is_kreisel(t) or t.get("function") != "connect":
|
|
continue
|
|
r = by_id.get(t["id"])
|
|
assert r, f'Kreisel {t["id"]} fehlt im Ergebnis'
|
|
ip = r["insert_point"]
|
|
assert abs(ip[0] - t["start_x"]) < 1.0, \
|
|
f'{t["id"]}: Einfuegepunkt X {ip[0]} != start_x {t["start_x"]}'
|
|
assert abs(ip[1] - t["start_y"]) < 1.0, \
|
|
f'{t["id"]}: Einfuegepunkt Y {ip[1]} != start_y {t["start_y"]}'
|
|
|
|
def test_vario_block_und_split(self, hundm05_testdata, hundm05_results):
|
|
"""Vario: VF_-Block, und ohne L_GF1-Vorgabe wird L_GF gleichmaessig auf
|
|
Vor- und Nachlauf verteilt (Default nach object_data.md 3.1)."""
|
|
by_id = {r["test_id"]: r for r in hundm05_results}
|
|
for t in hundm05_testdata:
|
|
if not _is_vario(t):
|
|
continue
|
|
r = by_id.get(t["test_id"])
|
|
assert r, f'Vario {t["test_id"]} fehlt im Ergebnis'
|
|
assert r["block_name"].startswith("VF_"), \
|
|
f'{t["test_id"]}: Block "{r["block_name"]}" ist kein VF_-Block'
|
|
attrs = r["actual_attributes"]
|
|
if "L_GF1" in t or "L_GF_m" not in attrs:
|
|
continue
|
|
teile = attrs["L_GF_m"].split(",")
|
|
assert len(teile) == 2, \
|
|
f'{t["test_id"]}: L_GF_m Format "{attrs["L_GF_m"]}"'
|
|
vorne, hinten = float(teile[0]), float(teile[1])
|
|
assert abs(vorne - hinten) < 0.002, \
|
|
f'{t["test_id"]}: L_GF nicht gleichmaessig verteilt ' \
|
|
f'({vorne} / {hinten})'
|
|
|
|
def test_gefaelle_block(self, hundm05_testdata, hundm05_results):
|
|
by_id = {r["test_id"]: r for r in hundm05_results}
|
|
for t in hundm05_testdata:
|
|
if not _is_gefaelle(t):
|
|
continue
|
|
r = by_id.get(t["test_id"])
|
|
assert r, f'Gefaellestrecke {t["test_id"]} fehlt im Ergebnis'
|
|
assert r["block_name"].startswith("GF_"), \
|
|
f'{t["test_id"]}: Block "{r["block_name"]}" ist kein GF_-Block'
|
|
|
|
|
|
# ============================================================
|
|
# DXF Geometrie-Pruefung (ezdxf)
|
|
# ============================================================
|
|
|
|
class TestHundM05Geometry:
|
|
|
|
def _counts(self, dxf):
|
|
c = {}
|
|
for e in dxf.modelspace():
|
|
if e.dxftype() == "INSERT":
|
|
c[e.dxf.name] = c.get(e.dxf.name, 0) + 1
|
|
return c
|
|
|
|
def test_block_anzahlen(self, hundm05_dxf, hundm05_testdata):
|
|
"""Anzahl der KREISEL_/VF_/GF_-Bloecke >= Vorgabe aus hundm05.json."""
|
|
c = self._counts(hundm05_dxf)
|
|
exp = _expected_counts(hundm05_testdata)
|
|
n_kr = sum(v for n, v in c.items() if n.startswith("KREISEL_"))
|
|
n_vf = sum(v for n, v in c.items() if n.startswith("VF_"))
|
|
n_gf = sum(v for n, v in c.items() if n.startswith("GF_"))
|
|
assert n_kr >= exp["kreisel"], f"KREISEL_: {n_kr} < {exp['kreisel']}"
|
|
assert n_vf >= exp["vario"], f"VF_: {n_vf} < {exp['vario']}"
|
|
assert n_gf >= exp["gefaellestrecke"], \
|
|
f"GF_: {n_gf} < {exp['gefaellestrecke']}"
|
|
|
|
def test_handles_im_modelspace(self, hundm05_dxf, hundm05_results):
|
|
"""Jedes gebaute Block-Handle (executed) liegt im Modelspace."""
|
|
handles = {e.dxf.handle for e in hundm05_dxf.modelspace()
|
|
if e.dxftype() == "INSERT"}
|
|
for r in hundm05_results:
|
|
if r["status"] == "executed" and r["block_handle"]:
|
|
assert r["block_handle"] in handles, \
|
|
f'{r["test_id"]}: Handle {r["block_handle"]} nicht im ' \
|
|
f'Modelspace'
|