Compare commits

...

4 commits
v1.8.1 ... main

Author SHA1 Message Date
TheMockTv
fb04f5ad74 Fassung 1.8.2
Aufraeumrunde nach der Pruefkette. Fuer den Nutzer sichtbar ist ein Punkt:
der Wechsel von einem Menuetitel zum anderen braucht nur noch EINEN Klick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:41:14 +02:00
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
TheMockTv
5babfc3e2e Zwei echte Fehler aus der Pruefkette behoben
## 1. Der Darstellungsdialog zerstoerte sich beim ersten Klick selbst

_oberflaeche_neu() loeschte ALLE Kinder des Hauptfensters - und ein
tk.Toplevel IST ein Kind. Der offene Dialog wurde also mitgeloescht, die
Zeile danach fasste eine Leiche an:

    _tkinter.TclError: invalid command name ".!toplevel"

Die Farbe wechselte zwar und nichts ging verloren, aber das Fenster
verschwand beim ersten Klick, jede weitere Umstellung brauchte einen neuen
Menueaufruf, und jedes Mal landete ein Traceback im Log. In BEIDEN
Programmen.

Behoben: fremde Toplevels bleiben stehen. Damit ueberlebt auch ein offener
Ordnerwahl- oder Einstellungsdialog den Wechsel.

⚠️ Warum mein Pruefstand es nicht fand: er setzte cfg["darstellung"] von Hand
und rief _oberflaeche_neu() direkt - also alles ausser dem Weg, den ein
Mensch geht. Den Dialog oeffnete er zwar, mass aber nur dessen Position.
pruef_darstellung drueckt jetzt den Radioknopf (.invoke()) und prueft, dass
der Dialog danach noch lebt UND dass ein ZWEITER Klick im selben Dialog
wirkt - das war vorher unmoeglich.

## 2. Unerreichbarer Code nach einem return

_fenstersymbol() hatte zwei Zeilen der alten Inline-Fassung hinter dem
return stehen. Rest meines symbol_setzen-Umbaus; per AST ueber alle Dateien
beider Ordner belegt (genau ein Treffer).

Beides gefunden von der Pruefkette nach R23. Die Aufraeumbefunde derselben
Runde (doppelter Code, tote Variablen, 34x dasselbe pruefe()) kommen
getrennt - eine Fehlerbehebung soll fuer sich pruefbar bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:10:40 +02:00
TheMockTv
08426b3d9b pruef_installation hinterlaesst den Zustand, den er vorfindet
War das Programm vorher installiert, ist es danach wieder installiert. Beim
ersten Lauf war es das NICHT - danach stand ein deinstalliertes Programm da,
und der naechste Schritt (den Updater starten lassen) lief ins Leere.

Ein Pruefstand, der aufraeumt, muss auch sich selbst aufraeumen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:57:33 +02:00
37 changed files with 639 additions and 357 deletions

23
.gitattributes vendored Normal file
View file

@ -0,0 +1,23 @@
# Zeilenenden festlegen, damit sie nicht von der Maschine abhaengen.
#
# Neun Dateien liegen in beiden Programmen gleich und werden von Hand
# hinueberkopiert; pruef_gemeinsam_automatisch.py prueft das byteweise.
# Ohne diese Festlegung entscheidet core.autocrlf jeder Maschine einzeln,
# wie eine Datei im Arbeitsverzeichnis aussieht - und dann meldet die
# Pruefung einen Unterschied, den es im Repository gar nicht gibt.
# Genau so stand es am 07.09.2026 bei hinweis.py: gleicher Inhalt, die
# eine Fassung CRLF, die andere LF.
* text=auto
*.py text eol=crlf
*.iss text eol=crlf
*.md text eol=crlf
*.json text eol=crlf
# Binaeres nie anfassen.
*.png binary
*.ico binary
*.svg text eol=crlf
*.ttf binary
*.exe binary
*.pdf binary
*.sqlite3 binary

4
api.py
View file

@ -138,6 +138,10 @@ class _Handler(socketserver.StreamRequestHandler):
try:
self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8"))
except OSError:
# Der Rufer hat die Verbindung schon zugemacht - das ist der
# Normalfall bei "schau_nach", wenn das andere Programm beendet
# wurde, waehrend die Antwort unterwegs war. Die Arbeit ist
# getan; nur die Empfangsbestaetigung geht ins Leere.
pass

100
app.py
View file

