[FEAT] VF-Linienzug ohne GUI/Konsole baubar (Stufe 0) + hundm05-Testfall

Ziel: eine VF_n-Kette soll aus einem Stapel Eingabedaten gebaut werden
koennen - ohne Dialog, ohne Konsolenfrage. Fahrplan und Begruendungen in
doc/TODO-plan-vf-interactive.md. Alle Aenderungen sind No-Ops, solange
*ssg-gui-aus* und *vfl-headless* nil sind.

Produktion (Lisp/vf_linienzug.lsp, Lisp/vf_core.lsp):
- vfl-journal-reset im Dispatcher VOR die Modus-cond gezogen. Bisher nur im
  Modus-1-Zweig: ein frischer Modus-2-Lauf erbte das Journal des Vorlaufs und
  schrieb es in die XDATA, ein spaeterer Doppelklick spielte fremde Eingaben
  vor.
- Lokale Variable "member" in vf-linienzug-modus2 umbenannt. Sie verdeckte im
  selben Scope das Builtin member, das weiter unten gebraucht wird - jedes
  Kletterer-Segment waere in "bad function" gelaufen.
- Neu vfl-meldung: sammelt den Text nach *vfl-meldungen* + dbgmsg und zeigt
  ihn nur bei erlaubter GUI modal, sonst per princ. Die 12 Bau-Pfad-alerts
  darauf umgestellt; ein Alert blockierte sonst jeden Batch-Lauf, und sein
  Text ist die einzige Auskunft, WELCHE Sektion abgewiesen wurde. Die reinen
  Interaktiv-Alerts (fehlendes DCL, "nicht editierbar", "kein Journal")
  bleiben alert.
- Neu *vfl-headless* (+ vfl-headless-p/-abbruch/-notausgang/-ort, Diagnose
  *vfl-headless-fehler*, optionaler Antwort-Hook *vfl-headless-antwort-fn*):
  eine erschoepfte Replay-Queue ist damit ein harter Abbruch MIT Fundstelle
  (Art der Eingabe, Glied- und Eingabe-Nummer) statt eines stillen Rueckfalls
  auf Live-Eingabe. Eingebaut in vfl-in-value, vfl-in-value-p,
  vfl-in-selection und vfl-in-abstand.
- vfl-journal-reset loescht Meldungen und Diagnose mit (gehoeren zum Lauf);
  vfl-view-refresh ueberspringt headless _PLAN/_ZOOM.

Testfall HundM05 (5 echte Ketten aus data/polylines.dxf):
- tests/testdata/hundm05.json neu erzeugt aus den XDATA-Journalen der
  VF_n-Bloecke (lib/vf_journal_export.py) - flach, weil ssg-load-json
  zeilenweise liest. Die drei kopierten Ketten bekommen ihren echten
  Einfuegepunkt, nicht das veraltete HOEHE_VON-Attribut.
- tests/test_hundm05.lsp arbeitet jetzt per Journal-Replay statt mit
  Eingabe-Mocks: ein echtes Journal fuehrt die geerbte Fahrtrichtung nicht mit
  (vfl-in-abstand journalisiert hz nur beim ersten Segment), ein Mock kann sie
  also nicht kennen. Schaltet *vfl-headless* ein und schreibt prompts,
  headless_fehler und meldungen ins Ergebnis-JSON.
- Kettenschleife fangt je Kette: ein Fehler NACH dem Bau nimmt nicht mehr die
  restlichen Ketten mit.
- entprev gibt es in AutoLISP nicht (nur entnext/entlast) - die Suche nach dem
  fertigen Block laeuft vorwaerts ab dem Zeichnungsstand vor dem Bau. Dieselbe
  Falle in tests/test_mubea.lsp mitbehoben; sie schlug dort nie zu, weil
  entlast immer sofort traf.

Absicherung ohne CAD:
- tests/test_vf_headless_statisch.py: eingechecktes Inventar aller
  alert/get*/ssget/new_dialog-Fundstellen je Funktion (ein neues getreal in
  einer Bau-Funktion faellt auf, auch wenn sein Zweig im Test nie erreicht
  wird), Praesenz des Riegels in allen vier Wrappern, Diagnose-Reset und die
  Reset-Reihenfolge im Dispatcher. Dazu ein Waechter gegen erfundene
  AutoLISP-Funktionen (entprev u.a.) - diese Fehlerklasse kostet sonst jedes
  Mal einen CAD-Lauf.
