Steuerrechnungstool/pruef_darstellung.py
TheMockTv 96671027c8 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_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* 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.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die 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

256 lines
8.5 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: hell, dunkel, automatisch - und nichts geht dabei verloren.
Sein Wortlaut vom 07.09.2026:
"waere nun noch gut das mann in den einstellungen einstellen kann hell
oder dunkel oder automatik das fehlt sollte eigendlich drin sein"
"und beim steuertool fehlt das theam in genze oder wird nicht durch die
automatik gesetzt"
Er hatte recht: dieses Programm hatte ueberhaupt kein Theme - es lief im
Tk-Standard, mit dreimal fest eingetragenem foreground="black".
GEPRUEFT WIRD DREIERLEI
1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt
Windows.
2. Beim Umschalten bleibt der Stand stehen - Jahr und Suchfeld.
3. NACH dem Wechsel traegt kein Widget mehr eine Farbe der anderen Palette.
Das ist die eigentliche Falle beim Umfaerben: ein einzelnes vergessenes
Label leuchtet hell im dunklen Fenster, und niemand findet die Stelle
wieder. Deshalb wird der ganze Widget-Baum abgegangen und mit den Farben
der jeweils ANDEREN Palette verglichen.
"""
import os
import shutil
import sys
import tempfile
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
from tkinter import messagebox # noqa: E402
messagebox.askyesno = lambda *a, **k: True
for _n in ("showinfo", "showwarning", "showerror"):
setattr(messagebox, _n, lambda *a, **k: None)
# Auf einer KOPIE des Programms arbeiten: der Datenordner liegt aus der Quelle
# heraus neben app.py (ablage.py), also legt eine Kopie im Temp-Ordner auch ein
# eigenes Journal an. Das echte Journal des Betriebs wird nicht angefasst -
# hier wird eine Einstellung geschrieben, und die gehoert nicht in seine Daten.
tmp = tempfile.mkdtemp(prefix="darstellung_")
prog = os.path.join(tmp, "programm")
os.makedirs(prog)
for name in os.listdir(QUELLE):
if name.endswith(".py"):
shutil.copy(os.path.join(QUELLE, name), os.path.join(prog, name))
for name in ("assets",):
if os.path.isdir(os.path.join(QUELLE, name)):
shutil.copytree(os.path.join(QUELLE, name), os.path.join(prog, name))
sys.path.insert(0, prog)
os.chdir(prog)
import app as A # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.App()
def pumpen(sekunden=1.0):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(2.0)
# ---- Ohne Einstellung: automatisch --------------------------------------
pruefe(theme.modus(f.cfg) == "auto",
f"ohne Eintrag gilt 'auto' ({theme.modus(f.cfg)!r})")
pruefe(f._dark_mode == theme.windows_dark_mode(),
"und folgt damit dem Windows-Theme")
# ---- Stand, der den Wechsel ueberleben muss ----------------------------
f.jahr.set(2025)
f.suche.set("Muster")
pumpen(0.5)
def farben(dunkel):
"""Die Palette der einen oder anderen Darstellung, als Menge."""
merker = f._dark_mode
f.cfg["darstellung"] = "dunkel" if dunkel else "hell"
f._apply_theme()
satz = {getattr(f, n).lower() for n in dir(f)
if n.startswith("col_") and isinstance(getattr(f, n), str)}
f._dark_mode = merker
return satz
HELL = farben(False)
DUNKEL = farben(True)
# Was in BEIDEN Paletten vorkommt, taugt nicht als Beweis.
NUR_HELL = HELL - DUNKEL
NUR_DUNKEL = DUNKEL - HELL
def fremdfarben(verboten):
"""Alle Widgets, die eine Farbe der falschen Palette tragen."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
for eigenschaft in ("bg", "background", "fg", "foreground"):
try:
wert = str(kind.cget(eigenschaft)).lower()
except tk.TclError:
continue
if wert in verboten:
treffer.append((str(kind), eigenschaft, wert))
durchgehen(kind)
durchgehen(f)
return treffer
# ---- Die drei Modi ------------------------------------------------------
for wunsch, soll_dunkel in (("hell", False), ("dunkel", True)):
f.cfg["darstellung"] = wunsch
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == soll_dunkel,
f"'{wunsch}' zeichnet {'dunkel' if soll_dunkel else 'hell'} "
f"(Hintergrund {f.col_bg})")
# Nichts darf verlorengehen.
pruefe(f.jahr.get() == 2025,
f"nach dem Wechsel steht das gewaehlte Jahr noch da ({f.jahr.get()})")
pruefe(f.suche.get() == "Muster",
f"und der Suchbegriff ({f.suche.get()!r})")
# Kein Rest der anderen Palette.
reste = fremdfarben(NUR_DUNKEL if not soll_dunkel else NUR_HELL)
pruefe(not reste,
f"in '{wunsch}' ist keine Farbe der anderen Darstellung "
f"uebrig ({reste[:3]})")
# ---- Dialoge gehen mittig ueber dem Programmfenster auf ------------------
# Seine Ansage: "einfach so programmieren das die popup auf dem fenster mittig
# vom main fenster auf gehen". Das ist zugleich die Antwort auf die
# Monitorfrage - steht das Programm auf dem zweiten Bildschirm, geht der
# Dialog dort auf. Vorher klemmte ein max(0, x) jede negative Koordinate auf
# null und riss die Fenster auf den Hauptmonitor zurueck.
d = f.dlg_darstellung()
pumpen(1.2)
d.update_idletasks()
mitte_dialog = d.winfo_rootx() + d.winfo_width() // 2
mitte_fenster = f.winfo_rootx() + f.winfo_width() // 2
pruefe(abs(mitte_dialog - mitte_fenster) <= 30,
f"der Dialog steht mittig ueber dem Fenster "
f"({mitte_dialog} vs. {mitte_fenster})")
pruefe(d.winfo_rootx() >= f.winfo_rootx() - 40
and d.winfo_rootx() + d.winfo_width() <= f.winfo_rootx() + f.winfo_width() + 40,
"und liegt waagerecht innerhalb des Fensters")
pruefe(d.winfo_rooty() >= f.winfo_rooty() - 40,
"und nicht oberhalb davon")
d.destroy()
pumpen(0.3)
# ---- Der Dialog muss den Klick UEBERLEBEN -------------------------------
# Gefunden am 07.09.2026 von der Pruefkette: _oberflaeche_neu() loeschte alle
# Kinder des Hauptfensters - und ein Toplevel IST ein Kind. Der offene Dialog
# wurde also beim ersten Klick mitgeloescht, die Zeile danach fasste eine
# Leiche an ("invalid command name .!toplevel").
#
# Dieser Pruefstand hat es NICHT gefunden, weil er cfg["darstellung"] von Hand
# setzte. Hier wird jetzt der Radioknopf gedrueckt - derselbe Weg, den ein
# Mensch geht.
d = f.dlg_darstellung()
pumpen(1.0)
def radioknoepfe(fenster):
"""Alle Radiobuttons unterhalb eines Fensters, mit ihrer Beschriftung."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
if kind.winfo_class() == "TRadiobutton":
try:
treffer.append((str(kind.cget("text")), kind))
except tk.TclError:
pass
durchgehen(kind)
durchgehen(fenster)
return treffer
knoepfe = radioknoepfe(d)
pruefe(len(knoepfe) == len(theme.MODI),
f"der Dialog zeigt alle {len(theme.MODI)} Moeglichkeiten "
f"({[t for t, _ in knoepfe]})")
ziel = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["dunkel"]), None)
pruefe(ziel is not None, "der Knopf 'Dunkel' ist da")
if ziel is not None:
ziel.invoke() # der echte Klick
pumpen(1.2)
pruefe(bool(d.winfo_exists()),
"der Dialog lebt nach dem Klick noch")
pruefe(f._dark_mode is True,
f"und die Darstellung ist umgestellt (Hintergrund {f.col_bg})")
# Und noch einmal, denn genau das war vorher unmoeglich:
hell = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["hell"]), None)
if hell is not None:
try:
hell.invoke()
pumpen(1.0)
pruefe(f._dark_mode is False,
"ein ZWEITER Klick im selben Dialog wirkt auch")
except tk.TclError as e:
pruefe(False, f"der zweite Klick scheiterte ({e})")
try:
d.destroy()
except tk.TclError:
pass
pumpen(0.3)
# ---- Zurueck auf automatisch --------------------------------------------
f.cfg["darstellung"] = "auto"
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == theme.windows_dark_mode(),
"'auto' folgt wieder dem Windows-Theme")
# ---- Und die Einstellung ueberlebt den Neustart -------------------------
f.cfg["darstellung"] = "hell"
f.destroy()
f = A.App()
pumpen(2.0)
pruefe(f._dark_mode is False,
"die Einstellung steht beim naechsten Start wieder da")
try:
f.destroy()
except Exception: # noqa: BLE001
pass
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Hell, dunkel und automatisch wirken - ohne Datenverlust")