Files
dxfmakros/tests/test_vf_headless_statisch.py
T
Michael StanglandClaude Opus 4.8 3cce1ec457 VF-Linienzug Modus 3 editierbar: Segment-Essenz statt Objekt-Handles
Modus 3 (Vorwaerts-Nachbau) hat jetzt dieselbe XDATA-Editierbarkeit +
Doppelklick-Neubau wie Modus 1/2. Statt der Pfad-Objekt-Handles wird die
abgeleitete Segment-ESSENZ journalisiert (neuer Journal-Typ SEGS): der VF_n-
Block ist damit selbst-enthalten, der Neubau unabhaengig davon, ob die
urspruenglichen LINE/ARC noch existieren - robuster als der OBJS-Weg von Modus 2.

Journal-Kern (vf_linienzug.lsp):
- Neuer Typ SEGS: Segmentliste (typ hz laenge bwinkel bseite spx spy epx epy)
  serialisiert als ";"-getrennte Segmente, Felder per "|". Eingebunden in
  vfl-entry->string / vfl-string->entry / vfl-entry->json.
- vfl-in-segmente: Live (ssget -> gf-sortiere-objekte -> gf-analysiere-kette ->
  Eck-Vorpruefung -> 9-Feld-Essenz) ODER Replay (Essenz direkt aus SEGS-Journal).
  Ersetzt vfl-in-selection im Modus-3-Ablauf.
- vfl-kette->segmente9 fuehrt Bau-Segmente + Start/Endpunkte zusammen; Soll-Ende
  kommt jetzt aus der Essenz statt aus den Original-Entities.

Modus-3-Umbau:
- Abbruch-Wickler + vfl-journal-xdata-schreiben mit Marker "linienzug3" (analog
  Modus 2). Kopf-Kommentar/Fehler-Handler-Variablen an Modus 2 angeglichen.
- Kein Live-Segment-Highlight mehr (Essenz-Bau reicht keine Entity mehr durch) -
  bewusst, im Code dokumentiert.

Doppelklick/Batch:
- vfl-edit-ent2 zu vfl-edit-ent-voll parametrisiert; vfl-edit-ent3 ruft
  vf-linienzug-modus3. vfl-edit-ent / vfl-konvertiere-ent / vfl-journal-xdata-
  lesen dispatchen "linienzug3".

Tests:
- 8 file-freie SEGS-Roundtrip-Unit-Tests (tests/test_unit.lsp, tu-tests-vfl-segs);
  234/234 LISP-Unit-Tests gruen in BricsCAD.
- vfl-in-segmente im statischen Headless-Waechter registriert.
- Verifiziert an einer echten linienzug3-Kette (results/test.dwg): SEGS-Journal
  korrekt geschrieben, Doppelklick-Neubau geometrisch deckungsgleich zum Original.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-18 15:55:47 +02:00

343 lines
15 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-in-segmente": {"ssget": 1}, # Modus-3-Wrapper: Live-Auswahl -> Segment-Essenz (SEGS), Replay aus Journal
"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")