From a4a045f30301b2a54fac568fd1ad723d886ea340 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 13:54:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20ein=20echtes=20Setup=20?= =?UTF-8?q?=E2=80=94=20Voraussetzungs-Pruefung,=20Zielordner,=20Ablage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: "Dann haette ich beim 'Setup' auch erwartet das nen echtes setup passiert - wo will ich das hinspeichern, nen pre requirement check usw. Da kommt garnichts Rippy geht einfach auf." Er hatte recht. Ein Doppelklick installierte stillschweigend nach %LOCALAPPDATA%\Rippy und oeffnete das Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts -- auch nicht, ob sein Rechner ueberhaupt kann, was Rippy braucht. Jetzt: ein Assistent mit acht Pruefungen, zwei Ordner-Wahlen und drei Schaltern. Auf diesem Rechner nachgemessen: OK Betriebssystem Windows 11 (Build 26200) OK Fenster (WebView2) 151.0.4129.107 OK Optisches Laufwerk \.\G: OK HandBrakeCLI 1.11.2 ! MakeMKV nicht gefunden -> wird von makemkv.com geholt OK Speicherplatz 59.1 GB frei OK Schreibrecht C:\Users\...\AppData\Local\Rippy OK Netzwerk-Port 7788 ist frei Drei Entwurfsentscheidungen, die dazugehoeren: * Nur ein FEHLER blockiert, eine Warnung nicht. Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein Laufwerk = Warnung (reine Komprimier-Maschine ist ein vorgesehener Betriebsfall). Kein Schreibrecht = Fehler. * Jeder Befund sagt, was zu tun ist. Ein Befund ohne Abhilfe laesst den Nutzer ratlos zurueck -- ein Test haelt das fuer jede Pruefung fest. * Die Pruefungen stehen in einrichtung.py, komplett ohne Fenster pruefbar. Im Fenstermodul steht nur Aufbau und Bruecke. WebView2 statt Win32-Dialog: Es ist ohnehin da (Entscheid 4), der Assistent kostet damit NULL zusaetzliche Bytes. Die Seite laedt nichts aus dem Netz -- sie muss auf einem Rechner ohne Internet aufgehen, dort wird sie am dringendsten gebraucht. Ein Test haelt das fest. --still und --ohne-assistent gehen weiterhin den stummen Weg. Geht das Fenster nicht auf, wird mit den Vorgaben installiert und gesagt warum -- ein Setup, das gar nichts tut, weil sein Fenster nicht aufging, waere schlimmer als eines, das nicht fragt. fix(tools): %ProgramFiles(x86)% wurde auf echten Rechnern NIE aufgeloest Beim Bauen der Pruefung aufgefallen, und es ist ein Lehrstueck: Testumgebung {"ProgramFiles(x86)": ...} -> C:\Program Files (x86)\... echtes Windows {"PROGRAMFILES(X86)": ...} -> '' Windows behandelt Umgebungsvariablen ohne Ruecksicht auf die Schreibweise und legt sie in os.environ GROSS ab. Der Vergleich in _entfalten war exakt, fand nie eine Uebereinstimmung, und die bekannten Installationsorte fielen aus der Kandidatenliste. MakeMKV wurde also NUR ueber die Registry-Rueckfallebene gefunden -- wo die fehlt oder anders aussieht, fand Rippy nichts. Aufgefallen ist es nur, weil hier zum ersten Mal eine ECHTE Umgebung benutzt wurde. Der Test spritzte genau die Schreibweise ein, die im Muster stand: Er war gruener als die Wirklichkeit. Beide Tests nehmen jetzt die echte Schreibweise, und einer prueft direkt gegen os.environ. Beim Zurueckdrehen des Fehlers rot gesehen (5 Fehlschlaege). Ampel lokal: 691 gruen, ruff sauber. Assistent im Bildschirmfoto belegt. Co-Authored-By: Claude Opus 5 --- src/rippy/einrichtung.py | 260 +++++++++++++++++++++ src/rippy/setup_fenster.py | 389 ++++++++++++++++++++++++++++++++ src/rippy/test_einrichtung.py | 188 +++++++++++++++ src/rippy/test_setup_fenster.py | 122 ++++++++++ src/rippy/tools/katalog.py | 33 ++- src/rippy/tools/test_katalog.py | 39 +++- src/rippy/windows_app.py | 48 +++- 7 files changed, 1073 insertions(+), 6 deletions(-) create mode 100644 src/rippy/einrichtung.py create mode 100644 src/rippy/setup_fenster.py create mode 100644 src/rippy/test_einrichtung.py create mode 100644 src/rippy/test_setup_fenster.py diff --git a/src/rippy/einrichtung.py b/src/rippy/einrichtung.py new file mode 100644 index 0000000..fa086df --- /dev/null +++ b/src/rippy/einrichtung.py @@ -0,0 +1,260 @@ +"""Der Einrichtungs-Assistent: prüfen, fragen, installieren. + +## Der Befund des Commanders (28.08.2026) + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend +nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts +gefragt und erfuhr nichts — auch nicht, ob sein Rechner überhaupt kann, was +Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht, +seit wann. + +## Was hier passiert und was NICHT + +Dieses Modul enthält die **Prüfungen und die Entscheidungen** — keine +Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne +WebView2, ohne Fenster und ohne Installation prüfbar bleibt. + +## Die drei Sorten Befund + + ok geht + warnung geht, aber der Nutzer sollte es wissen + fehler geht NICHT — die Installation würde nichts nützen + +Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine +Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für +jede Sorte: + +* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine + reine Komprimier-Maschine — ein vorgesehener Betriebsfall (`install.sh` sagt + das seit Langem: „läuft trotzdem durch"). +* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann. +* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden. + +## Warum jede Prüfung sagt, was zu tun ist + +Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder +Eintrag ein `abhilfe`-Feld — leer nur dann, wenn wirklich nichts zu tun ist. +""" + +import os +import shutil +import socket +import sys + +OK = "ok" +WARNUNG = "warnung" +FEHLER = "fehler" + +# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist +# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der +# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft. +MINDESTPLATZ_MB = 500 + +STANDARD_PORT = 7788 + + +def _eintrag(name, zustand, text, abhilfe=""): + return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe} + + +# ── Die einzelnen Prüfungen ───────────────────────────────────────────── +def pruefe_windows(version=None) -> dict: + """Windows 10 oder neuer. Darunter gibt es kein WebView2.""" + if os.name != "nt": + return _eintrag("Betriebssystem", FEHLER, "Kein Windows", + "Für Linux gibt es die Docker- und die Headless-Fassung.") + bau = version if version is not None else sys.getwindowsversion().build + if bau >= 22000: + return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau) + if bau >= 10240: + return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau) + return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau, + "Rippy braucht mindestens Windows 10.") + + +def pruefe_webview(version=None) -> dict: + """Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem. + + Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser + funktioniert, er ist nur weniger schön. + """ + if version is None: + from rippy import fenster + + version = fenster.webview2_version() + if version: + return _eintrag("Fenster (WebView2)", OK, version) + return _eintrag( + "Fenster (WebView2)", WARNUNG, "nicht gefunden", + 'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos ' + 'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").') + + +def pruefe_platz(ziel: str, frei_mb=None) -> dict: + """Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner.""" + from rippy import betrieb + + if frei_mb is None: + pfad = betrieb.naechster_vorhandener(ziel) + if not pfad: + return _eintrag("Speicherplatz", FEHLER, + "Der Ort ist nicht erreichbar: %s" % ziel, + "Einen anderen Zielordner wählen.") + try: + frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024) + except OSError as e: + return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e, + "Einen anderen Zielordner wählen.") + if frei_mb >= MINDESTPLATZ_MB: + return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024)) + return _eintrag("Speicherplatz", FEHLER, + "nur %d MB frei, gebraucht werden %d MB" + % (frei_mb, MINDESTPLATZ_MB), + "Platz schaffen oder einen anderen Zielordner wählen.") + + +def pruefe_schreibrecht(ziel: str, probe=None) -> dict: + """Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten. + + Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage + nach dem Schreibrecht anhand des Nur-Lesen-Flags, nicht anhand der + Zugriffsrechte. Also wird eine Datei angelegt und wieder gelöscht. + """ + if probe is not None: + geklappt, grund = probe + else: + geklappt, grund = _schreibprobe(ziel) + if geklappt: + return _eintrag("Schreibrecht", OK, ziel) + return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), + 'Einen Ordner unterhalb von „Benutzer" wählen — dort ' + 'braucht Rippy keine Administratorrechte.') + + +def _schreibprobe(ziel: str): + import tempfile + + try: + os.makedirs(ziel, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe", + delete=True): + pass + return True, "" + except OSError as e: + return False, e.strerror or str(e) + + +def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict: + """Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen.""" + if belegt is None: + belegt = _port_belegt(port) + if not belegt: + return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port) + # Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist + # eine Neuinstallation genau richtig. + return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port, + "Läuft Rippy schon? Sonst im Setup einen anderen Port " + "wählen.") + + +def _port_belegt(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + try: + s.bind(("127.0.0.1", port)) + return False + except OSError: + return True + + +def pruefe_laufwerk(laufwerke=None) -> dict: + """Ein optisches Laufwerk. WARNUNG, kein Fehler. + + Rippy ohne Laufwerk ist eine reine Komprimier-Maschine — ein vorgesehener + Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt. + """ + if laufwerke is None: + try: + from rippy import drives + + laufwerke = drives.treiber().list_optical_devices() + except Exception: # noqa: BLE001 + laufwerke = [] + if laufwerke: + return _eintrag("Optisches Laufwerk", OK, + ", ".join(str(x) for x in laufwerke)) + return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden", + "Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. " + "Ein USB-Brenner reicht.") + + +def pruefe_werkzeuge(bestand=None) -> list: + """MakeMKV und HandBrake — je ein eigener Eintrag. + + MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre + es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`). + """ + from rippy.tools import katalog + + bestand = katalog.bestand() if bestand is None else bestand + ergebnis = [] + for name, eintrag in sorted(bestand.items()): + titel = eintrag.get("titel", name) + if eintrag.get("da"): + ergebnis.append(_eintrag(titel, OK, + eintrag.get("version") or "gefunden")) + elif name == "handbrake": + ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert")) + else: + ergebnis.append(_eintrag( + titel, WARNUNG, "nicht gefunden", + "Das Setup lädt es von makemkv.com und startet den Installer " + "des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen.")) + return ergebnis + + +# ── Alles zusammen ────────────────────────────────────────────────────── +def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list: + """Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge.""" + return ([pruefe_windows(), pruefe_webview(), pruefe_laufwerk()] + + pruefe_werkzeuge() + + [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)]) + + +def darf_installieren(pruefungen: list) -> bool: + """Blockiert NUR bei einem echten Fehler. (reine Funktion) + + Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, + der nur warnt, ist eine Falle. + """ + return not any(p["zustand"] == FEHLER for p in pruefungen) + + +def zusammenfassung(pruefungen: list) -> str: + """Ein Satz über den Gesamtzustand. (reine Funktion)""" + fehler = [p for p in pruefungen if p["zustand"] == FEHLER] + warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG] + if fehler: + return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler) + if warnungen: + return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen) + return "Alles bereit." + + +def vorgaben() -> dict: + """Womit die Maske vorbelegt wird.""" + from rippy import betrieb + from rippy.platform import win_registry as reg + + return { + "ziel": reg.standard_installationsordner(), + "ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"), + "port": STANDARD_PORT, + "autostart": True, + "verknuepfungen": True, + "werkzeuge_holen": True, + } diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py new file mode 100644 index 0000000..0e55b87 --- /dev/null +++ b/src/rippy/setup_fenster.py @@ -0,0 +1,389 @@ +"""Das Fenster des Einrichtungs-Assistenten. + +## Warum es das gibt + +Commander am 28.08.2026: + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +## Warum WebView2 und nicht ein Windows-Dialog + +Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit +(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null +zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen +ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau +ausdrücklich ausgeschlossen. + +## Warum die Prüfungen NICHT hier stehen + +Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. +Was hier steht, lässt sich nicht testen — also steht hier so wenig wie +möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. + +## Was passiert, wenn das Fenster nicht aufgeht + +Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein +Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer +als eines, das nicht fragt. +""" + +import threading + +from rippy import einrichtung + +TITEL = "Rippy einrichten" +BREITE = 860 +HOEHE = 760 + + +class Bruecke: + """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. + + Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel + wie überall sonst in diesem Projekt. + """ + + def __init__(self): + self.ergebnis = None + self._fenster = None + self._fortschritt = [] + + # ── Auskunft ──────────────────────────────────────────────────────── + def vorgaben(self) -> dict: + return einrichtung.vorgaben() + + def pruefen(self, ziel: str = "", port: int = 0) -> dict: + """Der Voraussetzungs-Bericht für die angegebenen Werte.""" + vorgabe = einrichtung.vorgaben() + befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], + int(port or vorgabe["port"])) + return { + "befunde": befunde, + "darf": einrichtung.darf_installieren(befunde), + "text": einrichtung.zusammenfassung(befunde), + } + + def ordner_waehlen(self, vorschlag: str = "") -> str: + """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" + import webview + + try: + gewaehlt = self._fenster.create_file_dialog( + webview.FOLDER_DIALOG, directory=vorschlag or "") + except Exception: # noqa: BLE001 + return "" + if not gewaehlt: + return "" + return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) + + def fortschritt(self) -> list: + """Was seit dem letzten Abruf passiert ist. Leert sich dabei.""" + zeilen, self._fortschritt = self._fortschritt, [] + return zeilen + + # ── Handeln ───────────────────────────────────────────────────────── + def installieren(self, optionen: dict) -> dict: + """Installiert im Hintergrund-Thread und gibt sofort zurück. + + Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter + Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster + sieht aus wie ein Absturz. + """ + threading.Thread(target=self._installieren_wirklich, + args=(optionen or {},), daemon=True).start() + return {"gestartet": True} + + def _installieren_wirklich(self, optionen: dict) -> None: + from rippy import windows_app + + def melden(text, anteil=None): + self._fortschritt.append(str(text)) + + try: + melden("Dateien werden abgelegt …") + ziel = windows_app.installieren( + ziel=optionen.get("ziel") or None, + autostart=bool(optionen.get("autostart", True)), + starten_danach=False, + verknuepfen=bool(optionen.get("verknuepfungen", True)), + ) + if optionen.get("ablage"): + melden("Ablage wird gespeichert …") + _ablage_speichern(optionen["ablage"]) + if optionen.get("werkzeuge_holen", True): + melden("Werkzeuge werden geprüft …") + from rippy.tools import einrichten as werkzeug_einrichtung + + bericht = werkzeug_einrichtung.sicherstellen(fortschritt=melden) + melden(werkzeug_einrichtung.bericht(bericht)) + bereit = bericht["bereit"] + else: + bereit = True + self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} + melden("Fertig.") + except Exception as ex: # noqa: BLE001 + # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig + # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr + # passiert. + self.ergebnis = {"ok": False, + "fehler": "%s: %s" % (type(ex).__name__, ex)} + melden("Fehlgeschlagen: %s" % ex) + + def stand(self) -> dict: + """None, solange es läuft. Danach das Ergebnis.""" + return self.ergebnis or {} + + def schliessen(self) -> None: + try: + self._fenster.destroy() + except Exception: # noqa: BLE001 + pass + + +def _ablage_speichern(pfad: str) -> None: + """Die gewählte Ablage in die Einstellungen schreiben. + + Über die laufende Datenbank, nicht über eine Konfigurationsdatei: Das UI + liest `outputDir` aus den Einstellungen, und genau dort muss der Wert + landen, damit die Wahl im Assistenten überhaupt etwas bewirkt. + """ + from rippy import config, daemon, store + + werte = config.laden() + store.verbinden(config.datenbank_url(werte)) + import sys + + sys.path.insert(0, daemon._api_pfad()) + import db as api_db # noqa: E402 + + api_db.init_db() + aktuell = api_db.get_settings() or {} + aktuell["outputDir"] = pfad + api_db.save_settings(aktuell) + + +def starten() -> dict: + """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. + + `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, + ob er ohne Assistent installiert. + """ + import webview + + bruecke = Bruecke() + fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, + width=BREITE, height=HOEHE, + min_size=(720, 560), text_select=True) + bruecke._fenster = fenster + webview.start(gui="edgechromium", private_mode=False) + return bruecke.ergebnis or {} + + +def moeglich() -> tuple: + """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" + from rippy import fenster as fenster_modul + + return fenster_modul.verfuegbar() + + +# ── Die Seite ─────────────────────────────────────────────────────────── +# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine +# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne +# Internet aufgehen — dort wird er am dringendsten gebraucht. +SEITE = r""" + + +
+

