Files
dxfmakros/tests/test_vf_headless_statisch.py
T
s.ayadi 428f1ced70 [FEAT] VF-Linienzug aus Eingabedaten bauen (Stufe 1: Spec -> Journal -> Replay)
Stufe 1 des Fahrplans in doc/TODO-plan-vf-interactive.md, fertig fuer
Modus 1. Eine SPEC beschreibt eine Kette in Domaenenwerten ("links",
"winkel", "aussen", ja/nein) statt in Menue-Codes; vsp-journal uebersetzt sie
in ein Eingabe-Journal, vsp-bau-aus-spec spielt es per
vfl-journal-replay-start durch den UNVERAENDERTEN vf-linienzug-modus. Der
fertige Block traegt damit dasselbe SSG_VF_EDIT-Journal wie eine handgebaute
Kette und bleibt per Doppelklick editier- und 2D/3D-konvertierbar - kein
Journal-Konsument muss etwas von Specs wissen.

CAD-Lauf VF_SPEC_BAU: 5 OK, 0 Fehler. Je Kette status "executed",
prompts 0 (keine Live-Eingabe erreicht), eingaben_offen 0, keine
Spec-Fehler, keine Bau-Meldungen, glieder_ist == glieder_soll,
Einfuegepunkt gleich dem Kettenstart der Spec.

Neu:
- Lisp/vf_spec.lsp: Code-Tabellen, Emitter vsp-journal, Validierung
  vsp-pruefen, flacher JSON-Loader, Headless-Rahmen vsp-headless-an/-aus,
  Ergebnis-Record, vsp-bau-aus-spec/-liste, c:VF_SPEC_BAU. Von vf_core.lsp
  NACH vf_linienzug.lsp geladen - damit sind beide Ladewege abgedeckt (Menue
  und der .scr-Loader), das MNL bleibt unveraendert.
- lib/vf_spec_export.py: derselbe Emitter in Python PLUS die Umkehrung
  (spec_aus_journal), erzeugt die Spec-Testdaten.
- tests/testdata/vf_spec_hundm05.json: die 5 echten Ketten als Spec,
  erzeugt statt handgeschrieben.
- tests/test_vf_spec.lsp (C:TEST_VF_SPEC, in alltests.json): 7 Faelle je
  Glied-Typ, 7 Validierungsfaelle, Gruppieren, und die 5 echten Ketten gegen
  ihr aufgezeichnetes Journal.
- tests/test_vf_spec.py: 36 Tests (Rundlauf, Spec-Daten, Validierung,
  Erwartungswert-Abgleich, Ergebnisse von VF_SPEC_BAU).

Drei Regeln, die der Uebersetzer besitzen muss - jede wuerde sonst die ganze
Replay-Queue verschieben:
- Der Menue-Code haengt am Frame: erste Sektion 4 Optionen (kein GF-Bogen,
  es gibt noch keine Richtung), jede spaetere 5.
- hz steht genau EINMAL im Journal, beim ersten Segment (vfl-in-abstand
  journalisiert die Richtung nur bei freier Richtungswahl).
- Nur ein HORIZONTALER Erstkoerper einer VF-Einheit stellt die Separator-/
  Endpunktfragen vorab (winkel1=0 laeuft durch vfl-baue-horizontal-koerper);
  ein gewinkelter wird ohne jede Frage gebaut.

Wie die Richtigkeit belegt ist - drei Implementierungen derselben Grammatik,
die einander pruefen:
- Rundlauf gegen echte Daten: journal_aus_spec(spec_aus_journal(tok)) == tok
  fuer alle 5 HundM-Ketten, tokenweise numerisch verglichen. Damit ist
  bewiesen, dass die Spec das Journal verlustfrei abbildet.
- Der Weg, den LISP nimmt (flaches JSON -> Spec -> Journal), ergibt separat
  geprueft dieselben Journale wie das aufgezeichnete Protokoll.
- Der unabhaengige Dekoder aus lib/vf_journal_export.py verdaut alle 7
  synthetischen Emitter-Ausgaben restlos, auch die zwei Zweige, die in den
  echten Daten fehlen (Linie-VF, gewinkelter Erstkoerper).
- Die Erwartungswerte im LISP-Test kommen aus dem geprueften Python-Emitter;
  TestLispErwartungen liest sie zurueck und vergleicht erneut. Ohne das
  koennte ein Uebertragungsfehler einen falschen Erwartungswert
  festschreiben und der CAD-Lauf waere gruen, obwohl der Emitter falsch
  liegt.

Abweichungen von der Planung (Begruendungen in doc/TODO-plan-vf-interactive.md
Abschnitt 4.6): vsp-pruefen ruft den Emitter statt eine zweite Regelmenge zu
pflegen; das vollstaendige Journal steht nicht im Record (statt dessen
glieder_soll/glieder_ist als Desync-Detektor); Zusatzfeld segment_typ am
Glied "Linie", weil dort vfl-segment-entscheidung selbst zwischen GF und VF
waehlt; vf_ende-Werte "automatisch"/"zielpunkt-ohne-es" fuer die Faelle ohne
Frage; Praefix vsp- statt vfs- (das gehoert vf_standard.lsp).

Zusaetzlich:
- tests/test_vf_headless_statisch.py prueft jetzt auch, dass
  Lisp/vf_spec.lsp UEBERHAUPT keine Eingabe-/Dialogfunktion aufruft - beim
  Daten-Pfad ist die Null die Vorgabe.
- tests/testdata/mubea.json: die drei einzeln eingefuegten S-LP-Separatoren
  entfernt (Fortsetzung von 699744e - die Separatoren stecken jetzt in den
  Staustreckenbloecken).

Noch offen: die Uebersetzer fuer Modus 2 und 3. Modus 2 braucht laut
Fahrplan erst 2-3 echte "linienzug2"-Journale als Referenz -
tests/testdata/hundm05.json enthaelt ausschliesslich Modus-1-Ketten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:44:38 +02:00

342 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
# ============================================================
# 3b. vf_spec.lsp fragt ueberhaupt nichts
# ============================================================
# Das Spec-Modul ist der Daten-Pfad: es baut aus einer Datei und darf nie
# einen Prompt oder Dialog oeffnen. Anders als bei vf_linienzug.lsp (das den
# interaktiven Pfad ja weiter braucht) ist hier die Null die Vorgabe. Seine
# Stubs SETZEN getstring & Co. nur - sie rufen sie nicht auf, der Scanner
# zaehlt also korrekt null Treffer.
SPEC = os.path.join(os.path.dirname(__file__), "..", "Lisp", "vf_spec.lsp")
def test_vf_spec_ohne_eingaben():
if not os.path.isfile(SPEC):
pytest.skip("Lisp/vf_spec.lsp nicht gefunden")
inv = funktions_inventar(io.open(SPEC, encoding="cp1252").read())
treffer = dict((name, dict(z)) for name, z in inv.items() if z)
assert not treffer, (
"Lisp/vf_spec.lsp ruft Eingabe-/Dialogfunktionen auf: %s. Der "
"Daten-Pfad baut aus einer Datei und darf nichts fragen." % treffer)
# ============================================================
# 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")