[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
+9 -2
View File
@@ -102,7 +102,7 @@ Die Datei `tests/alltests.json` ist die zentrale Registry aller Test-Module.
{ "name": "omniflo_strecke", "save": "dxf", "module": "OmniModulInsert" },
{ "name": "gefaellestrecke", "save": "dwg", "module": "Gefaellestrecke" },
{ "name": "mubea", "save": "dxf", "module": "KreiselInsert" },
{ "name": "hundm05", "save": "dxf", "module": "KreiselInsert", "disabled": true }
{ "name": "hundm05", "save": "dxf", "module": "VarioFoerderer", "disabled": true }
]
```
@@ -160,7 +160,14 @@ tests/
test_foerderer.py # pytest-Validierung Foerderer (Status/Mathematik/CSV/Sivas)
test_gefaellestrecke.py # pytest-Validierung Gefaellestrecke
test_mubea.py # pytest-Validierung Mubea-Gesamtmodell
test_hundm05.py # pytest-Validierung HundM05-Abschnitt
test_hundm05.py # pytest-Validierung HundM05 (5 Linienzuege)
test_vf_journal_grammatik.py # pytest ohne CAD: Journal-Dekoder aus
# lib/vf_journal_export.py gegen handgebaute
# Journale (Linie-VF, gewinkelter Erstkoerper)
test_vf_headless_statisch.py # pytest ohne CAD: statischer Waechter, dass
# im VF-Bau-Pfad kein alert/get*/ssget neu
# auftaucht und der Headless-Riegel an allen
# vfl-in-*-Wrappern sitzt
test_unit.lsp / .scr # LISP-Unit-Tests: C:TEST_UNIT (ueber --lisp)
testdata/
kreisel_tests.json # Testfall-Definitionen fuer Kreisel
+1 -1
View File
@@ -7,5 +7,5 @@
{ "name": "omniflo_strecke", "save": "dxf", "module": "OmniModulInsert" },
{ "name": "gefaellestrecke", "save": "dwg", "module": "Gefaellestrecke" },
{ "name": "mubea", "save": "dxf", "module": "KreiselInsert" },
{ "name": "hundm05", "save": "dxf", "module": "KreiselInsert", "disabled": true }
{ "name": "hundm05", "save": "dxf", "module": "VarioFoerderer", "disabled": true }
]
+534 -468
View File
File diff suppressed because it is too large Load Diff
+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."""
+43 -12
View File
@@ -69,18 +69,49 @@
)
;; --- Letzten INSERT mit Blocknamen-Praefix (rueckwaerts) suchen ---
(defun mubea:last-insert-prefix (pref / ent found ed bn)
(setq ent (entlast))
(while (and ent (null found))
(setq ed (entget ent) bn (cdr (assoc 2 ed)))
(if (and (= (cdr (assoc 0 ed)) "INSERT") bn
(>= (strlen bn) (strlen pref))
(= (substr bn 1 (strlen pref)) pref))
(setq found ent))
(setq ent (entprev ent)))
found
)
;; --- Letzten INSERT mit Blocknamen-Praefix suchen ---
;; AutoLISP kennt KEIN entprev (nur entnext und entlast) - rueckwaerts durch
;; die Zeichnung laufen geht also nicht. Gesucht wird darum VORWAERTS, wobei
;; der letzte Treffer gewinnt. Ein blosses (entlast) genuegt nicht: nach dem
;; Bau koennen weitere Objekte entstehen (z.B. Beschriftungstexte), entlast
;; ist dann nicht der gesuchte Block - genau daran scheiterte der Testlauf.
;; ab-ent = Stand der Zeichnung VOR dem Bau. Damit prueft die Suche nur die
;; neu entstandenen Objekte: schnell, und sie kann nicht versehentlich den
;; Block eines frueheren Baus liefern (siehe Kommentar bei -ab).
(defun mubea:insert-prefix-p (ent pref / ed bn)
(setq ed (entget ent) bn (cdr (assoc 2 ed)))
(and ed (= (cdr (assoc 0 ed)) "INSERT") bn
(>= (strlen bn) (strlen pref))
(= (substr bn 1 (strlen pref)) pref)))
;; Vorwaertslauf ab ab-ent (nil = ganze Zeichnung); der LETZTE Treffer
;; gewinnt. Der entget-Test faengt ein inzwischen ungueltiges ab-ent ab
;; (entget liefert dann nil, entnext wuerde einen Fehler werfen).
(defun mubea:suche-vorwaerts (pref ab-ent / ent found)
(setq ent (if (and ab-ent (entget ab-ent)) (entnext ab-ent) (entnext)))
(while ent
(if (mubea:insert-prefix-p ent pref) (setq found ent))
(setq ent (entnext ent)))
found)
(defun mubea:last-insert-prefix-ab (pref ab-ent / ent)
(if ab-ent
;; MIT Marker nur die neu entstandenen Objekte pruefen - und BEWUSST
;; ohne entlast-Kurzschluss: hat dieser Bau keinen Block erzeugt, waere
;; entlast der Block eines FRUEHEREN Baus und das Ergebnis wuerde der
;; falschen Kette zugeordnet. Kein Treffer = kein Block, das ist die
;; richtige Antwort.
(mubea:suche-vorwaerts pref ab-ent)
(progn
;; Ohne Marker: erst entlast (der haeufige Fall, das gerade
;; eingefuegte Objekt), sonst die ganze Zeichnung.
(setq ent (entlast))
(if (and ent (mubea:insert-prefix-p ent pref))
ent
(mubea:suche-vorwaerts pref nil)))))
(defun mubea:last-insert-prefix (pref)
(mubea:last-insert-prefix-ab pref nil))
;; ============================================================
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_vf_headless_statisch.py - Statischer Waechter fuer den nicht-interaktiven
Betrieb des VF-Linienzugs. Laeuft ohne BricsCAD und ohne Zeichnung.
Warum statisch: die dynamischen Beweise (Ergebnis-Record mit "prompts": 0,
"headless_fehler": null in tests/output/hundm05_results.json) sagen nur, dass
der EINE getestete Ablauf sauber lief. Sie schlagen nicht an, wenn jemand
einem selten erreichten Zweig ein neues (alert ...) oder ein rohes
(getreal ...) hinzufuegt. Genau das haelt dieser Test fest: die Fundstellen
sind eingecheckt, jede neue muss hier bewusst eingetragen werden.
Hintergrund und Fahrplan: doc/TODO-plan-vf-interactive.md,
Mechanik: doc/variofoerderer.md Abschnitt 8b.
"""
import collections
import io
import os
import pytest
LISP = os.path.join(os.path.dirname(__file__), "..", "Lisp",
"vf_linienzug.lsp")
CORE = os.path.join(os.path.dirname(__file__), "..", "Lisp", "vf_core.lsp")
# Gesuchte Aufrufe: alles, was den Anwender fragt oder ihm ein Fenster zeigt.
GESUCHT = ("alert", "getstring", "getint", "getreal", "getpoint", "getkword",
"ssget", "entsel", "new_dialog")
# ============================================================
# Minimaler LISP-Scanner
# ============================================================
# Kein voller Parser: er braucht nur die Klammertiefe (fuer die Grenzen der
# Top-Level-Formen) und muss Strings sowie Kommentare ueberspringen, damit ein
# "alert" im Kommentar oder in einem i18n-Text nicht mitgezaehlt wird.
def funktions_inventar(quelltext, gesucht=GESUCHT):
"""{Funktionsname: Counter({Aufruf: Anzahl})} fuer alle Top-Level-defuns.
Code ausserhalb eines defun laeuft unter dem Namen "<top-level>".
gesucht = die Aufrufnamen, die gezaehlt werden."""
inventar = collections.defaultdict(collections.Counter)
tiefe = 0
aktuell = "<top-level>"
i = 0
n = len(quelltext)
while i < n:
c = quelltext[i]
if c == ";": # Kommentar bis Zeilenende
while i < n and quelltext[i] != "\n":
i += 1
continue
if c == '"': # String ueberspringen
i += 1
while i < n:
if quelltext[i] == "\\":
i += 2
continue
if quelltext[i] == '"':
i += 1
break
i += 1
continue
if c == "(":
wort = _wort_nach(quelltext, i + 1)
if tiefe == 0:
aktuell = ("<top-level>" if wort != "defun"
else _wort_nach(quelltext,
i + 1 + len("defun") + 1))
if wort in gesucht:
inventar[aktuell][wort] += 1
tiefe += 1
i += 1
continue
if c == ")":
tiefe -= 1
if tiefe <= 0:
tiefe = 0
aktuell = "<top-level>"
i += 1
continue
i += 1
return inventar
def _wort_nach(text, pos):
"""Erstes Symbol ab pos (Leerraum davor wird uebersprungen)."""
while pos < len(text) and text[pos] in " \t\r\n":
pos += 1
ende = pos
while ende < len(text) and text[ende] not in " \t\r\n()\";'":
ende += 1
return text[pos:ende]
def _funktionskoerper(quelltext, name):
"""Quelltext einer Top-Level-Funktion (Rohtext, genuegt fuer eine
Namenssuche)."""
start = quelltext.find("(defun %s " % name)
if start < 0:
start = quelltext.find("(defun %s(" % name)
if start < 0:
return None
tiefe, i = 0, start
while i < len(quelltext):
if quelltext[i] == "(":
tiefe += 1
elif quelltext[i] == ")":
tiefe -= 1
if tiefe == 0:
return quelltext[start:i + 1]
i += 1
return quelltext[start:]
@pytest.fixture(scope="module")
def quelltext():
if not os.path.isfile(LISP):
pytest.skip("Lisp/vf_linienzug.lsp nicht gefunden")
return io.open(LISP, encoding="cp1252").read()
@pytest.fixture(scope="module")
def inventar(quelltext):
return funktions_inventar(quelltext)
# ============================================================
# 1. alert - nur noch in reinen Interaktiv-Pfaden
# ============================================================
# Alle Bau-Pfad-Alerts sind zu vfl-meldung geworden (P1 im Fahrplan): der Text
# wird gesammelt statt modal gezeigt, damit ein Batch-Lauf nicht blockiert und
# die Auskunft "welche Sektion war nicht baubar" nicht verloren geht.
# Stehen bleiben darf, was nur bei echter Benutzerbedienung entstehen kann -
# plus vfl-meldung selbst, das den Alert bei erlaubter GUI weiterhin zeigt.
ALERT_ERLAUBT = {
"vfl-meldung": 1, # zeigt den Text nur bei (ssg-gui-p)
"vfl-glied-gf-bogen-dialog": 1, # dcl/vf_linienzug_wizard.dcl fehlt
"vfl-glied-as-es-dialog": 1, # dito
"vfl-glied-vario-dialog": 1, # dito
"vfl-dlg-modus": 1, # dcl/vfl_edit.dcl fehlt
"vfl-dlg-position": 1, # dito
"vfl-edit-glied": 1, # "Glied nicht einzeln editierbar"
"vfl-edit-ent": 1, # "kein Journal am Block"
}
def test_alert_nur_in_interaktiv_pfaden(inventar):
ist = {name: z["alert"] for name, z in inventar.items() if z["alert"]}
neu = sorted(k for k in ist if k not in ALERT_ERLAUBT)
assert not neu, (
"Neue (alert ...)-Aufrufe in %s. Im Bau-Pfad statt alert vfl-meldung "
"verwenden (sammelt den Text und zeigt ihn nur bei erlaubter GUI). "
"Ist der Aufruf wirklich rein interaktiv, hier eintragen." % neu)
assert ist == ALERT_ERLAUBT, (
"Alert-Inventar veraendert: ist %s, erwartet %s" % (ist,
ALERT_ERLAUBT))
# ============================================================
# 2. Rohe Eingaben nur in den Wrappern und in Interaktiv-Befehlen
# ============================================================
# Vollstaendiges Soll-Inventar. Ein neues getreal/getpoint/ssget in einer
# Bau-Funktion faellt damit auf, auch wenn sein Zweig im Test nie erreicht
# wird. Kommentar je Eintrag = warum die Stelle in Ordnung ist.
EINGABE_ERLAUBT = {
# --- die Journal-Wrapper selbst: hier laeuft der Replay durch ---
"vfl-getpoint": {"getpoint": 2},
"vfl-in-string": {"getstring": 1},
"vfl-in-int": {"getint": 1},
"vfl-in-real": {"getreal": 1},
"vfl-in-selection": {"ssget": 1},
"vfl-winkel-live": {"getint": 1}, # livefn fuer vfl-in-value
# --- Wizard-Schicht (DCL); bei ssg-gui-aus greift sie nicht ---
"vflw-zahl": {"getreal": 1}, # Fallback ohne Dialog
"vflw-zahl-impl": {"new_dialog": 1},
"vflw-wahl-impl": {"new_dialog": 1},
"vflw-gruppe-as-impl": {"new_dialog": 1},
"vflw-gruppe-gefaelle-impl": {"new_dialog": 1},
"vflw-gruppe-horizontal-impl": {"new_dialog": 1},
"vflw-gruppe-punkt-hoehe-impl": {"new_dialog": 1},
"vflw-gruppe-variokurve-impl": {"new_dialog": 1},
"vflw-gruppe-winkel-seite-impl": {"new_dialog": 1},
"vflw-gruppe-ziel-hoehe-impl": {"new_dialog": 1},
"vflw-seg-bogen-impl": {"new_dialog": 1},
"vflw-seg-linie-m2-impl": {"new_dialog": 1},
"vflw-seg-linie-m3-impl": {"new_dialog": 1},
# --- Edit-Dialoge: nur per Doppelklick erreichbar ---
"vfl-dlg-modus": {"new_dialog": 1, "alert": 1},
"vfl-dlg-position": {"new_dialog": 1, "alert": 1},
"vfl-glied-as-es-dialog": {"new_dialog": 1, "alert": 1},
"vfl-glied-gf-bogen-dialog": {"new_dialog": 1, "alert": 1},
"vfl-glied-vario-dialog": {"new_dialog": 1, "alert": 1},
"vfl-edit-ent": {"alert": 1},
"vfl-edit-glied": {"alert": 1},
"vfl-meldung": {"alert": 1},
# --- reine Benutzer-Befehle (kein Bau-Pfad, nicht journalisiert) ---
"vfl-praeambel-erfragen": {"getpoint": 1, "getreal": 1, "getkword": 2},
"c:VF_SEKTION_RESTORE": {"ssget": 1},
"c:VFL_KS_DIAG": {"getstring": 1},
"c:Vario_Kette_Merge": {"entsel": 1},
# --- Zeichnung durchsuchen, keine Benutzerfrage ---
"vfl-kette-sammle-alle": {"ssget": 1},
}
def test_eingabe_fundstellen_unveraendert(inventar):
ist = {name: dict(z) for name, z in inventar.items() if z}
neu = sorted(k for k in ist if k not in EINGABE_ERLAUBT)
assert not neu, (
"Neue Eingabe-/Dialog-Aufrufe in %s. Im Bau-Pfad gehoeren Eingaben "
"durch die vfl-in-*-Wrapper (Journal + Replay + Headless-Riegel), "
"nicht direkt an get*/ssget. Ist die Stelle rein interaktiv, hier "
"eintragen." % neu)
weg = sorted(k for k in EINGABE_ERLAUBT if k not in ist)
assert not weg, ("Erwartete Fundstellen verschwunden (umbenannt?): %s"
% weg)
abweichung = {k: (ist[k], EINGABE_ERLAUBT[k]) for k in ist
if ist[k] != EINGABE_ERLAUBT[k]}
assert not abweichung, ("Anzahl der Aufrufe geaendert (ist, erwartet): %s"
% abweichung)
# ============================================================
# 3. Der Headless-Riegel sitzt an allen Eingabe-Engstellen
# ============================================================
# Jede interaktive Eingabe des Linienzugs laeuft durch einen dieser Wrapper.
# Fehlt dort die Headless-Pruefung, faellt eine erschoepfte Replay-Queue
# wieder still auf Live-Eingabe zurueck - der Fehler, den P2 behoben hat.
RIEGEL_ERWARTET = {
"vfl-in-value": ["vfl-headless-p", "vfl-headless-notausgang"],
"vfl-in-value-p": ["vfl-headless-p", "vfl-headless-notausgang"],
"vfl-in-selection": ["vfl-headless-p", "vfl-headless-abbruch"],
"vfl-in-abstand": ["vfl-headless-p", "vfl-headless-abbruch"],
"vfl-view-refresh": ["vfl-headless-p"],
}
@pytest.mark.parametrize("name", sorted(RIEGEL_ERWARTET))
def test_headless_riegel_vorhanden(quelltext, name):
koerper = _funktionskoerper(quelltext, name)
assert koerper, "Funktion %s nicht gefunden" % name
for gesucht in RIEGEL_ERWARTET[name]:
assert gesucht in koerper, (
"%s ruft %s nicht - ohne den Riegel fragt der Wrapper bei leerer "
"Replay-Queue wieder live (siehe doc/variofoerderer.md 8b)"
% (name, gesucht))
def test_headless_symbole_definiert(quelltext):
for gesucht in ("(defun vfl-headless-p ", "(defun vfl-headless-abbruch ",
"(defun vfl-headless-notausgang ",
"(defun vfl-headless-ort ", "(defun vfl-meldung ",
"*vfl-headless-antwort-fn*", "*vfl-meldungen*",
"*vfl-headless-fehler*"):
assert gesucht in quelltext, "%s fehlt" % gesucht
def test_journal_reset_loescht_diagnose(quelltext):
"""Meldungen und Headless-Diagnose gehoeren zum LAUF: bleiben sie stehen,
traegt der naechste Bau die Befunde des vorherigen im Ergebnis."""
koerper = _funktionskoerper(quelltext, "vfl-journal-reset")
assert koerper
assert "*vfl-meldungen*" in koerper
assert "*vfl-headless-fehler*" in koerper
# ============================================================
# 4. Funktionen, die es in AutoLISP nicht gibt
# ============================================================
# Ein Tippfehler oder eine erfundene Funktion faellt erst auf, wenn der Zweig
# tatsaechlich durchlaufen wird - im Testlauf hat genau das (entprev ...)
# gekostet: AutoLISP kennt nur entnext und entlast, ein Rueckwaertslauf durch
# die Zeichnung ist nicht moeglich. Solche Faelle sind hier billig zu fangen.
# Erweitern, sobald wieder eine erfundene Funktion auffliegt.
NICHT_EXISTENT = ("entprev", "entfirst", "ssprev", "vl-string-replace")
LISP_DIRS = ("Lisp", "tests")
def test_keine_nicht_existierenden_funktionen():
wurzel = os.path.join(os.path.dirname(__file__), "..")
treffer = []
for verz in LISP_DIRS:
pfad = os.path.join(wurzel, verz)
if not os.path.isdir(pfad):
continue
for name in sorted(os.listdir(pfad)):
if not name.lower().endswith(".lsp"):
continue
text = io.open(os.path.join(pfad, name), encoding="cp1252").read()
# Gleicher Scanner, andere Trefferliste - er ueberspringt
# Kommentare und Strings, ein erklaerender Kommentartext wie
# "kennt kein entprev" schlaegt also nicht an.
for wo, zaehler in funktions_inventar(text,
NICHT_EXISTENT).items():
for fn in sorted(zaehler):
treffer.append("%s/%s: (%s in %s)" % (verz, name, fn, wo))
assert not treffer, (
"Aufrufe nicht existierender AutoLISP-Funktionen: %s" % treffer)
def test_dispatcher_setzt_journal_zurueck():
"""P0: der Reset muss VOR der Modus-Verzweigung stehen, nicht nur im
Modus-1-Zweig - sonst erbt ein frischer Modus-2-Lauf das Journal des
Vorlaufs und schreibt es in die XDATA."""
if not os.path.isfile(CORE):
pytest.skip("Lisp/vf_core.lsp nicht gefunden")
text = io.open(CORE, encoding="cp1252").read()
cond = text.find("(cond ((= wahl 2) (vf-linienzug-modus2))")
assert cond >= 0, "Dispatcher-cond nicht gefunden"
reset = text.rfind("(vfl-journal-reset)", 0, cond)
assert reset >= 0, "vfl-journal-reset steht nicht vor der Modus-cond"
zweig = text[cond:text.find("(exit)", cond)]
assert "vfl-journal-reset" not in zweig, (
"Reset steht (wieder) IN der cond - dann trifft er nur einen Modus")
+1367 -448
View File
File diff suppressed because it is too large Load Diff
+94 -2
View File
@@ -401,6 +401,36 @@ JSON (Prompt-Protokoll, Auszug):
]
```
Dasselbe Protokoll steht produktionsseitig als **XDATA am fertigen
`VF_n`-Block** (App `SSG_VF_EDIT`, Marker `"linienzug"`, Serialisierung
`vfl-entry->string`): jede Eingabe wird beim Bau journalisiert und ist damit
wiederabspielbar (`vfl-journal-replay-start` + `vf-linienzug-modus`, genutzt
vom Doppelklick-Editieren). Ein Journal aus einer echten Zeichnung laesst
sich darum 1:1 in dieses Schema zuruecklesen — genau das tut
`lib/vf_journal_export.py` (siehe Abschnitt 8, `hundm05.json`). Zwei
Unterschiede zum handgeschriebenen `linienzug_tests.json`:
- Zusaetzlicher Eintragstyp `step` (Glied-Marker aus `vfl-journal-mark`,
Label = gewaehlter Segmenttyp). Beim Replay uebersprungen
(`vfl-replay-pop`), fuer den Sektions-/Glied-Edit aber der Anker.
- `hz` steht nur beim **ersten** Segment der Kette in einem `point_rel`
(`vfl-in-abstand` journalisiert die gesnappte Richtung nur bei freier
Richtungswahl); jedes weitere Segment erbt die Richtung des Vorgaengers
und traegt nur `dL`. Winkel von AS/ES stehen als **Wert** (`"90"`/`"30"`),
Seiten/Optionen als **Menue-Index** (`"1"`/`"2"`).
**Headless-Riegel (Stufe 0, siehe `doc/TODO-plan-vf-interactive.md`)**: der
Replay allein garantierte noch keinen prompt-freien Lauf - lief die Queue
mitten im Bau leer (Datenquelle passt nicht zum Ablauf), fielen die
`vfl-in-*`-Wrapper **stillschweigend** auf Live-Eingabe zurueck und der Lauf
blieb an einem `getpoint` haengen. Mit `*vfl-headless*` = T ist das jetzt ein
harter Abbruch mit Fundstelle (`vfl-headless-abbruch` -> `*vfl-headless-fehler*`
nennt Art der Eingabe, Glied-Nummer und Eingabe-Nummer). Die Meldungen der
geometrisch abgewiesenen Sektionen laufen ueber `vfl-meldung` statt `alert` und
stehen in `*vfl-meldungen*`, blockieren also nichts mehr. `tests/test_hundm05.lsp`
schaltet beides ein; die Diagnose landet im Ergebnis-JSON
(`headless_fehler`, `meldungen`, `prompts`).
### 3.4 Vario_Kette_Merge — kein Neubau
`c:Vario_Kette_Merge`: benoetigt nur **einen** Start-Entity-Pick (`entsel`),
@@ -572,9 +602,9 @@ unbaubar. `lib/dxf_scan_components.py` trennt solche Paare darum ueber
gibt den **Ausgangspunkt** zurueck, nicht die Entity — ein Treiber holt den
Block ueber `(entlast)`. Bei fehlender Blockdatei ruft sie `(exit)` auf und
wuerde einen Testlauf abbrechen, darum vorher mit `ssg-ils-block-laden`
pruefen (so macht es `hundm05:build-gerade` in `tests/test_hundm05.lsp`).
pruefen.
Vollstaendiges JSON (wie in `hundm05.json`):
Vollstaendiges JSON:
```json
{
"test_id": "GERADE_HundM05_08",
@@ -585,6 +615,9 @@ Vollstaendiges JSON (wie in `hundm05.json`):
"endpunkt": [220499.01, 154926.53, 0.0]
}
```
Einen Treiber dafuer gibt es derzeit nicht mehr: `tests/test_hundm05.lsp`
spielt seit dem Umbau auf die Linienzug-Journale nur noch Ketten ab (siehe
Abschnitt 8); der fruehere `hundm05:build-gerade` steht in der Git-Historie.
---
@@ -606,3 +639,62 @@ Vollstaendiges JSON (wie in `hundm05.json`):
| Omniflo TEF/Zubehoer | `omni:insert-dxf` (fester Sivasnr) | Position/Hoehe/Rotation ja, Punkt-Eingabe interaktiv |
| Omniflo TV_*/APBW_* | - | **nein** (nicht implementiert, DUMMY) |
| Separator/Scanner | `ils-insert-sensor` | **nein** (Schleife mit `getpoint`/`pause`) |
## 8. HundM05 — die 5 Linienzuege am Kreisel (`hundm05.json`)
`tests/testdata/hundm05.json` enthaelt die **Eingabe-Journale der 5
VarioFoerderer-Linienzuege** aus `data/polylines.dxf` (die Anlage HundM, ein
Kreisel mit fuenf daran haengenden Ketten). Quelle ist nicht die gezeichnete
Geometrie, sondern die XDATA `SSG_VF_EDIT` (Marker `"linienzug"`) an den
`VF_n`-Bloecken — also genau die Werte, mit denen die Ketten gebaut wurden.
Schema je Eintrag: Kopffelder (`test_id`, `block`, `dxf_handle`,
`startpunkt_mm`, `rotation_grad`, `sivas_id`, `beschreibung`,
`erwartung_hinweis`, `quelle`) plus die Prompt-Sequenz in `eingaben` — Format
wie `linienzug_tests.json`, siehe Abschnitt 3.3 (inkl. `step`-Marker).
| test_id | Block | Sivas | Startpunkt (mm) | Kette |
|---|---|---|---|---|
| VF_hundm05_LZ_01 | VF_87 | 0025 | 4912 / 1231 / 2154 | der komplizierte links: 2x Horizontal-VF, 4 Vario-Kurven (90/90/30/30) |
| VF_hundm05_LZ_02 | VF_46 | 0019 | 8572 / 1276 / 2495 | Standardkette |
| VF_hundm05_LZ_03 | VF_46 | 0018 | 12916 / 1276 / 2495 | Standardkette |
| VF_hundm05_LZ_04 | VF_46 | 0020 | 17202 / 1205 / 2154 | Standardkette (die zuerst gebaute) |
| VF_hundm05_LZ_05 | VF_46 | 0008 | 21490 / 1196 / 2495 | Standardkette |
Die vier `VF_46`-Bloecke sind **Kopien EINER** gebauten Kette: sie referenzieren
dieselbe Blockdefinition und tragen darum Wort fuer Wort dasselbe Journal,
inklusive des Startpunkts der zuerst gebauten Kette (17201.78/1205.36/2100 mit
Starthoehe 2154). Im JSON ist dieser eine absolute Eintrag je Instanz auf ihren
echten Blockeinfuegepunkt gezogen (alle weiteren Eintraege sind relativ), damit
jeder Testfall fuer sich abspielbar ist. Achtung bei den Kopien: ihre
Block-Attribute (`HOEHE_VON_mm` = 2154, so auch im Sivas-Export) stammen aus
dem Original und sind nach dem Verschieben veraltet — die drei Kopien liegen
tatsaechlich 340.93 mm hoeher (belegt durch ihre Separator-Z-Werte im
Sivas-Export: 2575.43 statt 2234.50).
Neu erzeugen:
```
python lib/vf_journal_export.py data/polylines.dxf \
tests/testdata/hundm05.json --csv results/HundM_export.csv
```
Die frueheren Inhalte (Kreisel/Gefaellestrecke/Sensor-Eintraege aus dem
Fenster 215000/148000..250000/161000 von `results/ST500592_05.dxf`, erzeugt mit
`lib/dxf_scan_components.py`) stehen in der Git-Historie dieser Datei.
**Aufbau flach, nicht verschachtelt**: `ssg-load-json`/`ssg-parse-json-array`
(`Lisp/ssg_core.lsp`) liest die Datei ZEILENWEISE und behandelt jedes `{` als
Objektanfang — Verschachtelung (z.B. `"eingaben": [ {...} ]`) wuerde die
Kopf-Objekte verschlucken. Darum steht je Kette ein Kopf-Objekt (traegt
`test_id`) und danach ihre Eingaben (tragen `typ`), wie in
`linienzug_tests.json`. Der Anlagetyp im Kopf heisst `anlagetyp` und NICHT
`typ`, damit Kopf und Eingabe unterscheidbar bleiben.
Abgespielt wird das per `tests/test_hundm05.lsp` (`TEST_HUNDM05`,
`TEST_HUNDM05_2D`/`_3D`): der Treiber gruppiert die Objekte
(`hundm05:gruppiere`), baut daraus das Vorwaerts-Journal
(`hundm05:eintrag->journal`) und spielt es je Kette ueber
`vfl-journal-replay-start` + `vf-linienzug-modus` ab — denselben Weg nimmt die
Produktion beim Editieren/Konvertieren (`vfl-konvertiere-ent`). Bewusst KEINE
Eingabe-Mocks wie in `test_linienzug.lsp`: ein Mock muesste zu jedem Segment
die Fahrtrichtung kennen, die im Journal nur beim ersten Segment steht.