@ -476,34 +476,15 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
WAS DABEI ERHALTEN BLEIBT
-------------------------
Alles Eingetippte. Ein Themenwechsel darf keine halb getippte Rechnung
wegwerfen.
wegwerfen. Um die Tk-Variablen kuemmert sich variablen_sichern() im
ThemeMixin - dort steht auch, warum ALLE gesichert werden und nicht
eine gepflegte Auswahl.
Dass die Werte an StringVars haengen, reicht dafuer NICHT: die
_build_-Methoden legen ihre Vars selbst an (`self.k_name =
tk.StringVar()`), beim Neubau stehen also neue, leere Vars da. Beim
ersten Versuch waren Kundenname und Rechnungsnummer weg, waehrend die
Mengen blieben - die hatte ich einzeln gesichert.
Deshalb werden hier ALLE Variablen des Fensters gesichert, nicht eine
Auswahl: durchgegangen wird, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen - es ist
automatisch dabei. Eine Liste zum Pflegen waere genau die Sorte, die
man beim naechsten Feld vergisst.
Hier stehen nur die Dinge, die es sonst nirgends gibt: die Mengen und
Personenzahlen der Leistungszeilen (sie haengen an Woerterbuechern,
nicht am Fenster) und das Blatt, das gerade vorn war.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
gesichert = self.variablen_sichern()
mengen = {nr: v.get() for nr, v in self.anzahl_vars.items()}
personen = {nr: v.get() for nr, v in self.personen_vars.items()}
blatt = None
@ -512,8 +493,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
except Exception: # noqa: BLE001 - dann eben das erste Blatt
pass
for kind in self.winfo_children():
kind.destroy()
self.kinder_loeschen()
self.flaeche = None
self._text_zellen = []
@ -525,14 +505,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self._build_positionen()
self.flaeche.anordnen()
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
self.variablen_zuruecksetzen(gesichert)
for nr, wert in mengen.items():
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(wert)
@ -547,46 +520,17 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self._buch_anzeigen()
self.recalc()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung", font=(theme.SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340, foreground=self.col_muted,
text="Gilt nur für das Programmfenster. Die Farbe der Rechnung "
"steht unter Einstellungen → Firmendaten – das Blatt "
"gehört dem Kunden, nicht dem Programm.").pack(
anchor="w", pady=(6, 12))
# Der Dialog selbst steht im ThemeMixin (theme.py) - er sah in beiden
# Programmen gleich aus. Verschieden ist nur der Hinweis darin und die
# Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.
DARSTELLUNG_HINWEIS = (
"Gilt nur für das Programmfenster. Die Farbe der Rechnung steht unter "
"Einstellungen → Firmendaten – das Blatt gehört dem Kunden, nicht dem "
"Programm.")
gewaehlt = tk.StringVar(value=theme.modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == theme.modus(self.cfg):
return
self.cfg["darstellung"] = neu
config.save_config(self.cfg)
self._oberflaeche_neu()
# Der Dialog gehoert noch zur alten Darstellung - er wird
# mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen.
self._style_dialog(d)
for wert in theme.MODI:
ttk.Radiobutton(rahmen, text=theme.MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
theme.mittig(d, self)
return d
def _cfg_speichern(self):
"""Hier ist die Konfiguration eine config.json und will geschrieben sein."""
config.save_config(self.cfg)
def _umbruch_nachziehen(self, runde=0, vorige_breite=None):
"""Den Textumbruch der Leistungsspalte auf ihre echte Breite setzen.
@ -845,9 +789,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
def _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self, dunkel=getattr(self, "_dark_mode", False))
print("[symbol] assets/programm.ico nicht gefunden")
return False
return wasserzeichen.symbol_setzen(self)
def _hinweis_wenn_sichtbar(self, versuche=0):
"""Wartet, bis das Fenster gezeichnet ist, und fragt dann.
@ -1099,7 +1041,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
theme.mittig(d, self)
d.grab_set()
self.wait_window(d)
@ -1449,7 +1390,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
theme.mittig(d, self)
d.grab_set()
self.wait_window(d)

View file

@ -58,6 +58,7 @@ def _daten_ordner():
"""
if not getattr(sys, "frozen", False):
return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist
import gemeinsam
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
# NEBEN dem Programm, nicht darin: das Programm liegt in
# ravokk/Rechnungstool, die Daten eine Ebene darueber in ravokk. Genau so
@ -65,7 +66,8 @@ def _daten_ordner():
# es den ordner rechnungstool und steuertool". Nur dadurch kann ein
# Deinstallieren den Programmordner restlos leeren, ohne die Belege
# anzufassen (§ 147 AO).
kandidat = os.path.join(heim, "ravokk")
# Gerechnet wird der Pfad nur an EINER Stelle - siehe dort.
kandidat = gemeinsam.standard_ordner()
if not _beschreibbar(kandidat):
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
@ -84,8 +86,14 @@ def _daten_ordner():
os.replace(alt, neu_)
print(f"[config] {name} aus {alt} übernommen")
break
except OSError:
pass
except OSError as e:
# Nicht abbrechen - der naechste Ort wird noch probiert,
# und zur Not faengt das Programm eben neu an. Aber sagen,
# dass es schiefging: der Erfolgsfall schreibt eine Zeile,
# und wer sie sucht und nicht findet, wuesste sonst nicht,
# ob nichts da war oder ob die Uebernahme misslang. Es geht
# um Firmendaten, Leistungskatalog und Rechnungszaehler.
print(f"[config] {name} aus {alt} NICHT übernommen: {e}")
return kandidat

View file

@ -41,6 +41,10 @@ def eigene_pid_merken():
with open(PID_DATEI, "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
# Der Merkzettel ist eine Bequemlichkeit, keine Bedingung: ohne ihn
# findet eine zweite Instanz das laufende Fenster nicht und meldet
# nur, dass das Programm schon laeuft. Daran darf der Start nicht
# scheitern - der Ordner kann schreibgeschuetzt sein.
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""

View file

@ -512,7 +512,6 @@ class KorrekturMixin:
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
theme.mittig(d, self)
d.grab_set()
self.wait_window(d)

View file

@ -32,29 +32,32 @@ Beschriftung ergibt eine Trennlinie.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import theme
import tkinter as tk
from tkinter import ttk
class Menueleiste(tk.Frame):
def __init__(self, master, menues, farben=None, mit_trennlinie=True):
def __init__(self, master, menues, farben=None):
self.f = farben if farben is not None else master
super().__init__(master, bg=self.f.col_bg)
self.menues = menues
self._offen = None
self._anker = None # welcher Titel gerade offen ist
self._titel = {} # Label -> Eintraege, fuer den Wechsel im Klick
self.pack(side="top", fill="x")
for titel, eintraege in menues.items():
lbl = tk.Label(self, text=titel, bg=self.f.col_bg, fg=self.f.col_fg,
padx=8, pady=3)
lbl.pack(side="left")
self._titel[lbl] = eintraege
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.f.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.f.col_bg))
lbl.bind("<Button-1>",
lambda e, b=lbl, it=eintraege: self.aufklappen(b, it))
if mit_trennlinie:
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
# ---------------------------------------------------------------- Klappen
def aufklappen(self, anker, eintraege):
@ -83,9 +86,15 @@ class Menueleiste(tk.Frame):
eintrag.bind("<Button-1>", lambda e, c=befehl: self._ausfuehren(c))
pop.update_idletasks()
pop.geometry(f"+{anker.winfo_rootx()}"
f"+{anker.winfo_rooty() + anker.winfo_height()}")
# Auf den sichtbaren Bereich begrenzen, wie jede andere Position im
# Programm auch. Steht das Fenster am unteren oder rechten Rand, lief
# die Liste sonst hinaus und war nicht mehr zu treffen.
x, y = theme.auf_bildschirm(
anker.winfo_rootx(), anker.winfo_rooty() + anker.winfo_height(),
pop.winfo_reqwidth(), pop.winfo_reqheight())
pop.geometry(f"+{x}+{y}")
self._offen = pop
self._anker = anker
pop.grab_set()
pop.bind("<Button-1>", self._klick_ausserhalb, add="+")
pop.bind("<Escape>", lambda e: self.zuklappen())
@ -94,14 +103,53 @@ class Menueleiste(tk.Frame):
faerben(pop)
def _klick_ausserhalb(self, ereignis):
"""Klick neben die Liste: schliessen - und auf einem anderen Titel
gleich dessen Liste aufklappen.
WARUM DAS HIER STEHT UND NICHT IM LABEL (07.09.2026)
----------------------------------------------------
Solange eine Liste offen ist, haelt ihr `grab_set` alle Mausklicks der
Anwendung fest. Die `<Button-1>`-Bindung des Menuetitels feuert dann
gar nicht - der Klick landet hier. Vorher endete er damit beim blossen
Zuklappen, und man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. 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.
Ein Klick auf DENSELBEN Titel schliesst weiterhin nur - sonst koennte
man eine Liste nie ueber ihren Titel wieder loswerden.
"""
pop = self._offen
if not pop:
return
if (ereignis.x_root < pop.winfo_rootx()
or ereignis.x_root > pop.winfo_rootx() + pop.winfo_width()
or ereignis.y_root < pop.winfo_rooty()
or ereignis.y_root > pop.winfo_rooty() + pop.winfo_height()):
self.zuklappen()
if (pop.winfo_rootx() <= ereignis.x_root
<= pop.winfo_rootx() + pop.winfo_width()
and pop.winfo_rooty() <= ereignis.y_root
<= pop.winfo_rooty() + pop.winfo_height()):
return # der Klick lag in der Liste
vorher = self._anker
self.zuklappen()
getroffen = self._titel_bei(ereignis.x_root, ereignis.y_root)
if getroffen is not None and getroffen is not vorher:
self.aufklappen(getroffen, self._titel[getroffen])
def _titel_bei(self, x_root, y_root):
"""Welcher Menuetitel an dieser Bildschirmstelle liegt - oder None.
Nachgerechnet statt gefragt: `winfo_containing()` waere kuerzer, gibt
aber nichts zurueck, sobald das Fenster einer ANDEREN Anwendung ueber
dem Punkt liegt. Beim Bedienen ist das nie der Fall - beim Pruefen
schon, und dort meldete es dann einen Fehler, den es nicht gibt
(07.09.2026, zwei Pruefstaende liefen gleichzeitig auf demselben
Bildschirmplatz). Wo die eigenen Titel liegen, weiss diese Leiste
selbst; dafuer muss sie das Fenstersystem nicht fragen.
"""
for lbl in self._titel:
x, y = lbl.winfo_rootx(), lbl.winfo_rooty()
if (x <= x_root <= x + lbl.winfo_width()
and y <= y_root <= y + lbl.winfo_height()):
return lbl
return None
def _ausfuehren(self, befehl):
# Erst zuklappen, dann ausfuehren: sonst haelt der grab_set der Liste
@ -118,3 +166,4 @@ class Menueleiste(tk.Frame):
except tk.TclError:
pass
self._offen = None
self._anker = None

View file

@ -118,8 +118,6 @@ class OrdnerWahl(tk.Toplevel):
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
self.update_idletasks()
hoehe = max(self.winfo_reqheight(), 300)
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
y = master.winfo_rooty() + 90
self.geometry(f"{BREITE}x{hoehe}")
theme.mittig(self, master)
self.minsize(BREITE, hoehe)

View file

@ -32,16 +32,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -73,6 +63,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}

View file

@ -52,6 +52,7 @@ 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():
@ -63,16 +64,6 @@ _wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.

View file

@ -30,14 +30,7 @@ shutil.copy(os.path.join(QUELLE, "config.json"), config.CONFIG_PATH)
import bilder # noqa: E402
import pdf_renderer # noqa: E402
from berechnung import berechne # noqa: E402
fehler = []
def pruefe(bedingung, text):
print(("OK | " if bedingung else "FEHL | ") + text)
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
def testbild(pfad, farbe):

View file

@ -35,16 +35,6 @@ import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
@ -69,6 +59,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"),
import app as A # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.RechnungsApp()
@ -189,6 +180,66 @@ pruefe(d.winfo_rooty() >= f.winfo_rooty() - 40,
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"
config.save_config(f.cfg)

View file

@ -26,16 +26,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -64,6 +54,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True, "Storno erstellt": False,
"Angaben zum Kunden": True, "Weicht ab": True}

View file

@ -44,6 +44,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import flaeche as FL # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -55,16 +56,6 @@ _wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.

View file

@ -28,16 +28,6 @@ import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -70,6 +60,7 @@ os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}
gefragt = []

View file

@ -22,6 +22,12 @@ GEPRUEFT WIRD
2. Der Ordner laesst sich anlegen, und darin entsteht EIN Nummernbuch.
3. Was das eine eintraegt, findet das andere - ohne dass jemand einen Pfad
eingegeben hat.
4. Die Dateien, die von sich sagen, sie laegen in beiden Programmen gleich,
sind auch wirklich gleich.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - und sie prueft sich unter Punkt 4 selbst mit, denn sie traegt
denselben Satz wie die anderen.
Angefasst wird dabei nichts Echtes: der Standardordner wird ueber die
Umgebungsvariable LOCALAPPDATA auf einen Temp-Ordner umgelenkt.
@ -31,16 +37,6 @@ import shutil
import sys
import tempfile
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
RECHNUNG = r"C:\claude\rechnungstool"
STEUER = r"C:\claude\beherbergungssteuer"
@ -49,6 +45,7 @@ STEUER = r"C:\claude\beherbergungssteuer"
# Geladen wird jede Fassung EINZELN, damit wirklich die Datei des jeweiligen
# Programms befragt wird und nicht die zufaellig zuerst importierte.
import importlib.util # noqa: E402
from pruefhelfer import fehler, pruefe
def modul_aus(ordner, name):
@ -95,9 +92,54 @@ buch_s.eintragen("2026-778", jahr=2026, nachname="Gegenprobe", quelle="journal")
zurueck = [n for n in buch_r.nummern(2026) if n == "2026-778"]
pruefe(bool(zurueck), "und umgekehrt genauso")
pruefe(len(os.listdir(angelegt)) >= 1
and any(n.endswith(".sqlite3") for n in os.listdir(angelegt)),
f"es liegt genau ein Buch dort ({os.listdir(angelegt)})")
# GENAU eins - nicht "mindestens eins". Vorher stand hier `>= 1` mit einem
# any(); im Text darueber stand "genau ein Buch". Eine Pruefung, die nicht rot
# werden kann, prueft nichts: laege dort ein zweites Buch, weil eine der
# Fassungen einen anderen Dateinamen bildet, waere sie gruen geblieben - und
# das ist genau der Fall, den dieser Pruefstand abfangen soll.
#
# Die Begleitdateien von SQLite (-wal, -shm) zaehlen nicht mit; sie gehoeren
# zum selben Buch.
buecher = [n for n in os.listdir(angelegt) if n.endswith(".sqlite3")]
pruefe(len(buecher) == 1,
f"es liegt genau EIN Buch dort ({os.listdir(angelegt)})")
pruefe(buecher == [g_rechnung.DATEINAME],
f"und es traegt den Namen, den beide erwarten ({buecher})")
# ---- 4. Was "in beiden gleich" heisst, ist auch gleich ------------------
# Neun Dateien tragen im Kopf den Satz, sie laegen in beiden Programmen
# gleich. Das ist eine Zusage an den Naechsten, der eine davon anfasst - und
# geprueft hat sie bis zum 07.09.2026 niemand. Bei hinweis.py stimmte sie da
# schon nicht mehr: gleicher Inhalt, andere Zeilenenden.
#
# Die Liste steht bewusst NICHT hier, sondern wird aus dem Satz selbst
# gelesen. Eine Liste, die jemand von Hand nachfuehren muss, veraltet still
# (und still veraltete Pruefstaende fuehren in die Irre).
MARKE = "Diese Datei liegt in BEIDEN Programmen gleich"
gemeinsame = sorted(n for n in os.listdir(RECHNUNG) if n.endswith(".py")
and MARKE in open(os.path.join(RECHNUNG, n),
encoding="utf-8").read())
pruefe(len(gemeinsame) >= 5,
f"{len(gemeinsame)} Dateien sagen von sich, sie seien in beiden gleich")
for name in gemeinsame:
hier = os.path.join(RECHNUNG, name)
dort = os.path.join(STEUER, name)
if not os.path.isfile(dort):
pruefe(False, f"{name} fehlt im Steuerrechnungstool")
continue
roh_h, roh_d = open(hier, "rb").read(), open(dort, "rb").read()
if roh_h == roh_d:
pruefe(True, f"{name} ist byteweise gleich")
continue
# Nicht gleich - dann wenigstens sagen, WORAN es liegt. Das ist der
# Unterschied zwischen einer Meldung und einer Suche.
if roh_h.replace(b"\r\n", b"\n") == roh_d.replace(b"\r\n", b"\n"):
grund = "nur die Zeilenenden weichen ab"
else:
grund = "der Inhalt weicht ab"
pruefe(False, f"{name}: {grund}")
shutil.rmtree(tmp, ignore_errors=True)

View file

@ -31,16 +31,6 @@ import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -71,6 +61,7 @@ os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import hinweis as H # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}
gesehen = []

View file

@ -36,6 +36,12 @@ programme gehoeren zu der firma"*.
⚠ DIESER PRUEFSTAND INSTALLIERT WIRKLICH. Er laeuft deshalb nur, wenn das
Setup gebaut ist, und er fasst NUR die Programmordner an - vorhandene Daten
werden vorher gesichert und danach zurueckgelegt.
Und er hinterlaesst den Zustand, den er vorgefunden hat: war das Programm
vorher installiert, ist es danach wieder installiert. Beim ersten Lauf war es
das NICHT - danach stand ein deinstalliertes Programm da, und der naechste
Schritt lief ins Leere. Ein Pruefstand, der aufraeumt, muss auch sich selbst
aufraeumen.
"""
import os
import shutil
@ -48,22 +54,13 @@ HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
from pruefhelfer import fehler, pruefe
LOKAL = os.environ.get("LOCALAPPDATA", "")
RAVOKK = os.path.join(LOKAL, "ravokk")
NAME = os.path.splitext(version.EXE_NAME)[0]
PROGRAMMORDNER = os.path.join(RAVOKK, NAME)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
def setup_datei():
ordner = os.path.join(HIER, "release")
@ -116,6 +113,7 @@ if os.path.isdir(RAVOKK):
print(f" ({len(gesichert)} vorhandene Datei(en) gesichert)")
beenden()
war_installiert = os.path.exists(os.path.join(PROGRAMMORDNER, "unins000.exe"))
alter_uninstaller = os.path.join(PROGRAMMORDNER, "unins000.exe")
if os.path.exists(alter_uninstaller):
subprocess.run([alter_uninstaller, "/VERYSILENT", "/SUPPRESSMSGBOXES",
@ -172,6 +170,13 @@ pruefe(os.path.isdir(RAVOKK),
# ---- Aufraeumen ---------------------------------------------------------
if os.path.exists(probe):
os.remove(probe)
if war_installiert:
# So hinterlassen, wie es vorgefunden wurde.
subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(5)
print(f" (war vorher installiert - wieder eingerichtet: "
f"{os.path.isdir(PROGRAMMORDNER)})")
for d in gesichert:
ziel = os.path.join(RAVOKK, d)
if not os.path.exists(ziel):

View file

@ -53,6 +53,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -66,18 +67,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
# ---- Dialoge: jede Frage muss angemeldet sein --------------------------------
antworten = {}

View file

@ -52,6 +52,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -65,18 +66,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
antworten = {"Fertig": False, "Storno wirklich": True,
"endgültig erstellen": True,

184
pruef_menueleiste.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die eigene Menueleiste - Wechsel mit EINEM Klick, und am Rand.
WARUM ES DIESE LEISTE GIBT
--------------------------
`tk.Menu` ist unter Windows ein Menue des Betriebssystems. Es nimmt Farben
entgegen und zeichnet trotzdem im Systemgrau - ein weisser Balken ueber einem
dunklen Fenster. Sein Befund am 07.09.2026: *"nur die einstellunden hilfe
leiste die ist noch hell"*. Deshalb ist die Leiste aus gewoehnlichen Labels
gebaut.
WAS HIER GEPRUEFT WIRD
1. Ein Klick auf einen Titel klappt seine Liste auf, ein zweiter auf denselben
Titel klappt sie wieder zu.
2. Steht eine Liste offen und man klickt auf einen ANDEREN Titel, klappt
dessen Liste auf - mit EINEM Klick.
Das ging bis zum 07.09.2026 nicht. Solange eine Liste offen ist, haelt ihr
`grab_set` alle Mausklicks der Anwendung fest; die `<Button-1>`-Bindung des
Menuetitels feuert dann gar nicht. Der Klick landete beim Zuklappen und war
damit verbraucht - man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. Das native tk.Menu wechselte schon beim blossen
Ueberfahren; zwei Klicks waren also eine Verschlechterung gegenueber dem,
was ersetzt wurde.
Geprueft wird das ueber ein echtes Klickereignis auf der offenen Liste, mit
den Bildschirmkoordinaten des anderen Titels - also genau der Weg, den der
grab_set erzwingt. Ein direkter Aufruf von aufklappen() wuerde am Problem
vorbeipruefen.
3. Die Liste bleibt auf dem Bildschirm. Steht das Fenster am unteren Rand,
lief sie vorher darueber hinaus und war nicht mehr zu treffen: die Position
wurde ungeprueft gesetzt, waehrend jede andere neue Position im Programm
ueber theme.auf_bildschirm() laeuft.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import os
import sys
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
import menueleiste # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe # noqa: E402
class Fenster(theme.ThemeMixin, tk.Tk):
"""Ein nacktes Fenster mit Farben - mehr braucht die Leiste nicht."""
def __init__(self):
super().__init__()
self.cfg = {} # theme.modus() will etwas mit .get()
self._apply_theme()
self.title("Pruefstand Menueleiste")
self.geometry("560x320" + theme.pruefplatz())
f = Fenster()
gerufen = []
f.menue = menueleiste.Menueleiste(f, {
"Datei": [("Neu", lambda: gerufen.append("neu")), ("-", None),
("Beenden", lambda: gerufen.append("beenden"))],
"Bearbeiten": [("Rueckgaengig", lambda: gerufen.append("rueck"))],
"Hilfe": [("Info", lambda: gerufen.append("info"))],
})
def pumpen(sekunden=0.4):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(1.0)
titel = {str(w.cget("text")): w for w in f.menue.winfo_children()
if isinstance(w, tk.Label)}
pruefe(set(titel) == {"Datei", "Bearbeiten", "Hilfe"},
f"alle drei Titel stehen in der Leiste ({sorted(titel)})")
def klick_auf_titel(name):
"""Auf einen Menuetitel klicken - so, wie es die Maus tut.
Steht schon eine Liste offen, MUSS das Ereignis an sie gehen: ihr grab_set
faengt jeden Klick der Anwendung ab. Genau das ist der Fall, um den es
unter Punkt 2 geht.
"""
w = titel[name]
x = w.winfo_rootx() + w.winfo_width() // 2
y = w.winfo_rooty() + w.winfo_height() // 2
ziel = f.menue._offen or w
if ziel is w:
# Achtung: Tk nennt die Bildschirmkoordinaten beim Erzeugen -rootx
# und -rooty; im Ereignis heissen sie dann x_root und y_root.
w.event_generate("<Button-1>", x=w.winfo_width() // 2,
y=w.winfo_height() // 2, rootx=x, rooty=y)
else:
ziel.event_generate("<Button-1>", x=-1, y=-1, rootx=x, rooty=y)
pumpen(0.4)
def offener_titel():
"""Welcher Titel gerade offen ist - oder None."""
anker = f.menue._anker
if anker is None or not f.menue._offen:
return None
return next((t for t, w in titel.items() if w is anker), "?")
# ---- 1. Aufklappen und wieder zuklappen ---------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", f"'Datei' klappt auf ({offener_titel()})")
klick_auf_titel("Datei")
pruefe(offener_titel() is None,
f"und derselbe Titel klappt wieder zu ({offener_titel()})")
# ---- 2. Der Wechsel: EIN Klick ------------------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", "'Datei' steht offen")
klick_auf_titel("Hilfe")
pruefe(offener_titel() == "Hilfe",
f"ein Klick auf 'Hilfe' wechselt hinueber ({offener_titel()})")
klick_auf_titel("Bearbeiten")
pruefe(offener_titel() == "Bearbeiten",
f"und weiter auf 'Bearbeiten' ({offener_titel()})")
# Ein Klick irgendwo neben die Leiste schliesst - und oeffnet nichts.
pop = f.menue._offen
pop.event_generate("<Button-1>", x=-1, y=-1,
rootx=f.winfo_rootx() + 300,
rooty=f.winfo_rooty() + 250)
pumpen(0.4)
pruefe(offener_titel() is None,
f"ein Klick daneben schliesst und oeffnet nichts ({offener_titel()})")
# ---- 3. Ein Eintrag fuehrt seinen Befehl aus ----------------------------
klick_auf_titel("Hilfe")
eintrag = None
for kind in f.menue._offen.winfo_children():
for enkel in kind.winfo_children():
if isinstance(enkel, tk.Label) and str(enkel.cget("text")) == "Info":
eintrag = enkel
eintrag.event_generate("<Button-1>", x=2, y=2)
pumpen(0.4)
pruefe(gerufen == ["info"], f"der Eintrag 'Info' fuehrt seinen Befehl aus ({gerufen})")
pruefe(offener_titel() is None, "und die Liste ist danach zu")
# ---- 4. Am unteren Bildschirmrand bleibt die Liste sichtbar -------------
# Das Fenster so weit nach unten schieben, dass unter der Leiste kein Platz
# mehr fuer die Klappliste ist.
links, oben = theme.auf_bildschirm(f.winfo_rootx(), f.winfo_rooty())
unten = oben + f.winfo_screenheight()
f.geometry(f"560x320+{links}+{unten - 120}")
pumpen(0.6)
klick_auf_titel("Datei")
pop = f.menue._offen
pruefe(pop is not None, "die Liste geht auch am unteren Rand auf")
if pop is not None:
x, y = pop.winfo_rootx(), pop.winfo_rooty()
hoehe, breite = pop.winfo_height(), pop.winfo_width()
grenze_x, grenze_y = theme.auf_bildschirm(x, y, breite, hoehe)
pruefe((x, y) == (grenze_x, grenze_y),
f"und steht vollstaendig auf dem Bildschirm "
f"(Liste {x},{y} bis {x + breite},{y + hoehe})")
f.destroy()
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die Menueleiste wechselt mit einem Klick und bleibt im Bild")

View file

@ -49,6 +49,7 @@ os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -61,16 +62,6 @@ _wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}

View file

@ -23,16 +23,6 @@ import threading
TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -66,6 +56,7 @@ sys.path.remove(JOURNAL)
from datetime import date # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None

View file

@ -22,16 +22,6 @@ import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -65,6 +55,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
warnungen = []
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.

View file

@ -31,16 +31,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -69,6 +59,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.

View file

@ -39,16 +39,7 @@ sys.path.insert(0, HIER)
import updater # noqa: E402
import version # noqa: E402
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
try:

View file

@ -32,19 +32,10 @@ import ast
import io
import os
import sys
from pruefhelfer import fehler, pruefe
ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"]
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
def fundstellen(quelltext):
"""Alle Zeilen, in denen `self` steht, ohne dass es eines gibt."""

View file

@ -41,16 +41,6 @@ import time
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
@ -72,6 +62,7 @@ for _n in ("showinfo", "showwarning", "showerror"):
setattr(messagebox, _n, lambda *a, **k: None)
import app as A # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.RechnungsApp()

View file

@ -36,16 +36,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
stamm, endung = os.path.splitext(version.EXE_NAME)

View file

@ -48,6 +48,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -61,18 +62,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {}

View file

@ -25,16 +25,7 @@ QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
import updater as U # noqa: E402
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
EXE = "Rechnungstool.exe"

View file

@ -40,6 +40,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -51,16 +52,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.

47
pruefhelfer.py Normal file
View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Das Gemeinsame aller Pruefstaende: eine Zeile schreiben und sie merken.
WARUM ES DIESE DATEI GIBT (07.09.2026)
--------------------------------------
Die Funktion pruefe() stand 35-mal im Quelltext, in sechs leicht verschiedenen
Fassungen. Keine davon war falsch - aber sie sahen unterschiedlich aus, und
wer 37 Laeufe hintereinander durchsieht, liest dann dreierlei Formen fuer
dieselbe Aussage. Wird an der Ausgabe je etwas geaendert, sind es 35 Stellen.
BENUTZUNG
from pruefhelfer import fehler, pruefe
pruefe(a == b, "beide nennen denselben Ordner")
...
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | ...")
`fehler` ist eine Liste und wird beim Importieren geteilt - jeder Pruefstand
laeuft in einem eigenen Prozess, sie gehoert also ihm allein.
Den Abschluss schreibt jeder Pruefstand selbst. Er sieht nur aehnlich aus:
manche raeumen dort noch einen Temp-Ordner ab, manche zaehlen die
fehlgeschlagenen Punkte einzeln auf. Das ist Inhalt, keine Wiederholung.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import sys
# Alles, was nicht erfuellt war - der Text, nicht nur die Anzahl. Wer am Ende
# nur eine Zahl hat, muss den Lauf noch einmal lesen, um zu wissen, was fehlte.
fehler = []
def pruefe(bedingung, text):
"""Eine Zusage pruefen, das Ergebnis melden und Fehlschlaege merken."""
zeile = (" OK | " if bedingung else "FEHLER | ") + text
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)

130
theme.py
View file

@ -46,7 +46,6 @@ def titelleiste_faerben(fenster, dunkel):
if not sys.platform.startswith("win"):
return False
try:
import ctypes
fenster.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(fenster.winfo_id()) or int(fenster.frame(), 16)
wert = ctypes.c_int(1 if dunkel else 0)
@ -84,16 +83,13 @@ def pruefplatz():
if not sys.platform.startswith("win"):
return ""
try:
import ctypes
from ctypes import wintypes
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", RECT),
("rcWork", RECT), ("dwFlags", wintypes.DWORD)]
_fields_ = [("cbSize", ctypes.wintypes.DWORD), ("rcMonitor", RECT),
("rcWork", RECT), ("dwFlags", ctypes.wintypes.DWORD)]
gefunden = []
@ -106,9 +102,9 @@ def pruefplatz():
r.left, r.top, r.right - r.left))
return 1
RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, wintypes.HMONITOR,
wintypes.HDC, ctypes.POINTER(RECT),
wintypes.LPARAM)
RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HMONITOR,
ctypes.wintypes.HDC, ctypes.POINTER(RECT),
ctypes.wintypes.LPARAM)
ctypes.windll.user32.EnumDisplayMonitors(0, 0, RUECKRUF(je_monitor), 0)
# Der breiteste Nebenmonitor - bei mehreren der linkeste.
neben = sorted((m for m in gefunden if not m[0]),
@ -137,7 +133,6 @@ def auf_bildschirm(x, y, breite=0, hoehe=0):
if not sys.platform.startswith("win"):
return int(x), int(y)
try:
import ctypes
hole = ctypes.windll.user32.GetSystemMetrics
links, oben = hole(76), hole(77)
rechts, unten = links + hole(78), oben + hole(79)
@ -207,7 +202,6 @@ def schrift_vorbereiten():
_schrift_geladen = True
if not sys.platform.startswith("win"):
return SCHRIFT_NAME
import ctypes
FR_PRIVATE = 0x10
geladen = 0
for basis in _ordner():
@ -615,6 +609,120 @@ class ThemeMixin:
"""
titelleiste_faerben(win, bool(getattr(self, "_dark_mode", False)))
# ------------------------------------------------ Hell / dunkel umstellen
# Der Hinweistext gehoert dem einzelnen Programm - er sagt etwas ueber
# DIESES Programm. Hier steht nur, was dasteht, wenn ihn niemand setzt.
DARSTELLUNG_HINWEIS = "Gilt nur für das Programmfenster."
def _cfg_speichern(self):
"""Die Einstellungen wegschreiben - falls das noetig ist.
Im Rechnungstool ist die Konfiguration ein gewoehnliches Woerterbuch
und muss ausdruecklich gespeichert werden. Im Steuerrechnungstool ist
sie eine Ablage, die jeden Schreibzugriff sofort in die
Journaltabelle durchreicht - dort ist hier nichts zu tun.
"""
return
def variablen_sichern(self):
"""Alle Tk-Variablen des Fensters als {Name: Wert}.
WARUM ALLE UND NICHT EINE AUSWAHL
---------------------------------
Die _build-Methoden legen ihre Variablen selbst an
(`self.k_name = tk.StringVar()`). Nach dem Neubau stehen also neue,
leere da. Beim ersten Versuch waren Kundenname und Rechnungsnummer
weg, waehrend die Mengen blieben - die hatte ich einzeln gesichert.
Durchgegangen wird deshalb, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen; eine Liste zum
Pflegen waere genau die Sorte, die man beim naechsten Feld vergisst.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
return gesichert
def variablen_zuruecksetzen(self, gesichert):
"""Die gesicherten Werte in die neu gebauten Variablen zurueckgeben."""
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
def kinder_loeschen(self):
"""Die Oberflaeche abraeumen - aber keine fremden Fenster.
Ein tk.Toplevel IST ein Kind des Hauptfensters. Der offene
Darstellungsdialog wurde hier bisher mitgeloescht, und die Zeile
danach fasste eine Leiche an ("invalid command name .!toplevel").
Gefunden am 07.09.2026 von der Pruefkette, mit dem echten Bedienweg
gemessen: Klick auf "Dunkel" -> Farbe wechselt, Fenster ist weg,
Traceback im Log.
Der Pruefstand hatte es nicht gefunden, weil er cfg["darstellung"] von
Hand setzte und _oberflaeche_neu() direkt rief - also alles ausser dem
Weg, den ein Mensch geht. Er klickt jetzt.
"""
for kind in self.winfo_children():
if not isinstance(kind, tk.Toplevel):
kind.destroy()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung",
font=(SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340,
foreground=self.col_muted,
text=self.DARSTELLUNG_HINWEIS).pack(anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == modus(self.cfg):
return
self.cfg["darstellung"] = neu
self._cfg_speichern()
self._oberflaeche_neu()
# Der Dialog gehoert noch zur alten Darstellung - er wird
# mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen.
self._style_dialog(d)
for wert in MODI:
ttk.Radiobutton(rahmen, text=MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
mittig(d, self)
return d
class MeldungMixin:
"""Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms.

View file

@ -245,6 +245,11 @@ def aufraeumen(ordner, exe_name, behalten=2):
os.remove(pfad)
weg.append(os.path.basename(pfad))
except OSError:
# Laeuft die alte Fassung noch oder haelt ein Virenscanner die
# Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die
# Datei stoert nur, sie schadet nicht, und beim naechsten Mal
# klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg
# ist, taucht dort nicht auf.
pass
return weg

View file

@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
Regel
-----
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
"v": Tag `v1.8.1` -> VERSION "1.8.1". Wer das eine ohne das andere aendert,
"v": Tag `v1.8.2` -> VERSION "1.8.2". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.8.1"
VERSION = "1.8.2"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/rechnungstool"

View file

@ -45,7 +45,7 @@ def pfad(name):
return ""
def kachel(bild, kante, grund=(0xFF, 0xFF, 0xFF), rand=0.16, radius=0.22):
def kachel(bild, kante, rand=0.16):
"""Das Logo auf eine helle Kachel mit runden Ecken setzen.
Seine Ansage am 07.09.2026: *"bei den windows icons kannst du einen
@ -57,9 +57,9 @@ def kachel(bild, kante, grund=(0xFF, 0xFF, 0xFF), rand=0.16, radius=0.22):
das Programm nicht. Eine helle Kachel ist auf jedem sichtbar; so machen es
die meisten Programme, und deshalb sieht es auch nicht fremd aus.
`rand` ist der Abstand zum Kachelrand, `radius` die Eckenrundung - beides
als Anteil der Kantenlaenge, damit es in jeder Groesse gleich aussieht.
Die 22 % entsprechen etwa dem, was Windows fuer Kacheln verwendet.
`rand` ist der Abstand zum Kachelrand, als Anteil der Kantenlaenge - so
sieht es in jeder Groesse gleich aus. Die Rundung liegt fest bei 22 %,
etwa dem, was Windows fuer Kacheln verwendet.
"""
from PIL import Image, ImageDraw
kante = int(kante)
@ -68,8 +68,8 @@ def kachel(bild, kante, grund=(0xFF, 0xFF, 0xFF), rand=0.16, radius=0.22):
fein = kante * 4
maske = Image.new("L", (fein, fein), 0)
ImageDraw.Draw(maske).rounded_rectangle(
(0, 0, fein - 1, fein - 1), radius=int(fein * radius), fill=255)
flaeche = Image.new("RGBA", (fein, fein), tuple(grund) + (255,))
(0, 0, fein - 1, fein - 1), radius=int(fein * 0.22), fill=255)
flaeche = Image.new("RGBA", (fein, fein), (0xFF, 0xFF, 0xFF, 255))
flaeche.putalpha(maske)
innen = max(1, int(fein * (1 - 2 * rand)))
@ -126,17 +126,16 @@ def symbol_bauen(ziel):
return ""
def symbol_setzen(fenster, melden=print, dunkel=False):
def symbol_setzen(fenster, melden=print):
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
hat.
`dunkel` wird nicht mehr gebraucht und nur noch entgegengenommen, damit
bestehende Aufrufe weiterlaufen: seit das Symbol eine helle Kachel ist, ist
es in jeder Darstellung sichtbar. Vorher gab es dafuer eine zweite,
aufgehellte Datei - die ist mit der Kachel ueberfluessig geworden.
Frueher gab es dafuer eine zweite, aufgehellte ICO und einen Schalter
`dunkel`. Seit das Symbol eine helle Kachel ist, ist es in jeder
Darstellung sichtbar - beides ist damit weggefallen.
"""
p = pfad("programm.ico")
if not p: