[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:
@@ -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")
|
||||
Reference in New Issue
Block a user