#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ test_gefaellestrecke.py - Validiert GEFAELLESTRECKE Testergebnisse. Prueft: 1. Status-Werte und Vollstaendigkeit (gefaellestrecke_results.json) 2. Mathematische Invarianten (Hoehen, Winkel, deltaH) 3. Konsistenz zwischen den Modi (Modus 1 Richtungsunabhaengigkeit, Modus 3 uebernimmt deltaH/deltaL aus Modus 2) """ import csv import json import os import sys import pytest # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- def _testdata_dir(): return os.getenv("DXFM_TESTDATA", os.path.join(os.path.dirname(os.path.abspath(__file__)), "testdata")) def _output_dir(): return os.getenv("DXFM_TESTOUT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")) def _load_json(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def _load_gefaellestrecke_results(): path = os.path.join(_output_dir(), "gefaellestrecke_results.json") if not os.path.exists(path): pytest.skip( "gefaellestrecke_results.json nicht vorhanden - " "TEST_GEFAELLESTRECKE in BricsCAD ausfuehren" ) data = _load_json(path) if not data: pytest.skip("gefaellestrecke_results.json ist leer") return data def _load_gefaellestrecke_testdata(): path = os.path.join(_testdata_dir(), "gefaellestrecke_tests.json") return _load_json(path) # --------------------------------------------------------------------------- # Klasse 1: Status und Vollstaendigkeit # --------------------------------------------------------------------------- class TestGefaellestreckeStatus: """Prueft Status-Werte und Vollstaendigkeit der Ergebnisse.""" @pytest.fixture(autouse=True) def setup(self): self.results = _load_gefaellestrecke_results() testdata = _load_gefaellestrecke_testdata() self.tests = [t for t in testdata if "test_id" in t] self.results_by_id = {r["test_id"]: r for r in self.results} def test_alle_testfaelle_vorhanden(self): """Alle Testfaelle muessen in den Ergebnissen vorhanden sein.""" expected_ids = {t["test_id"] for t in self.tests} result_ids = {r["test_id"] for r in self.results} missing = expected_ids - result_ids assert not missing, f"Fehlende Testfaelle in Ergebnissen: {missing}" def test_status_werte_gueltig(self): """Status muss GEBAUT oder FEHLER (bzw. FEHLER_LINIE) sein.""" gueltige_status = {"GEBAUT", "FEHLER", "FEHLER_LINIE"} for r in self.results: assert r["status"] in gueltige_status, ( f'{r["test_id"]}: Unbekannter Status "{r["status"]}"' ) def test_alle_modus1_gebaut(self): """Alle Modus-1-Testfaelle (Richtungs-Sweep + 30-Grad-Variante) muessen GEBAUT sein.""" erwartet = [t["test_id"] for t in self.tests if t["modus"] == 1] modus1 = [r for r in self.results if r["modus"] == 1] assert len(modus1) == len(erwartet), ( f"Erwartet {len(erwartet)} Modus-1-Testfaelle, gefunden {len(modus1)}" ) for r in modus1: assert r["status"] == "GEBAUT", ( f'{r["test_id"]}: erwartet GEBAUT, ist "{r["status"]}"' ) def test_modus1_30grad_gebaut(self): """Die per Dialog waehlbare 30-Grad-Element-Variante (M1_w30) muss GEBAUT sein.""" r = self.results_by_id.get("M1_w30") if r is None: pytest.skip("M1_w30 nicht in Ergebnissen (aeltere Testdaten?)") assert r["status"] == "GEBAUT", ( f'M1_w30: erwartet GEBAUT, ist "{r["status"]}"' ) assert r.get("as_winkel") == "30" and r.get("es_winkel") == "30", ( f'M1_w30: Element-Winkel sollten 30 sein, sind ' f'AS={r.get("as_winkel")} ES={r.get("es_winkel")}' ) def test_modus2_und_modus3_gebaut(self): """Modus 2 und Modus 3 muessen GEBAUT sein.""" for test_id in ("M2_hz000", "M3_hz270"): r = self.results_by_id.get(test_id) assert r is not None, f"{test_id} fehlt in den Ergebnissen" assert r["status"] == "GEBAUT", ( f'{test_id}: erwartet GEBAUT, ist "{r["status"]}"' ) def test_mindestens_ein_gebaut(self): """Mindestens ein Testfall muss GEBAUT sein.""" gebaut = [r for r in self.results if r["status"] == "GEBAUT"] assert len(gebaut) > 0, "Keine einzige Gefaellestrecke konnte gebaut werden" # --------------------------------------------------------------------------- # Klasse 2: Mathematische Invarianten # --------------------------------------------------------------------------- class TestGefaellestreckeMathematik: """Prueft mathematische Korrektheit der berechneten Werte.""" @pytest.fixture(autouse=True) def setup(self): self.results = _load_gefaellestrecke_results() self.gebaut = [r for r in self.results if r["status"] == "GEBAUT"] self.results_by_id = {r["test_id"]: r for r in self.results} def test_gebaut_hat_pflichtfelder(self): """GEBAUT-Ergebnisse muessen alle Kernfelder enthalten.""" pflichtfelder = { "modus", "hz_grad", "deltaL_mm", "winkel_grad", "hoehe_von_mm", "hoehe_bis_mm", "delta_h_mm", } for r in self.gebaut: missing = pflichtfelder - set(r.keys()) assert not missing, f'{r["test_id"]}: fehlende Felder {missing}' def test_deltaL_positiv(self): """deltaL_mm muss fuer alle GEBAUT-Faelle groesser als 0 sein.""" for r in self.gebaut: assert r["deltaL_mm"] > 0, ( f'{r["test_id"]}: deltaL_mm={r["deltaL_mm"]} muss > 0 sein' ) def test_winkel_positiv(self): """Der berechnete Gefaellewinkel muss groesser als 0 Grad sein.""" for r in self.gebaut: assert r["winkel_grad"] > 0, ( f'{r["test_id"]}: winkel_grad={r["winkel_grad"]} muss > 0 sein' ) def test_delta_h_konsistent_mit_hoehen(self): """delta_h_mm muss der absoluten Differenz von hoehe_von/hoehe_bis entsprechen.""" for r in self.gebaut: erwartet = abs(r["hoehe_von_mm"] - r["hoehe_bis_mm"]) assert erwartet == r["delta_h_mm"], ( f'{r["test_id"]}: delta_h_mm={r["delta_h_mm"]}, ' f'erwartet |{r["hoehe_von_mm"]}-{r["hoehe_bis_mm"]}|={erwartet}' ) def test_hoehe_bis_kleiner_hoehe_von(self): """Bei einer Gefaellestrecke muss die Endhoehe unter der Starthoehe liegen.""" for r in self.gebaut: assert r["hoehe_bis_mm"] < r["hoehe_von_mm"], ( f'{r["test_id"]}: hoehe_bis_mm={r["hoehe_bis_mm"]} ' f'nicht < hoehe_von_mm={r["hoehe_von_mm"]}' ) # --------------------------------------------------------------------------- # Klasse 3: Konsistenz zwischen den Modi # --------------------------------------------------------------------------- class TestGefaellestreckeModusKonsistenz: """Prueft Zusammenhaenge zwischen Modus 1/2/3 (Richtungsunabhaengigkeit, Uebernahme von deltaH/deltaL aus Modus 2 in Modus 3).""" # Toleranz fuer die 2-Pass-Winkelkorrektur in Modus 3 (mm) DELTA_H_TOLERANZ_MM = 5 @pytest.fixture(autouse=True) def setup(self): self.results = _load_gefaellestrecke_results() self.results_by_id = {r["test_id"]: r for r in self.results} self.testdata = _load_gefaellestrecke_testdata() self.tests_by_id = {t["test_id"]: t for t in self.testdata if "test_id" in t} def test_modus1_richtungsunabhaengig(self): """Die vier Modus-1-Richtungen (90-Grad-Elemente) muessen dieselbe Hoehe/Winkel liefern, da nur die Fahrtrichtung (hz), nicht die Geometrie variiert. Die 30-Grad-Variante (M1_w30) ist bewusst ausgenommen (andere Element-Masse).""" modus1 = [r for r in self.results if r["modus"] == 1 and r["status"] == "GEBAUT" and r.get("as_winkel", "90") == "90"] assert len(modus1) >= 2, "Zu wenige Modus-1-Ergebnisse fuer Vergleich" referenz = modus1[0] for r in modus1[1:]: assert r["delta_h_mm"] == referenz["delta_h_mm"], ( f'{r["test_id"]}: delta_h_mm={r["delta_h_mm"]} weicht von ' f'{referenz["test_id"]}={referenz["delta_h_mm"]} ab (Richtungsabhaengigkeit!)' ) assert r["winkel_grad"] == referenz["winkel_grad"], ( f'{r["test_id"]}: winkel_grad={r["winkel_grad"]} weicht von ' f'{referenz["test_id"]}={referenz["winkel_grad"]} ab' ) def test_modus3_uebernimmt_deltaH_aus_modus2(self): """Modus 3 (Linienzug mit 2-Pass-Korrektur) muss innerhalb einer kleinen Toleranz dieselbe Hoehendifferenz wie die Quelle aus Modus 2 erreichen.""" m3_test = self.tests_by_id.get("M3_hz270") if m3_test is None: pytest.skip("M3_hz270 nicht in Testdaten definiert") quelle_id = m3_test.get("deltaH_quelle") m2 = self.results_by_id.get(quelle_id) m3 = self.results_by_id.get("M3_hz270") if m2 is None or m3 is None: pytest.skip("M2- oder M3-Ergebnis fehlt") if m2["status"] != "GEBAUT" or m3["status"] != "GEBAUT": pytest.skip("M2 oder M3 nicht GEBAUT") abweichung = abs(m3["delta_h_mm"] - m2["delta_h_mm"]) assert abweichung <= self.DELTA_H_TOLERANZ_MM, ( f'M3_hz270: delta_h_mm={m3["delta_h_mm"]} weicht um {abweichung} mm ' f'von {quelle_id}={m2["delta_h_mm"]} ab ' f'(Toleranz {self.DELTA_H_TOLERANZ_MM} mm)' ) # --------------------------------------------------------------------------- # Klasse 4: CSV-Export-Pruefung (gefaellestrecke_tests_export.csv) # --------------------------------------------------------------------------- # TeileArt-Werte, die einen Sensor bzw. ein Streckenbauteil beschreiben statt # einer GF-Strecke selbst - csv:element-proxies-erzeugen (export.lsp) erfasst # die verpackten Bausteine (Boegen, gerade Foerderer-/Gefaellestrecken- # Segmente, Umlenk-/Motorstation) als je eigene Zeile, wie schon # csv:sep-proxies-erzeugen fuer Separator_SP-Symbole; siehe # test_foerderer.py:NEBEN_TEILEARTEN (analoge Konstante dort, erste GF-eigene # CSV-Struktur-Pruefung hier - vorher gab es fuer GF-Ketten noch keine). NEBEN_TEILEARTEN = { "ILS 2.0 Separator", "ILS 2.0 Bogen", "ILS 2.0 Streckenelement", "ILS 2.0 Umlenkstation", "ILS 2.0 Motorstation", } class TestGefaellestreckeExportCSV: """Prueft den CSV-Export aus BricsCAD (gefaellestrecke_tests_export.csv, aus TEST_EXPORT_ALL).""" @pytest.fixture(autouse=True) def setup(self): csv_path = os.path.join(_output_dir(), "gefaellestrecke_tests_export.csv") if not os.path.exists(csv_path): pytest.skip( "gefaellestrecke_tests_export.csv nicht vorhanden - " "TEST_GEFAELLESTRECKE und anschliessend TEST_EXPORT_ALL in BricsCAD ausfuehren" ) self.rows = [] with open(csv_path, "r", encoding="utf-8") as f: reader = csv.reader(f, delimiter=";") self.header = next(reader) for row in reader: self.rows.append(row) self.col = {name: i for i, name in enumerate(self.header)} def _cell(self, row, spalte): i = self.col.get(spalte) if i is None or i >= len(row): return None return row[i] def test_teileart_bekannt(self): """Jede Zeile ist entweder eine GF-Strecke oder eine der bekannten Neben-TeileArten - keine unerwartete dritte Art.""" bekannt = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke"} | NEBEN_TEILEARTEN for i, row in enumerate(self.rows): teileart = self._cell(row, "TeileArt") if teileart is None: continue assert teileart.strip('"') in bekannt, ( f"Zeile {i + 2}: unbekannte TeileArt '{teileart}'" ) def test_element_zeilen_haben_k1_oder_k2(self): """Bogen/Umlenkstation/Motorstation-Zeilen sollten zuverlaessig ein eigenes KS_EIN/KS_AUS tragen (anders als Vario_Kurve_*, das laut Code-Kommentar nie tatsaechlich eingefuegt wird und daher hier auch nie auftaucht) - K1 ODER K2 muss gefuellt sein. Streckenelement wird hier NICHT geprueft (dafuer siehe test_streckenelement_laenge_stimmt_mit_k1_k2_ueberein unten).""" pruef_teilearten = {"ILS 2.0 Bogen", "ILS 2.0 Umlenkstation", "ILS 2.0 Motorstation"} for i, row in enumerate(self.rows): teileart = self._cell(row, "TeileArt") if teileart is None or teileart.strip('"') not in pruef_teilearten: continue k1 = self._cell(row, "K1") k2 = self._cell(row, "K2") assert (k1 and k1.strip('"')) or (k2 and k2.strip('"')), ( f"Zeile {i + 2}: TeileArt='{teileart}' hat weder K1 noch K2 gefuellt" ) def test_streckenelement_laenge_stimmt_mit_k1_k2_ueberein(self): """Regressionstest fuer den csv:get-k-kos-strings-Skalierungsfix: Staustrecke_SP_1000_mm wird beim Bau per Laenge NICHT-UNIFORM skaliert (insert-inclined-scaled-block) - ohne die sx/sy/sz-Beruecksichtigung in csv:get-k-kos-strings waere K2 dort so berichtet, als waere der Block unskaliert (1000mm) eingefuegt, unabhaengig von der tatsaechlich gebauten Segmentlaenge. Der K1->K2-Abstand einer Streckenelement-Zeile muss daher der Boundingbox-Ausdehnung (die reale, gebaute Laenge) entsprechen, NICHT literal 1000mm, sofern das Segment gestreckt wurde.""" sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) from export_neighbors import _decode_trans_xy gefunden = 0 for i, row in enumerate(self.rows): teileart = self._cell(row, "TeileArt") if teileart is None or teileart.strip('"') != "ILS 2.0 Streckenelement": continue k1_str = self._cell(row, "K1") k2_str = self._cell(row, "K2") if not k1_str or not k2_str: continue p1 = _decode_trans_xy(k1_str.strip('"')) p2 = _decode_trans_xy(k2_str.strip('"')) if p1 is None or p2 is None: continue k1_k2_laenge = ((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2) ** 0.5 # Nur Segmente pruefen, deren reale Laenge sichtbar von 1000mm # abweicht - sonst waere ein (durch den Bug) falsch auf 1000mm # zurueckfallender Wert zufaellig nicht von einem korrekten zu # unterscheiden. if abs(k1_k2_laenge - 1000.0) < 50.0: continue gefunden += 1 boundingbox = self._cell(row, "Boundingbox") assert boundingbox, f"Zeile {i + 2}: Boundingbox fehlt" dx, dy, dz = (float(v) for v in boundingbox.strip('"').split(",")) bbox_diagonale = (dx ** 2 + dy ** 2) ** 0.5 # Toleranz: die Boundingbox-Diagonale ist ein Oberwert (das # Element kann geneigt/gedreht sein), der K1-K2-Abstand darf sie # nicht wesentlich ueberschreiten - waere er trotz Streckung immer # ~1000mm (der Bug), wuerde er bei stark gestreckten Segmenten # deutlich UNTER der Bounding-Box-Diagonale liegen. assert k1_k2_laenge <= bbox_diagonale + 50.0, ( f"Zeile {i + 2}: K1->K2-Abstand={k1_k2_laenge:.0f}mm groesser als " f"Boundingbox-Diagonale={bbox_diagonale:.0f}mm" ) assert k1_k2_laenge >= 50.0, ( f"Zeile {i + 2}: K1->K2-Abstand={k1_k2_laenge:.0f}mm verdaechtig klein " f"(Skalierungsfix eventuell nicht wirksam)" ) if gefunden == 0: pytest.skip( "keine Streckenelement-Zeile mit von 1000mm abweichender " "K1-K2-Laenge gefunden - Testdaten enthalten kein gestrecktes Segment" )