Bisher tauchten nur der Wrapper selbst, verpackte Separatoren und AS/ES- Kettenenden als eigene CSV-Zeilen auf - die uebrigen Kettenbausteine (Boegen, gerade Foerderer-/Gefaellestrecken-Segmente, Umlenk-/Motorstation) blieben nur in der Wrapper-Blockdefinition verpackt. csv:element-proxies- erzeugen (analog zu csv:sep-proxies-erzeugen) fuegt fuer jeden Fund eine temporaere Kopie ein, K1/K2 kommen aus dem EIGENEN KS_EIN/KS_AUS jedes Elements (nicht dem Gesamt-AS/ES der Kette) und bleiben leer, wo keines existiert (z.B. das nie tatsaechlich eingefuegte Vario_Kurve_*). Zusaetzlicher Fix: csv:get-k-kos-strings ignorierte bisher die Skalierung des Top-Level-Inserts - fuer Staustrecke_SP_1000_mm (das per Laenge nicht- uniform skaliert wird) waere K2 damit falsch gewesen, unabhaengig von der tatsaechlich gebauten Segmentlaenge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
370 lines
16 KiB
Python
370 lines
16 KiB
Python
#!/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"
|
|
)
|