Files
dxfmakros/tests/test_vf_headless_statisch.py
T
s.ayadi ba01455d52 [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>
2026-09-03 10:14:31 +02:00

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")