- tests/test_hundm05.py prueft zusaetzlich prompts == 0, keine
  Headless-Abbrueche und keine Bau-Meldungen.

tests/alltests.json: hundm05-Zeile laedt VarioFoerderer (nicht KreiselInsert)
und bleibt bis zu einem gruenen CAD-Lauf abgeschaltet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:14:31 +02:00
parent bbc9e9b9f9
commit ba01455d52
14 changed files with 3590 additions and 1143 deletions
+299 -179
View File
@@ -1,66 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_hundm05.py - Validiert den HundM-Kreisel-Abschnitt.
test_hundm05.py - Validiert die 5 VarioFoerderer-Linienzuege der Anlage HundM.
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)
Zwei Ebenen:
1. Die Testdaten selbst (tests/testdata/hundm05.json): Struktur der
Eingabe-Journale und Lesbarkeit durch den LISP-Parser.
2. Die von TEST_HUNDM05 (test_hundm05.lsp) erzeugten Ergebnisse
(output/hundm05_results.json + output/hundm05_tests.dxf): jede Kette
gebaut, kein Journal-Wert offen, Attribute passen zu den Eingaben.
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.
Die Testdaten stammen aus lib/vf_journal_export.py (Journale der VF_n-Bloecke
aus data/polylines.dxf, XDATA-App SSG_VF_EDIT) und nicht aus Handarbeit -
Schema siehe tests/testdata/object_data.md Abschnitt 8.
"""
import json
import os
import pytest
EINGABE_TYPEN = ("point_abs", "point_rel", "real", "int", "string", "step")
# ============================================================
# Klassifikation der hundm05.json-Eintraege
# Hilfsfunktionen: flaches JSON in Ketten gruppieren
# ============================================================
# Reihenfolge wie im Dispatcher von test_hundm05.lsp: Omniflo VOR Kreisel,
# weil Omniflo-Eintraege sowohl "id" als auch "type" tragen.
# Gleiche Regel wie hundm05:gruppiere in test_hundm05.lsp: ein Objekt mit
# "test_id" ist ein Kopf und beginnt eine Kette, die folgenden Objekte mit
# "typ" sind ihre Eingaben.
def _is_sensor(item):
return "block" in item
def _ketten(testdata):
ketten = []
for obj in testdata:
if "test_id" in obj:
ketten.append((obj, []))
elif "typ" in obj and ketten:
ketten[-1][1].append(obj)
return ketten
def _is_omniflo(item):
return "type" in item and "block" not in item
def _journal_laenge(eingaben):
"""Anzahl der Journal-Eintraege, die der Treiber daraus baut: ein
point_rel mit "hz" wird zu ZWEI Eintraegen (DL + REAL)."""
return sum(2 if (e["typ"] == "point_rel" and "hz" in e) else 1
for e in eingaben)
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)),
}
def _pfad(*teile):
return os.path.join(os.path.dirname(os.path.abspath(__file__)), *teile)
# ============================================================
@@ -69,34 +58,106 @@ def _expected_counts(testdata):
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_fuenf_ketten(self, hundm05_testdata):
"""Die Anlage hat fuenf am Kreisel haengende Linienzuege."""
ketten = _ketten(hundm05_testdata)
assert len(ketten) == 5, \
f"{len(ketten)} Ketten in hundm05.json, erwartet 5"
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_jedes_objekt_zuordenbar(self, hundm05_testdata):
"""Kein Objekt darf zwischen Kopf und Eingabe fallen - sonst
verschluckt hundm05:gruppiere es stillschweigend."""
for i, obj in enumerate(hundm05_testdata):
assert ("test_id" in obj) or ("typ" in obj), \
f"Objekt {i} ist weder Kopf noch Eingabe: {sorted(obj)}"
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_kopf_felder(self, hundm05_testdata):
for kopf, _ in _ketten(hundm05_testdata):
assert kopf["test_id"].startswith("VF_"), kopf["test_id"]
# "typ" wuerde den Kopf zu einer Eingabe machen (siehe
# hundm05:gruppiere) - der Anlagetyp heisst darum "anlagetyp".
assert "typ" not in kopf, \
f'{kopf["test_id"]}: Kopf darf kein Feld "typ" tragen'
assert kopf["anlagetyp"] == "linienzug", kopf["test_id"]
assert kopf["modus"] == 1, kopf["test_id"]
assert kopf["block"].startswith("VF_"), kopf["block"]
assert len(kopf["startpunkt_mm"]) == 3, kopf["test_id"]
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_anzahl_eingaben_stimmt(self, hundm05_testdata):
"""anzahl_eingaben ist die Selbstauskunft des Kopfes - weicht sie ab,
fehlen Eingaben (z.B. durch eine Handaenderung)."""
for kopf, eingaben in _ketten(hundm05_testdata):
assert kopf["anzahl_eingaben"] == len(eingaben), \
f'{kopf["test_id"]}: anzahl_eingaben=' \
f'{kopf["anzahl_eingaben"]}, gezaehlt {len(eingaben)}'
def test_eingabe_typen_bekannt(self, hundm05_testdata):
for kopf, eingaben in _ketten(hundm05_testdata):
for i, e in enumerate(eingaben):
assert e["typ"] in EINGABE_TYPEN, \
f'{kopf["test_id"]} Eingabe {i}: unbekannter Typ ' \
f'{e["typ"]}'
assert e.get("kommentar"), \
f'{kopf["test_id"]} Eingabe {i}: kein Kommentar'
def test_kette_beginnt_mit_startpunkt(self, hundm05_testdata):
"""Erste Eingabe = absoluter Startpunkt, zweite = Starthoehe. Fehlt
der Punkt, wuerde das Replay ihn interaktiv erfragen und die Kette an
der falschen Stelle aufbauen (siehe hundm05:build-linienzug)."""
for kopf, eingaben in _ketten(hundm05_testdata):
assert eingaben[0]["typ"] == "point_abs", kopf["test_id"]
assert eingaben[1]["typ"] == "real", kopf["test_id"]
punkt = eingaben[0]["wert"]
assert len(punkt) == 3, kopf["test_id"]
assert punkt == kopf["startpunkt_mm"], \
f'{kopf["test_id"]}: point_abs {punkt} != startpunkt_mm ' \
f'{kopf["startpunkt_mm"]}'
assert eingaben[1]["wert"] == punkt[2], \
f'{kopf["test_id"]}: Starthoehe {eingaben[1]["wert"]} != ' \
f'Z des Startpunkts {punkt[2]}'
def test_hz_nur_beim_ersten_segment(self, hundm05_testdata):
"""vfl-in-abstand journalisiert die Fahrtrichtung NUR beim ersten
Segment der Kette; jedes weitere erbt sie vom Vorgaenger. Ein "hz" an
spaeterer Stelle waere ein Wert zu viel in der Replay-Queue und
verschiebt alle folgenden Antworten (Desync)."""
for kopf, eingaben in _ketten(hundm05_testdata):
rel = [e for e in eingaben if e["typ"] == "point_rel"]
assert rel, f'{kopf["test_id"]}: kein Segment'
assert "hz" in rel[0], \
f'{kopf["test_id"]}: erstes Segment ohne hz'
for i, e in enumerate(rel[1:], start=2):
assert "hz" not in e, \
f'{kopf["test_id"]}: Segment {i} traegt hz - nur das ' \
f'erste Segment darf eines haben'
def test_segmentlaengen_positiv(self, hundm05_testdata):
for kopf, eingaben in _ketten(hundm05_testdata):
for e in eingaben:
if e["typ"] == "point_rel":
assert e["dL"] > 0, f'{kopf["test_id"]}: dL={e["dL"]}'
def test_menue_antworten_sind_strings(self, hundm05_testdata):
"""Menue-Antworten muessen Strings bleiben ("1"/"2"/"90"/"30"): der
Produktionscode vergleicht mit (= antwort "1"), eine Zahl wuerde
stumm in den Default-Zweig laufen."""
for kopf, eingaben in _ketten(hundm05_testdata):
for i, e in enumerate(eingaben):
if e["typ"] in ("string", "step"):
assert isinstance(e["wert"], str), \
f'{kopf["test_id"]} Eingabe {i}: {e["wert"]!r} ist ' \
f'kein String'
def test_glieder_vorhanden(self, hundm05_testdata):
"""Jede Kette hat Glied-Marker (STEP) - sie sind der Anker fuer den
Sektions-/Glied-Edit und die Kurzbeschreibung."""
for kopf, eingaben in _ketten(hundm05_testdata):
glieder = [e["wert"] for e in eingaben if e["typ"] == "step"]
assert glieder, f'{kopf["test_id"]}: kein STEP-Marker'
for g in glieder:
assert g in ("Linie-GF", "Linie-VF", "Linie", "GF-Bogen",
"Horizontal-VF", "Vario-Kurve", "ES"), \
f'{kopf["test_id"]}: unbekanntes Glied {g}'
def test_arrays_einzeilig(self):
"""Jedes Array muss komplett in EINER Zeile stehen.
@@ -104,14 +165,11 @@ class TestHundM05Testdaten:
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
baut die Kette dann nicht an ihrer 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:
with open(_pfad("testdata", "hundm05.json"), 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.
@@ -121,6 +179,33 @@ class TestHundM05Testdaten:
f"Zeile {nr}: Array ueber mehrere Zeilen umgebrochen - " \
f"ssg-cfg-parse-array kann das nicht lesen: {zeile.strip()!r}"
def test_flach_fuer_lisp_parser(self, hundm05_testdata):
"""Die Datei muss FLACH bleiben.
ssg-load-json / ssg-parse-json-array behandelt jedes "{" als
Objektanfang und jedes "}"/"}," als Objektende - es kennt keine
Verschachtelung. Ein Objekt in einem Objekt (oder ein Array von
Objekten wie "eingaben": [...]) wuerde das umgebende Objekt
verschlucken. Dieser Test zaehlt die Objekte so, wie der LISP-Parser
sie zaehlen wuerde, und vergleicht mit dem echten JSON.
"""
objekte = 0
with open(_pfad("testdata", "hundm05.json"), encoding="utf-8") as fh:
for zeile in fh:
gestrippt = zeile.strip()
if gestrippt == "{":
objekte += 1
elif gestrippt in ("}", "},"):
continue
elif gestrippt.startswith('"'):
# Ein Wert, der ein Objekt oder eine Objektliste eroeffnet,
# macht die Datei fuer den LISP-Parser unlesbar.
assert not gestrippt.endswith(("{", "[")), \
f"verschachtelter Wert: {gestrippt!r}"
assert objekte == len(hundm05_testdata), \
f"LISP-Parser saehe {objekte} Objekte, json.load sieht " \
f"{len(hundm05_testdata)}"
# ============================================================
# Ergebnis-Pruefung (results.json vs. testdata)
@@ -128,100 +213,147 @@ class TestHundM05Testdaten:
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 = {}
def test_je_kette_ein_ergebnis(self, hundm05_testdata, hundm05_results):
soll = [k["test_id"] for k, _ in _ketten(hundm05_testdata)]
ist = [r["test_id"] for r in hundm05_results]
assert ist == soll, f"Ergebnisse {ist}, erwartet {soll}"
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)}'
assert r["kind"] == "linienzug", f'{r["test_id"]}: {r["kind"]}'
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."""
def test_status_executed(self, hundm05_results):
""""desync" (Journal-Werte nicht verbraucht) und "failed" (Abbruch)
sind beide echte Fehler - siehe hundm05:build-linienzug."""
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}'
assert r["status"] == "executed", \
f'{r["test_id"]}: Status={r["status"]}'
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_journal_vollstaendig_verbraucht(self, hundm05_testdata,
hundm05_results):
"""Alle aufgezeichneten Werte muessen im Bau abgerufen worden sein.
Bleibt etwas offen, ist der Ablauf von der Aufzeichnung abgewichen und
die Geometrie kann der Vorlage nicht entsprechen."""
soll = {k["test_id"]: _journal_laenge(e)
for k, e in _ketten(hundm05_testdata)}
for r in hundm05_results:
assert "eingaben_offen" in r, (
f'{r["test_id"]}: kein eingaben_offen im Ergebnis - die Kette '
f'ist vor der Auswertung abgebrochen (status='
f'{r.get("status")!r}, {r.get("fehler_text")})')
assert r["eingaben_offen"] == 0, \
f'{r["test_id"]}: {r["eingaben_offen"]} Journal-Werte offen'
assert r["eingaben_gesamt"] == soll[r["test_id"]], \
f'{r["test_id"]}: {r["eingaben_gesamt"]} Journal-Eintraege ' \
f'abgespielt, erwartet {soll[r["test_id"]]}'
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_keine_live_eingabe(self, hundm05_results):
"""Kein Prompt, kein Dialog: der Treiber schaltet *vfl-headless* ein
(harter Abbruch statt stillem Rueckfall auf Live-Eingabe, siehe
vfl-headless-abbruch in Lisp/vf_linienzug.lsp) UND ersetzt zusaetzlich
getpoint/getstring/getint/getreal durch zaehlende Stubs. Beide Wege
muessen leer bleiben - "prompts" ist der positive Beweis, dass keine
Eingabe ausserhalb der vfl-in-*-Wrapper erreicht wurde."""
for r in hundm05_results:
if "prompts" in r:
assert r["prompts"] == 0, \
f'{r["test_id"]}: {r["prompts"]} Live-Eingabe(n) erreicht'
assert not r.get("headless_fehler"), \
f'{r["test_id"]}: Headless-Abbruch bei {r["headless_fehler"]}'
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'
def test_keine_geometrie_meldungen(self, hundm05_results):
"""*vfl-meldungen* sammelt die abgewiesenen Sektionen (frueher
blockierende alert-Fenster, jetzt vfl-meldung). Eine Meldung heisst:
ein Glied liess sich geometrisch nicht bauen - der Nachbau weicht dann
von der Vorlage ab, auch wenn das Journal aufgeht."""
for r in hundm05_results:
assert not r.get("meldungen"), \
f'{r["test_id"]}: Meldungen aus dem Bau: {r["meldungen"]}'
def test_vf_block_erzeugt(self, hundm05_results):
for r in hundm05_results:
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})'
f'{r["test_id"]}: Block "{r["block_name"]}" ist kein VF_-Block'
assert r["block_handle"], f'{r["test_id"]}: kein Handle'
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):
def test_dimension_einheitlich(self, hundm05_results):
"""Alle Ergebnisse eines Laufs stammen aus EINER Bau-Dimension.
Die Eingabedaten sind dimensionsfrei; ob _2D- oder _3D-Blockvarianten
verwendet werden, loest ssg-ils-dim-aktuell auf. Ein Lauf, in dem beide
vorkommen, waere ein halb umgeschalteter Zustand (z.B. weil ein
vorheriger Test *ssg-ils-dim* nicht zurueckgesetzt hat).
"""
dims = {r.get("dimension") for r in hundm05_results}
assert len(dims) == 1, f"gemischte Bau-Dimensionen im Lauf: {dims}"
dim = dims.pop()
assert dim in ("2D", "3D"), \
f'Bau-Dimension "{dim}" ist weder 2D noch 3D'
def test_startpunkt_getroffen(self, hundm05_testdata, hundm05_results):
"""Der VF_n-Block wird mit dem Kettenstartpunkt als Basispunkt
gewickelt (ssg-block-wrap-welt in vfl-block-erstellen) - sein
Einfuegepunkt muss also der Startpunkt aus den Eingaben sein."""
soll = {k["test_id"]: k["startpunkt_mm"]
for k, _ in _ketten(hundm05_testdata)}
for r in hundm05_results:
p = soll[r["test_id"]]
ist = r["insert_point"]
for achse, i in (("X", 0), ("Y", 1), ("Z", 2)):
assert abs(ist[i] - p[i]) < 1.0, \
f'{r["test_id"]}: Einfuegepunkt {achse} {ist[i]} != ' \
f'{p[i]}'
def test_as_es_attribute_aus_eingaben(self, hundm05_testdata,
hundm05_results):
"""WINKEL_AS/SEITE_AS und WINKEL_ES/SEITE_ES muessen die Antworten aus
dem Journal wiedergeben (AS steht in der Praeambel, ES im ES-Glied)."""
seiten = {"1": "links", "2": "rechts"}
nach_id = {r["test_id"]: r for r in hundm05_results}
for kopf, eingaben in _ketten(hundm05_testdata):
r = nach_id[kopf["test_id"]]
attrs = r["actual_attributes"]
# Praeambel: [0] Punkt, [1] Hoehe, [2] AS setzen, [3] Winkel,
# [4] Seite
if eingaben[2]["wert"] == "1":
assert attrs.get("WINKEL_AS") == eingaben[3]["wert"], \
f'{kopf["test_id"]}: WINKEL_AS=' \
f'{attrs.get("WINKEL_AS")}, erwartet {eingaben[3]["wert"]}'
assert attrs.get("SEITE_AS") == seiten[eingaben[4]["wert"]], \
f'{kopf["test_id"]}: SEITE_AS={attrs.get("SEITE_AS")}'
# ES: die beiden Strings direkt nach dem STEP-Marker "ES"
for i, e in enumerate(eingaben):
if e["typ"] == "step" and e["wert"] == "ES":
assert attrs.get("WINKEL_ES") == eingaben[i + 1]["wert"], \
f'{kopf["test_id"]}: WINKEL_ES=' \
f'{attrs.get("WINKEL_ES")}'
assert attrs.get("SEITE_ES") == \
seiten[eingaben[i + 2]["wert"]], \
f'{kopf["test_id"]}: SEITE_ES={attrs.get("SEITE_ES")}'
break
def test_vario_kurven_gezaehlt(self, hundm05_testdata, hundm05_results):
"""Jede Vario-Kurve im Journal muss in den VF_Bogen_*-Attributen
auftauchen (vfl-insert-vario-kurve-block zaehlt sie mit)."""
nach_id = {r["test_id"]: r for r in hundm05_results}
for kopf, eingaben in _ketten(hundm05_testdata):
soll = sum(1 for e in eingaben
if e["typ"] == "step" and e["wert"] == "Vario-Kurve")
attrs = nach_id[kopf["test_id"]]["actual_attributes"]
ist = sum(int(attrs.get(f"VF_Bogen_{lage}_{w}", 0) or 0)
for lage in ("A", "I") for w in (90, 60, 30))
assert ist == soll, \
f'{kopf["test_id"]}: {ist} Vario-Kurven in den Attributen, ' \
f'{soll} im Journal'
def test_hoehe_von_ist_starthoehe(self, hundm05_testdata,
hundm05_results):
nach_id = {r["test_id"]: r for r in hundm05_results}
for kopf, eingaben in _ketten(hundm05_testdata):
attrs = nach_id[kopf["test_id"]]["actual_attributes"]
if "HOEHE_VON_mm" not in attrs:
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'
assert abs(int(attrs["HOEHE_VON_mm"]) - eingaben[1]["wert"]) <= 1, \
f'{kopf["test_id"]}: HOEHE_VON_mm={attrs["HOEHE_VON_mm"]}, ' \
f'Starthoehe {eingaben[1]["wert"]}'
# ============================================================
@@ -230,24 +362,12 @@ class TestHundM05Results:
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_block_anzahl(self, hundm05_dxf, hundm05_testdata):
"""Je Kette muss ein VF_-Block im Modelspace stehen."""
n_vf = sum(1 for e in hundm05_dxf.modelspace()
if e.dxftype() == "INSERT" and e.dxf.name.startswith("VF_"))
soll = len(_ketten(hundm05_testdata))
assert n_vf >= soll, f"VF_: {n_vf} < {soll}"
def test_handles_im_modelspace(self, hundm05_dxf, hundm05_results):
"""Jedes gebaute Block-Handle (executed) liegt im Modelspace."""