#!/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 "". gesucht = die Aufrufnamen, die gezaehlt werden.""" inventar = collections.defaultdict(collections.Counter) tiefe = 0 aktuell = "" 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 = ("" 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 = "" 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")