rechnungstool/pruef_api.py
TheMockTv 2b09d15cd0 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes
  Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende
  nicht sagen, WAS fehlschlug.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und
  einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten
  config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler
  neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die
  24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade
  zerstoert wurde, und eine Meldung waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00

167 lines
6.5 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: das Fenster ueber die API bedienen - mit Schluessel.
Warum es das gibt (seine Ansage): *"bau dir steuer logik per api rein, dann
kannst damit die sachen triggern, brauchst meine maus nicht"* und *"mach pw
rein, also schluessel wie bei ssh key, das nur du und das beherbergungssteuer
ding rein kann, weil muss ja sprechen"*.
Geprueft wird:
1. Ohne Schluessel geht NICHTS - auch nicht "nur nachsehen".
2. Mit falschem Schluessel ebenfalls nicht.
3. Mit dem Schluessel aus dem gemeinsamen Ordner laesst sich das Fenster
fuellen, nachlesen und eine Vorschau erzeugen.
4. Was einen BELEG schreibt, geht nur im Testlauf - und schreibt dann
tatsaechlich die Rechnung.
5. ping und schau_nach bleiben schluessellos, sonst kann ein aelteres
Steuerjournal nicht mehr anklopfen.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="api_pruef_")
ordner = os.path.join(tmp, "rechnungen")
gemeinsam_ordner = os.path.join(tmp, "install")
os.makedirs(ordner)
os.makedirs(gemeinsam_ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
roh["gemeinsam_ordner"] = gemeinsam_ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
os.environ["CAMPINGHOF_TEST"] = "1"
import api # noqa: E402
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True
f = A.RechnungsApp()
f.cfg["output_dir"] = ordner
f.buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
f.api.starten(f.buch, ordner=gemeinsam_ordner)
f.update()
port = f.api.port
pruefe(bool(port), f"die API laeuft ({port})")
schluessel = api.schluessel_holen(gemeinsam_ordner)
pruefe(len(schluessel) == 64, f"ein Schluessel liegt im gemeinsamen Ordner ({len(schluessel)} Zeichen)")
pruefe(os.path.exists(os.path.join(gemeinsam_ordner, api.SCHLUESSEL_DATEI)),
"und zwar als Datei neben dem Nummernbuch")
pruefe(api.schluessel_holen(gemeinsam_ordner) == schluessel,
"beim zweiten Lesen kommt derselbe zurueck (kein neuer je Start)")
def rufen(**nachricht):
"""Direkt anrufen - der Pruefstand spielt hier das andere Programm."""
was = nachricht.pop("was", "aktion")
return api._reden(port, was, zeitgrenze=20.0, **nachricht)
# Das Fenster muss waehrend des Anrufs weiterlaufen: die Aktion wird in seinem
# Faden ausgefuehrt. Also nebenher anrufen und die Ereignisschleife drehen.
def anrufen_und_drehen(**nachricht):
ergebnis = {}
def anruf():
ergebnis["antwort"] = rufen(**nachricht)
t = threading.Thread(target=anruf, daemon=True)
t.start()
while t.is_alive():
f.update()
return ergebnis.get("antwort")
# ---- 1. + 2. Ohne (oder mit falschem) Schluessel: kein Zutritt -------------
pruefe(anrufen_und_drehen(name="zustand") == "kein zutritt",
"ohne Schluessel: kein Zutritt")
pruefe(anrufen_und_drehen(name="zustand", schluessel="0" * 64) == "kein zutritt",
"falscher Schluessel: kein Zutritt")
# ---- 5. Die alten zwei Woerter bleiben schluessellos -----------------------
pruefe(anrufen_und_drehen(was="ping") == api.ANTWORT_HALLO,
"ping antwortet weiterhin ohne Schluessel")
# ---- 3. Mit Schluessel: fuellen, nachlesen, Vorschau -----------------------
antwort = anrufen_und_drehen(
name="setze", schluessel=schluessel,
daten={"kunde": {"vorname": "Anna", "nachname": "Beispiel",
"strasse": "Musterweg 1", "plzort": "12345 Musterstadt"},
"anreise": "01.09.2026", "abreise": "04.09.2026",
"positionen": [{"nr": sorted(f.anzahl_vars)[0], "anzahl": 3}]})
pruefe(isinstance(antwort, dict) and antwort.get("kunde", {}).get("nachname") == "Beispiel",
f"mit Schluessel laesst sich das Formular fuellen ({str(antwort)[:60]}…)")
pruefe(antwort.get("gesamt", 0) > 0, f"und es ist gerechnet ({antwort.get('gesamt')})")
antwort = anrufen_und_drehen(name="vorschau", schluessel=schluessel)
pfad = (antwort or {}).get("pfad", "")
pruefe(bool(pfad) and os.path.exists(pfad), f"die Vorschau entsteht ohne Maus ({os.path.basename(pfad)})")
pruefe(not os.listdir(ordner), "und sie landet NICHT im Rechnungsordner")
# ---- 4. Beleg schreiben ---------------------------------------------------
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, ohne den Pruefschalter.
# Umgeschaltet wird die Umgebungsvariable selbst - das Programm fragt sie bei
# jeder Aktion neu ab, genau wie beim echten Start.
os.environ.pop("CAMPINGHOF_TEST", None)
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
os.environ["CAMPINGHOF_TEST"] = "1"
pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
f"im echten Betrieb verweigert sie den Beleg ({antwort})")
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
"und es kam keine zweite Rechnung dazu")
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)
pruefe("kenne ich nicht" in str(antwort), f"unbekannte Aktion wird benannt ({str(antwort)[:50]}…)")
f.api.stoppen(f.buch)
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
print(f"(Ordner bleibt: {tmp})")
sys.exit(1)
shutil.rmtree(tmp, ignore_errors=True)
print("ALLES GRUEN")