ba01455d52
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>
320 lines
14 KiB
Python
320 lines
14 KiB
Python
#!/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")
|