#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ test_vf_spec.py - Validiert den Spec-UEBERSETZER des VF-Linienzugs. Laeuft komplett ohne BricsCAD. 1. Rundlauf: fuer jede der 5 echten HundM-Ketten muss journal_aus_spec(spec_aus_journal(tokens)) == tokens gelten. Das beweist, dass die Spec das Eingabe-Journal VERLUSTFREI abbildet - sonst waere ein spec-gebauter Nachbau eine andere Kette als das Original. 2. Die Spec-Testdaten (tests/testdata/hundm05.json): Struktur, Lesbarkeit durch den LISP-Parser, und dass sie dieselben Journale ergeben wie die Aufzeichnung tests/testdata/hm_recformat.json. 3. Validierung: was die Spec nicht hergibt, muss ein Fehler sein. 4. Die Erwartungswerte in tests/test_vf_spec.lsp stimmen mit dem hier geprueften Emitter ueberein. Die GEBAUTE Geometrie prueft tests/test_hundm05.py (braucht BricsCAD). Der LISP-Emitter (vsp-journal in Lisp/vf_spec.lsp) ist die zweite Implementierung derselben Grammatik; er wird in tests/test_vf_spec.lsp gegen dieselben erwarteten Tokenfolgen geprueft. """ import json import os import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) import vf_spec_export as vse # noqa: E402 HIER = os.path.dirname(__file__) # Die AUFZEICHNUNG (rohes Journal aus der Kundenzeichnung, Frage-Antwort- # Protokoll) und die daraus abgeleitete SPEC. Erstere ist die Vorlage, an # der die zweite gemessen wird. PROTOKOLL = os.path.join(HIER, "testdata", "hm_recformat.json") SPEC_JSON = os.path.join(HIER, "testdata", "hundm05.json") def _lade(pfad, was): if not os.path.isfile(pfad): pytest.skip("%s fehlt (%s)" % (pfad, was)) with open(pfad, encoding="utf-8") as fh: return json.load(fh) @pytest.fixture(scope="module") def protokoll(): return _lade(PROTOKOLL, "lib/vf_journal_export.py erzeugt es") @pytest.fixture(scope="module") def spec_objekte(): return _lade(SPEC_JSON, "lib/vf_spec_export.py erzeugt es") def _ketten(protokoll): """Gleiche Gruppierregel wie hmrec:gruppiere in LISP.""" ketten = [] for obj in protokoll: if "test_id" in obj: ketten.append((obj, [])) elif "typ" in obj and ketten: ketten[-1][1].append(obj) return ketten # ============================================================ # 1. Rundlauf Journal -> Spec -> Journal # ============================================================ class TestRundlauf: def test_fuenf_ketten(self, protokoll): assert len(_ketten(protokoll)) == 5 def test_verlustfrei(self, protokoll): """Der eigentliche Beweis: die Spec verliert nichts. Weicht hier eine Kette ab, wuerde ein spec-gebauter Nachbau andere Geometrie erzeugen als die aufgezeichnete Vorlage.""" for kopf, eintraege in _ketten(protokoll): tok = vse.tokens_aus_protokoll(eintraege) spec = vse.spec_aus_journal(tok) zurueck = vse.journal_aus_spec(spec) idx, was = vse.erste_abweichung(tok, zurueck) assert vse.gleich(tok, zurueck), \ f'{kopf["test_id"]}: Abweichung bei Token {idx} - {was}' def test_sektionen_plausibel(self, protokoll): """Jede Kette beginnt mit einem Glied, das ohne Frame waehlbar ist, und endet mit dem ES-Glied (alle 5 HundM-Ketten haben ein ES).""" for kopf, eintraege in _ketten(protokoll): spec = vse.spec_aus_journal( vse.tokens_aus_protokoll(eintraege)) sek = spec["sektionen"] assert sek, f'{kopf["test_id"]}: keine Sektionen' assert sek[0]["glied"] in vse.MENU_START, \ f'{kopf["test_id"]}: erstes Glied {sek[0]["glied"]!r} ist ' \ f'ohne Frame nicht waehlbar' assert sek[-1]["glied"] == "ES", \ f'{kopf["test_id"]}: letztes Glied ist {sek[-1]["glied"]!r}' def test_hz_nur_beim_ersten_segment(self, protokoll): """vfl-in-abstand journalisiert die Fahrtrichtung nur beim ersten Segment der Kette; jedes weitere erbt sie vom Vorgaenger.""" for kopf, eintraege in _ketten(protokoll): spec = vse.spec_aus_journal( vse.tokens_aus_protokoll(eintraege)) mit_hz = [] for i, sek in enumerate(spec["sektionen"]): if "hz" in sek: mit_hz.append(("sektion", i)) for j, sub in enumerate((sek.get("vf") or {}).get("subs", [])): if "hz" in sub: mit_hz.append(("sub", i, j)) assert mit_hz == [("sektion", 0)], \ f'{kopf["test_id"]}: hz an {mit_hz}, erwartet nur in der ' \ f'ersten Sektion' def test_vf_einheiten_terminiert(self, protokoll): """Eine VF-Einheit muss mit motorstation oder kettenende enden - sonst laesst sich die Fortsetzungsschleife nicht schliessen.""" for kopf, eintraege in _ketten(protokoll): spec = vse.spec_aus_journal( vse.tokens_aus_protokoll(eintraege)) for sek in spec["sektionen"]: vf = sek.get("vf") if not vf: continue subs = vf["subs"] assert subs, f'{kopf["test_id"]}: VF-Einheit ohne subs' assert subs[-1]["sub"] in ("motorstation", "kettenende"), \ f'{kopf["test_id"]}: VF-Einheit endet mit ' \ f'{subs[-1]["sub"]!r}' for sub in subs[:-1]: assert sub["sub"] not in ("motorstation", "kettenende") # ============================================================ # 2. Die Spec-Testdaten # ============================================================ class TestSpecDaten: def test_fuenf_specs(self, spec_objekte): specs = vse.spec_aus_flachen_objekten(spec_objekte) assert len(specs) == 5 def test_gleiche_journale_wie_protokoll(self, protokoll, spec_objekte): """Der Weg, den LISP nimmt: flaches JSON -> Spec -> Journal. Er muss dieselben Journale liefern wie das aufgezeichnete Protokoll.""" specs = vse.spec_aus_flachen_objekten(spec_objekte) ketten = _ketten(protokoll) assert len(specs) == len(ketten) for (kopf, eintraege), (skopf, spec) in zip(ketten, specs): tok = vse.tokens_aus_protokoll(eintraege) neu = vse.journal_aus_spec(spec) idx, was = vse.erste_abweichung(tok, neu) assert vse.gleich(tok, neu), \ f'{skopf["spec_id"]} vs {kopf["test_id"]}: Abweichung bei ' \ f'Token {idx} - {was}' def test_objekte_zuordenbar(self, spec_objekte): """Jedes Objekt ist Kopf, Sektion, Sub - oder ein von Hand ergaenztes Anlagenteil (erkennbar an "function", z.B. ein Kreisel). Etwas anderes kennt weder vsp-gruppieren noch der Generator.""" for i, obj in enumerate(spec_objekte): assert ("spec_id" in obj) or ("glied" in obj) or ("sub" in obj) \ or ("function" in obj), \ f"Objekt {i} ist weder Kette noch Anlagenteil: {obj}" def test_fremde_objekte_stoeren_die_ketten_nicht(self, spec_objekte): """Die von Hand ergaenzten Anlagenteile duerfen die Ketten-Zerlegung nicht beeinflussen - auch nicht, wenn sie mitten zwischen den Sektionen stehen.""" fremde = [o for o in spec_objekte if "function" in o] ohne = [o for o in spec_objekte if "function" not in o] assert (len(vse.spec_aus_flachen_objekten(spec_objekte)) == len(vse.spec_aus_flachen_objekten(ohne))), \ f"{len(fremde)} Zusatzobjekt(e) veraendern die Kettenzahl" def test_kopf_felder(self, spec_objekte): for obj in spec_objekte: if "spec_id" not in obj: continue for feld in ("modus", "start_punkt", "start_hoehe", "as"): assert feld in obj, f'{obj["spec_id"]}: {feld} fehlt' assert len(obj["start_punkt"]) == 3 assert obj["as"] in (0, 1), "Wahrheitswerte als 1/0 (der " \ "LISP-Parser kennt kein true/false)" if obj["as"]: assert obj["as_winkel"] in ("30", "90") assert obj["as_seite"] in ("links", "rechts") def test_glied_und_sub_werte_bekannt(self, spec_objekte): for obj in spec_objekte: if "glied" in obj: assert obj["glied"] in vse.GLIEDER, \ f'unbekanntes Glied {obj["glied"]!r}' elif "sub" in obj: erlaubt = set(vse.IM_VF.values()) | {"motorstation", "kettenende"} assert obj["sub"] in erlaubt, \ f'unbekannter sub {obj["sub"]!r}' def test_domaenenwerte_statt_codes(self, spec_objekte): """Die Spec soll lesbar sein: Seiten/Varianten als Wort, nicht als Menue-Index. Rohe Codes wuerde der Emitter zwar durchreichen, in den erzeugten Testdaten haben sie aber nichts zu suchen.""" for obj in spec_objekte: for feld, erlaubt in (("seite", ("links", "rechts")), ("variante", ("aussen", "innen")), ("gefaelle", ("hoehe", "winkel")), ("gf_verteilung", ("haelfte", "einlauf"))): if feld in obj: assert obj[feld] in erlaubt, \ f'{feld}={obj[feld]!r} ist kein Domaenenwert' def test_arrays_einzeilig(self): """ssg-cfg-parse-array kann keine umgebrochenen Zahlen-Arrays lesen - start_punkt MUSS in einer Zeile stehen.""" if not os.path.isfile(SPEC_JSON): pytest.skip("Spec-JSON fehlt") with open(SPEC_JSON, encoding="utf-8") as fh: for nr, zeile in enumerate(fh, 1): offen = zeile.count("[") - zeile.count("]") assert offen == 0 or zeile.strip() in ("[", "]"), \ f"Zeile {nr} hat ein unvollstaendiges Array: " \ f"{zeile.rstrip()}" def test_flach_fuer_lisp_parser(self, spec_objekte): """ssg-parse-json-array liest zeilenweise: ein "{" beginnt ein Objekt, Verschachtelung ist unmoeglich. Gilt auch fuer die von Hand ergaenzten Anlagenteile.""" for obj in spec_objekte: for k, v in obj.items(): assert not isinstance(v, dict), \ f"{k} ist verschachtelt - der LISP-Parser kann das nicht" if isinstance(v, list): assert all(isinstance(x, (int, float)) for x in v), \ f"{k} darf nur Zahlen enthalten" # ============================================================ # 3. Validierung: die Spec darf nichts raten # ============================================================ class TestValidierung: def _spec(self, spec_objekte, nr=0): return json.loads(json.dumps( vse.spec_aus_flachen_objekten(spec_objekte)[nr][1])) def test_hz_in_spaeterer_sektion_ist_fehler(self, spec_objekte): spec = self._spec(spec_objekte) spec["sektionen"][1]["hz"] = 90.0 with pytest.raises(ValueError, match="hz"): vse.journal_aus_spec(spec) def test_fehlendes_hz_ist_fehler(self, spec_objekte): spec = self._spec(spec_objekte) del spec["sektionen"][0]["hz"] with pytest.raises(ValueError, match="hz"): vse.journal_aus_spec(spec) def test_gf_bogen_als_erste_sektion_ist_fehler(self, spec_objekte): """Die erste Sektion laeuft ohne Frame - ein GF-Bogen wird dort nicht angeboten (es gibt noch keine Richtung, an die er anschliesst).""" spec = self._spec(spec_objekte) spec["sektionen"].insert(0, {"glied": "GF-Bogen", "winkel": 90, "seite": "links"}) with pytest.raises(ValueError, match="nicht waehlbar"): vse.journal_aus_spec(spec) def test_unbekannter_domaenenwert_ist_fehler(self, spec_objekte): spec = self._spec(spec_objekte) spec["sektionen"][-1]["seite"] = "mitte" with pytest.raises(ValueError, match="seite"): vse.journal_aus_spec(spec) def test_unterminierte_vf_einheit_ist_fehler(self, spec_objekte): spec = self._spec(spec_objekte) for sek in spec["sektionen"]: if sek.get("vf"): sek["vf"]["subs"] = sek["vf"]["subs"][:-1] break with pytest.raises(ValueError, match="motorstation|kettenende"): vse.journal_aus_spec(spec) def test_fehlendes_pflichtfeld_ist_fehler(self, spec_objekte): spec = self._spec(spec_objekte) del spec["start"]["hoehe"] with pytest.raises(ValueError, match="hoehe"): vse.journal_aus_spec(spec) # ============================================================ # 4. Die erwarteten Tokenfolgen in tests/test_vf_spec.lsp # ============================================================ # tests/test_vf_spec.lsp prueft den LISP-Emitter gegen von Hand # eingetragene Tokenfolgen. Diese Folgen sind mit dem hier geprueften # Python-Emitter erzeugt worden - und genau das haelt dieser Test fest: # eine Uebertragungsfehler im LISP-Test wuerde sonst einen falschen # Erwartungswert festschreiben, und der CAD-Lauf waere gruen, obwohl der # Emitter falsch liegt. # # Die Specs stehen dafuer zweimal da (hier und im LISP-Test). Das ist # gewollt: nur zwei unabhaengige Niederschriften koennen einander pruefen. LISP_TEST = os.path.join(HIER, "test_vf_spec.lsp") _PRE_MIT_AS = [("PT", None), ("REAL", 3000.0), ("STR", "1"), ("STR", "90"), ("STR", "1")] _PRE_OHNE_AS = [("PT", None), ("REAL", 3000.0), ("STR", "2")] def _start(as_=True): s = {"punkt": [1000.0, 2000.0, 3000.0], "hoehe": 3000.0, "as": as_} if as_: s["as_winkel"] = "90" s["as_seite"] = "links" return s def _es(winkel="90", seite="rechts"): return {"glied": "ES", "winkel": winkel, "seite": seite} # Name im LISP-Test (Anfang genuegt) -> Spec LISP_FAELLE = { "Linie-GF + ES": { "start": _start(), "sektionen": [ {"glied": "Linie-GF", "dl": 5500.0, "hz": 0.0, "gefaelle": "winkel", "winkel": 3.0, "ende": "ja-mit-es"}, _es()]}, "Horizontal-VF + Motorstation + ES": { "start": _start(), "sektionen": [ {"glied": "Horizontal-VF", "dl": 8000.0, "hz": 90.0, "vf": {"gf_verteilung": "einlauf", "sep_vor": False, "sep_nach": False, "ende": "ja-mit-es", "subs": [{"sub": "motorstation"}]}}, _es()]}, "Linie-VF + Vario-Kurve": { "start": _start(as_=False), "sektionen": [ {"glied": "Linie-VF", "dl": 12000.0, "hz": 0.0, "hoehe": 2000.0, "winkel_idx": 2, "vf": {"gf_verteilung": "haelfte", "ende": "nein", "separator": True, "subs": [{"sub": "vario-kurve", "winkel": 90, "seite": "links", "variante": "aussen"}, {"sub": "motorstation"}]}}]}, "GF-Bogen als zweite Sektion": { "start": _start(), "sektionen": [ {"glied": "Linie-GF", "dl": 3000.0, "hz": 180.0, "gefaelle": "hoehe", "hoehe": 2500.0, "ende": "nein"}, {"glied": "GF-Bogen", "winkel": 60, "seite": "rechts"}]}, "Linie -> GF + ES": { "start": _start(), "sektionen": [ {"glied": "Linie", "dl": 4000.0, "hz": 270.0, "hoehe": 2000.0, "segment_typ": "GF"}, _es("30", "links")]}, "Linie -> VF (auto-Ende": { "start": _start(), "sektionen": [ {"glied": "Linie", "dl": 9000.0, "hz": 0.0, "hoehe": 1500.0, "segment_typ": "VF", "vf": {"gf_verteilung": "einlauf", "ende": "automatisch", "subs": [{"sub": "auf-ab", "dl": 3000.0, "hoehe": 1200.0}, {"sub": "motorstation"}]}}, _es()]}, "Kettenende am Zielpunkt ohne ES": { "start": _start(), "sektionen": [ {"glied": "Horizontal-VF", "dl": 7000.0, "hz": 0.0, "vf": {"gf_verteilung": "einlauf", "sep_vor": True, "sep_nach": True, "ende": "zielpunkt-ohne-es", "subs": [{"sub": "kettenende", "es": False, "dl": 2500.0, "hoehe": 1000.0}]}}]}, } def _lisp_erwartungen(): """Die Erwartungswerte aus den tsp-journal-eq-Aufrufen des LISP-Tests lesen: {Testname: [(Art, Wert), ...]}.""" import re if not os.path.isfile(LISP_TEST): pytest.skip("tests/test_vf_spec.lsp fehlt") with open(LISP_TEST, encoding="cp1252") as fh: text = fh.read() # LISP-Kommentare weg (die Erwartungslisten sind kommentiert) text = re.sub(r";[^\n]*", "", text) raus = {} for treffer in re.finditer(r'\(tsp-journal-eq\s+"([^"]+)"(.*?)' r'\(vsp-journal spec\)\)', text, re.S): name, block = treffer.group(1), treffer.group(2) tok = [] if "tsp-pre-mit-as" in block: tok.extend(_PRE_MIT_AS) elif "tsp-pre-ohne-as" in block: tok.extend(_PRE_OHNE_AS) for art, wert in re.findall(r'\(cons\s+"([A-Z]+)"\s+' r'("[^"]*"|-?[\d.]+)\)', block): if wert.startswith('"'): tok.append((art, wert.strip('"'))) else: tok.append((art, float(wert) if "." in wert else int(wert))) raus[name] = tok return raus def _norm(tok): """Python-Tokens (Art, Text) in die Vergleichsform bringen: PT-Werte interessieren hier nicht (sie stehen im LISP-Test in der Praeambel- Hilfsfunktion), Zahlen numerisch.""" raus = [] for art, wert in tok: if art == "PT": raus.append(("PT", None)) elif art in ("REAL", "DL"): raus.append((art, float(wert))) elif art == "INT": raus.append((art, int(wert))) else: raus.append((art, wert)) return raus class TestLispErwartungen: def test_alle_faelle_gefunden(self): erwartungen = _lisp_erwartungen() assert len(erwartungen) == 7, \ f"7 tsp-journal-eq-Faelle erwartet, {len(erwartungen)} gefunden: " \ f"{sorted(erwartungen)}" @pytest.mark.parametrize("praefix", sorted(LISP_FAELLE)) def test_lisp_erwartung_stimmt(self, praefix): erwartungen = _lisp_erwartungen() passend = [n for n in erwartungen if n.startswith(praefix)] assert len(passend) == 1, \ f"{praefix!r} passt auf {passend} - Testname im LISP-Test " \ f"geaendert?" soll = _norm(vse.journal_aus_spec(LISP_FAELLE[praefix])) ist = erwartungen[passend[0]] assert ist == soll, ( f"Die Erwartung im LISP-Test weicht vom Python-Emitter ab.\n" f" LISP: {ist}\n Python: {soll}\n" f"Entweder ist die Tokenfolge im LISP-Test falsch uebertragen, " f"oder die Spec hier und dort beschreibt nicht dasselbe.")