#!/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'