Rippy einrichten

+
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
+
+
+
+

Voraussetzungen

+
wird geprüft …
+
+ +
+

Wohin

+ + + +
+ +
+

Sonst noch

+ + + +
+ +
+

Läuft

+
+
+
+
+
wird geprüft …
+ + +
+ + +""" + +# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). +SEITE_LAENGE = len(SEITE) + + +def seite_enthaelt(text: str) -> bool: + """Kleine Hilfe für Tests: Steht etwas in der Seite?""" + return text in SEITE diff --git a/src/rippy/test_einrichtung.py b/src/rippy/test_einrichtung.py new file mode 100644 index 0000000..8b5eebd --- /dev/null +++ b/src/rippy/test_einrichtung.py @@ -0,0 +1,188 @@ +"""Der Einrichtungs-Assistent — die Pruefungen, ohne etwas zu installieren. + +## Warum es diese Tests gibt + +Commander am 28.08.2026: *„Dann haette ich beim ‚Setup' auch erwartet das nen +echtes setup passiert — wo will ich das hinspeichern, nen pre requirement +check usw. Da kommt garnichts Rippy geht einfach auf."* + +Er hatte recht: Ein Doppelklick installierte stillschweigend und oeffnete das +Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts. + +Alle Pruefungen sind eingespritzt — kein Fenster, keine Installation, kein +WebView2. Sonst waere „kein Laufwerk" nur auf einem Rechner ohne Laufwerk +pruefbar, und genau dort sieht es niemand. +""" + +import os + +from rippy import einrichtung as e + + +def _p(zustand, name="X"): + return {"name": name, "zustand": zustand, "text": "", "abhilfe": ""} + + +# ── Was blockiert und was nicht ───────────────────────────────────────── +def test_ohne_fehler_darf_installiert_werden(): + assert e.darf_installieren([_p(e.OK), _p(e.WARNUNG)]) is True + + +def test_ein_fehler_blockiert(): + assert e.darf_installieren([_p(e.OK), _p(e.FEHLER)]) is False + + +def test_eine_warnung_blockiert_NICHT(): + """Eine Warnung, die den Knopf sperrt, ist eine Bevormundung. Rippy ohne + optisches Laufwerk ist ein vorgesehener Betriebsfall — eine reine + Komprimier-Maschine.""" + assert e.darf_installieren([_p(e.WARNUNG), _p(e.WARNUNG)]) is True + + +# ── Die einzelnen Pruefungen ──────────────────────────────────────────── +def test_windows_10_und_11_gehen(): + assert e.pruefe_windows(22631)["zustand"] == e.OK + assert e.pruefe_windows(19045)["zustand"] == e.OK + + +def test_zu_altes_windows_ist_ein_fehler(): + befund = e.pruefe_windows(7601) # Windows 7 + assert befund["zustand"] == e.FEHLER + assert befund["abhilfe"], "ein Befund ohne Abhilfe laesst den Nutzer ratlos" + + +def test_fehlendes_webview_ist_nur_eine_warnung(): + """Ohne WebView2 faellt Rippy auf den Browser zurueck. Das ist weniger + schoen, aber es LAEUFT — ein Fehler waere hier eine Luege.""" + befund = e.pruefe_webview(version="") + assert befund["zustand"] == e.WARNUNG + assert "Browser" in befund["abhilfe"] + + +def test_vorhandenes_webview_ist_ok(): + assert e.pruefe_webview(version="151.0.4129.107")["zustand"] == e.OK + + +def test_zu_wenig_platz_ist_ein_fehler(): + befund = e.pruefe_platz("C:\\egal", frei_mb=100) + assert befund["zustand"] == e.FEHLER + assert "500" in befund["text"] + + +def test_genug_platz_ist_ok(): + assert e.pruefe_platz("C:\\egal", frei_mb=5000)["zustand"] == e.OK + + +def test_fehlendes_schreibrecht_ist_ein_fehler(): + """`os.access(..., W_OK)` luegt unter Windows — es beantwortet die Frage + anhand des Nur-Lesen-Flags, nicht anhand der Zugriffsrechte. Deshalb wird + wirklich geschrieben.""" + befund = e.pruefe_schreibrecht("C:\\Programme\\Rippy", + probe=(False, "Zugriff verweigert")) + assert befund["zustand"] == e.FEHLER + assert "Benutzer" in befund["abhilfe"] + + +def test_schreibprobe_am_echten_ordner(tmp_path): + befund = e.pruefe_schreibrecht(str(tmp_path)) + assert befund["zustand"] == e.OK + # Und die Probe darf nichts hinterlassen. + assert list(tmp_path.iterdir()) == [] + + +def test_belegter_port_ist_nur_eine_warnung(): + """Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine + Neuinstallation genau richtig.""" + befund = e.pruefe_port(7788, belegt=True) + assert befund["zustand"] == e.WARNUNG + assert "Rippy" in befund["abhilfe"] + + +def test_freier_port_ist_ok(): + assert e.pruefe_port(7788, belegt=False)["zustand"] == e.OK + + +def test_kein_laufwerk_ist_nur_eine_warnung(): + """install.sh sagt seit Langem: ohne Laufwerk „laeuft trotzdem durch, dann + ist das eine reine KOMPRIMIER-Maschine".""" + befund = e.pruefe_laufwerk(laufwerke=[]) + assert befund["zustand"] == e.WARNUNG + assert "komprimieren" in befund["abhilfe"] + + +def test_gefundenes_laufwerk_wird_genannt(): + befund = e.pruefe_laufwerk(laufwerke=["\\\\.\\G:"]) + assert befund["zustand"] == e.OK + assert "G:" in befund["text"] + + +# ── Die Werkzeuge ─────────────────────────────────────────────────────── +def _bestand(makemkv, handbrake): + return { + "makemkv": {"titel": "MakeMKV", "da": makemkv, "version": "1.18.4", + "pflicht": True}, + "handbrake": {"titel": "HandBrakeCLI", "da": handbrake, + "version": "1.11.2", "pflicht": False}, + } + + +def test_fehlendes_makemkv_ist_eine_warnung_mit_ausweg(): + befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(False, True))} + assert befunde["MakeMKV"]["zustand"] == e.WARNUNG + assert "makemkv.com" in befunde["MakeMKV"]["abhilfe"] + + +def test_fehlendes_handbrake_ist_kein_problem(): + """Es liegt im Paket — das Setup legt es beim Installieren daneben.""" + befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, False))} + assert befunde["HandBrakeCLI"]["zustand"] == e.OK + assert "mitgeliefert" in befunde["HandBrakeCLI"]["text"] + + +def test_vorhandene_werkzeuge_zeigen_ihre_version(): + befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, True))} + assert befunde["MakeMKV"]["text"] == "1.18.4" + + +# ── Die Zusammenfassung ───────────────────────────────────────────────── +def test_zusammenfassung_nennt_die_fehler_beim_namen(): + text = e.zusammenfassung([_p(e.FEHLER, "Speicherplatz"), _p(e.OK, "Port")]) + assert "nicht möglich" in text + assert "Speicherplatz" in text + + +def test_zusammenfassung_nennt_die_hinweise(): + text = e.zusammenfassung([_p(e.WARNUNG, "MakeMKV"), _p(e.OK, "Port")]) + assert "MakeMKV" in text + assert "nicht möglich" not in text + + +def test_alles_gruen_sagt_es_auch_so(): + assert e.zusammenfassung([_p(e.OK), _p(e.OK)]) == "Alles bereit." + + +def test_jede_pruefung_hat_dieselbe_form(): + """Ein fehlendes Feld waere in der Oberflaeche ein `undefined`.""" + for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""), + e.pruefe_laufwerk(laufwerke=[]), + e.pruefe_platz("C:\\x", frei_mb=9999), + e.pruefe_port(7788, belegt=False)] + + e.pruefe_werkzeuge(_bestand(False, False))): + assert set(befund) == {"name", "zustand", "text", "abhilfe"}, befund + assert befund["zustand"] in (e.OK, e.WARNUNG, e.FEHLER) + if befund["zustand"] != e.OK: + assert befund["abhilfe"], ( + "%s sagt nicht, was zu tun ist" % befund["name"]) + + +# ── Die Vorgaben ──────────────────────────────────────────────────────── +def test_vorgaben_sind_vollstaendig(): + if os.name != "nt": + import pytest + pytest.skip("Registry-Vorgaben gibt es nur unter Windows") + v = e.vorgaben() + assert set(v) == {"ziel", "ablage", "port", "autostart", + "verknuepfungen", "werkzeuge_holen"} + assert v["port"] == 7788 + assert v["ziel"] and v["ablage"] + assert "/app/" not in v["ablage"], "Container-Pfad in einer Windows-Vorgabe" diff --git a/src/rippy/test_setup_fenster.py b/src/rippy/test_setup_fenster.py new file mode 100644 index 0000000..7896cc3 --- /dev/null +++ b/src/rippy/test_setup_fenster.py @@ -0,0 +1,122 @@ +"""Der Einrichtungs-Assistent: Bruecke und Seite, ohne ein Fenster zu oeffnen. + +Ein Fenster laesst sich im Test nicht bedienen. Geprueft wird deshalb das, +was DAHINTER haengt: was die Seite abfragen darf, was beim Installieren +passiert, und dass ein Fehlschlag sichtbar wird statt zu verschwinden. + +Hinweis zu den Backslashes: Windows-Pfade stehen hier in ROHEN Zeichenketten +(`r"..."`). In einer normalen waere `C:\\Rippy` die ungueltige Escape-Sequenz +Backslash-R. +""" + +from rippy import setup_fenster + + +class LeeresFenster: + def __init__(self, antwort=None): + self.antwort = antwort + self.zerstoert = False + + def create_file_dialog(self, *a, **k): + if self.antwort is None: + raise RuntimeError("kein Dialog") + return self.antwort + + def destroy(self): + self.zerstoert = True + + +# ── Die Seite ─────────────────────────────────────────────────────────── +def test_die_seite_fragt_nach_dem_ablageort(): + """Die ausdrueckliche Frage des Commanders: „wo will ich das + hinspeichern".""" + assert setup_fenster.seite_enthaelt('id="ablage"') + assert setup_fenster.seite_enthaelt("Fertige Filme") + + +def test_die_seite_zeigt_die_voraussetzungen(): + """„nen pre requirement check usw." — vorher kam da gar nichts.""" + assert setup_fenster.seite_enthaelt("Voraussetzungen") + assert setup_fenster.seite_enthaelt("pywebview.api.pruefen") + + +def test_die_seite_laedt_nichts_aus_dem_netz(): + """Der Assistent muss auf einem Rechner OHNE Internet aufgehen — dort + wird er am dringendsten gebraucht.""" + for muster in ("http://", "https://", "