From 678fe276f900706f51c17331a87ab4dac97f7b0b Mon Sep 17 00:00:00 2001
From: Hitonabi
Date: Sat, 29 Aug 2026 11:53:12 +0200
Subject: [PATCH] feat(windows): Arbeitsordner im Setup, Beta-Key auf
Knopfdruck
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Zwei Wuensche des Commanders vom 28.08.2026.
## 1. „kannst du noch einbauen das man den arbeitsordner usw. in den
## Einstellungen setzen kann und direkt im setup?"
In den Einstellungen ging es seit 35370fd (freies Pfadfeld plus die Laufwerke
als Ein-Klick-Wahl). Im SETUP wurde bisher nur nach Programmordner und Ablage
gefragt — der Arbeitsordner tauchte erst auf, wenn schon installiert war.
Das ist die falsche Reihenfolge: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat es
zu spaet erfahren — genau so lief am 25.07.2026 die VM-Platte voll.
Neu ist auch ein Waechter, der alle drei Arten von halb angeschlossenem Feld
faengt (kein Eingabefeld, keine Vorbelegung, wird beim Installieren nicht
mitgeschickt). Der dritte Fall ist der gemeinste: Man waehlt etwas, es
passiert nichts, und niemand sagt warum.
## 2. „der könnte theoretisch auch automatisch ausgelesen werden, ich glaube
## das web rippy kann das"
Er hatte recht. `makemkv_key.refresh_loop()` laeuft seit jeher taeglich mit
(main.py, Startereignis). GEMESSEN am 28.08.2026: Forum antwortet in 3,3 s,
gueltiger Key mit 62 Zeichen, und in seinen Einstellungen lag bereits einer.
Es war nur nichts davon zu sehen. Kein Knopf, keine Meldung, keine Zeitangabe
— im Feld stand ein Key, und ob der von Hand kam oder von selbst, wusste
niemand. Eine Automatik, die man nicht sehen kann, ist fuer den Benutzer
keine.
Dazu kam eine echte Luecke: Die Schleife startet 60 Sekunden nach dem Server.
Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt ohne Key — MakeMKV
faellt in den 30-Tage-Testmodus. Das Setup holt ihn jetzt selbst.
Der geholte Key wandert NICHT ueber die Leitung zurueck: Er steht in den
Einstellungen, und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn
zu verlieren.
Rechtlich unveraendert: oeffentliche Beta-LIZENZ der Software, KEIN
Disc-Schluessel. Rippy liefert und verteilt keine Disc-Schluessel.
803 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5
---
docker/api/main.py | 49 +++++++++++++++++
docker/api/test_makemkv_key.py | 70 ++++++++++++++++++++++++
docker/ui/src/pages/Settings.tsx | 76 +++++++++++++++++++++++---
src/rippy/einrichtung.py | 7 +++
src/rippy/setup_fenster.py | 83 ++++++++++++++++++++++++++--
src/rippy/test_einrichtung.py | 7 +--
src/rippy/test_setup_fenster.py | 93 ++++++++++++++++++++++++++++++++
7 files changed, 372 insertions(+), 13 deletions(-)
diff --git a/docker/api/main.py b/docker/api/main.py
index 271f5c2..c20ec6b 100644
--- a/docker/api/main.py
+++ b/docker/api/main.py
@@ -2406,6 +2406,55 @@ async def get_keydb_status():
return await asyncio.to_thread(sammle)
+@app.post("/system/makemkv-key/holen")
+async def makemkv_key_holen():
+ """Holt den kostenlosen MakeMKV-Beta-Key JETZT aus dem Forum.
+
+ ## Warum es diesen Knopf gibt (Commander 28.08.2026)
+
+ > „Bezüglich des MKV Beta Keys — der könnte theoretisch auch automatisch
+ > ausgelesen werden, ich glaube das web rippy kann das"
+
+ Er hatte recht: `makemkv_key.refresh_loop()` läuft seit jeher täglich mit
+ (main.py, Startereignis). Nachgemessen: Forum antwortet in 3,3 Sekunden,
+ Key mit 62 Zeichen.
+
+ Nur war davon **nichts zu sehen**. Kein Knopf, keine Zeitangabe, kein
+ Hinweis — im Einstellungsfeld stand ein Key, und ob der von Hand kam oder
+ von selbst, wusste niemand. Eine Automatik, die man nicht sehen kann, ist
+ für den Benutzer keine.
+
+ Der Key wechselt etwa monatlich. Wer nicht warten will, bis die Schleife
+ das nächste Mal nachsieht, drückt hier.
+
+ Rechtlich unverändert: öffentliche Beta-LIZENZ der Software, KEIN
+ Disc-Schlüssel.
+ """
+ import makemkv_key as key_modul
+
+ try:
+ key = await asyncio.to_thread(key_modul.fetch_current_key)
+ except Exception as e: # noqa: BLE001
+ raise HTTPException(
+ status_code=502,
+ detail=f"Das MakeMKV-Forum antwortet nicht ({type(e).__name__}). "
+ "Der Key lässt sich weiter von Hand eintragen.")
+ if not key:
+ raise HTTPException(
+ status_code=502,
+ detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
+ "Vermutlich hat sich die Seite geändert.")
+ geaendert = await asyncio.to_thread(key_modul._apply_key, key)
+ await asyncio.to_thread(
+ db.add_log, "success", "makemkv-key",
+ "Beta-Key aus dem Forum geholt (…%s)%s"
+ % (key[-6:], "" if geaendert else " — war schon der aktuelle"))
+ # Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
+ # und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
+ # verlieren.
+ return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:]}
+
+
@app.post("/system/keydb")
async def set_keydb(request: KeydbRequest):
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
diff --git a/docker/api/test_makemkv_key.py b/docker/api/test_makemkv_key.py
index ecfd257..c13c5cb 100644
--- a/docker/api/test_makemkv_key.py
+++ b/docker/api/test_makemkv_key.py
@@ -1,4 +1,6 @@
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
+import pytest
+
from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY
assert treffer.startswith("T-")
+
+
+# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
+#
+# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
+# > ausgelesen werden, ich glaube das web rippy kann das"
+#
+# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
+# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
+# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
+# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
+
+@pytest.fixture
+def klient():
+ from fastapi.testclient import TestClient
+
+ from main import app
+ return TestClient(app)
+
+
+def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
+ import main as api
+
+ monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
+ monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
+ monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
+
+ antwort = klient.post("/system/makemkv-key/holen")
+ assert antwort.status_code == 200
+ daten = antwort.json()
+ assert daten["geholt"] is True
+ assert daten["geaendert"] is True
+ assert daten["endet_auf"] == _KEY[-6:]
+
+
+def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
+ """Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
+ ein zweiter Weg, ihn zu verlieren."""
+ import main as api
+
+ monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
+ monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
+ monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
+
+ text = klient.post("/system/makemkv-key/holen").text
+ assert _KEY not in text
+ assert _KEY[:20] not in text
+
+
+def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
+ monkeypatch):
+ """Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
+ def wirft(*a, **k):
+ raise OSError("Verbindung abgelehnt")
+
+ monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
+ antwort = klient.post("/system/makemkv-key/holen")
+ assert antwort.status_code == 502
+ assert "Forum" in antwort.json()["detail"]
+
+
+def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
+ """Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
+ sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
+ monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
+ antwort = klient.post("/system/makemkv-key/holen")
+ assert antwort.status_code == 502
+ assert "geändert" in antwort.json()["detail"]
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index e6ce294..479c958 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -213,6 +213,9 @@ export default function SettingsPage() {
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState([])
+ // Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
+ const [keyHolt, setKeyHolt] = useState(false)
+ const [keyMeldung, setKeyMeldung] = useState('')
const [keystore, setKeystore] = useState(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef(null)
@@ -253,6 +256,34 @@ export default function SettingsPage() {
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
}
+ /*
+ * Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
+ *
+ * Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
+ * und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
+ * zwei Wege, ihn zu verlieren.
+ */
+ const keyJetztHolen = async () => {
+ setKeyHolt(true)
+ setKeyMeldung('')
+ try {
+ const r = await api.post('/system/makemkv-key/holen')
+ const antwort = await api.get('/settings')
+ setSettings({ ...defaultSettings, ...antwort.data })
+ setKeyMeldung(r.data?.geaendert
+ ? `Neuer Key geholt (endet auf …${r.data.endet_auf}).`
+ : `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
+ toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
+ } catch (e: any) {
+ const grund = e?.response?.data?.detail
+ || 'Das Forum ist nicht erreichbar.'
+ setKeyMeldung('Fehlgeschlagen: ' + grund)
+ toast('error', grund)
+ } finally {
+ setKeyHolt(false)
+ }
+ }
+
const keydbHochladen = async (datei: File) => {
setKeydbBusy(true)
try {
@@ -1295,13 +1326,44 @@ export default function SettingsPage() {
- handleChange('makemkvAppKey', e.target.value)}
- placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
- />
+ {/*
+ Der Beta-Key holt sich selbst — man sah es nur nirgends.
+
+ Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
+ könnte theoretisch auch automatisch ausgelesen werden, ich
+ glaube das web rippy kann das." Er hatte recht:
+ `makemkv_key.refresh_loop()` läuft täglich mit, seit es die
+ Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
+ Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
+ darin von Hand kam oder von selbst, war nicht zu erkennen.
+ Eine Automatik, die man nicht sehen kann, ist für den
+ Benutzer keine.
+ */}
+
+ handleChange('makemkvAppKey', e.target.value)}
+ placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
+ />
+
+
+
+ {keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ + 'der Key wechselt etwa monatlich.'}
+
+
+
{/*
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
diff --git a/src/rippy/einrichtung.py b/src/rippy/einrichtung.py
index 1f853e4..2604699 100644
--- a/src/rippy/einrichtung.py
+++ b/src/rippy/einrichtung.py
@@ -403,6 +403,13 @@ def vorgaben() -> dict:
return {
"ziel": reg.standard_installationsordner(),
"ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"),
+ # Der Arbeitsordner gehoert ins Setup, nicht erst in die Einstellungen
+ # (Commander-Wunsch 28.08.2026). Grund: Der Roh-Rip einer 4K-UHD ist
+ # bis zu 100 GB gross. Wer das erst NACH der ersten vollen Platte
+ # erfaehrt, hat es zu spaet erfahren — genau so lief am 25.07.2026
+ # die VM-Platte voll.
+ "arbeit": betrieb.arbeits_vorgabe({}, container=False,
+ plattform_name="win32"),
"port": STANDARD_PORT,
"autostart": True,
"verknuepfungen": True,
diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py
index 090fcea..0a8608f 100644
--- a/src/rippy/setup_fenster.py
+++ b/src/rippy/setup_fenster.py
@@ -221,10 +221,18 @@ class Bruecke:
starten_danach=False,
verknuepfen=bool(optionen.get("verknuepfungen", True)),
)
+ orte = {}
if optionen.get("ablage"):
+ orte["outputDir"] = optionen["ablage"]
+ # Der Arbeitsordner gehoert mit ins Setup (Commander 28.08.2026).
+ # Leer heisst „nimm die Vorgabe" — dann steht hier nichts, und der
+ # Betrieb rechnet ihn sich neben der Ablage aus.
+ if optionen.get("arbeit"):
+ orte["workDir"] = optionen["arbeit"]
+ if orte:
self._lauf.beginne("ablage")
- melden("Ablage wird gespeichert …")
- _ablage_speichern(optionen["ablage"])
+ melden("Ordner werden gespeichert …")
+ _einstellungen_schreiben(orte)
self._lauf.beginne("werkzeuge")
if optionen.get("werkzeuge_holen", True):
melden("Werkzeuge werden geprüft …")
@@ -235,6 +243,7 @@ class Bruecke:
aktualisieren=bool(optionen.get("aktualisieren", False)))
melden(werkzeug_einrichtung.bericht(bericht))
bereit = bericht["bereit"]
+ _beta_key_holen(melden)
else:
bereit = True
self._lauf.beginne("fertig")
@@ -299,16 +308,73 @@ def _ablage_speichern(pfad: str) -> None:
Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war
ohnehin überflüssig.
"""
+ _einstellungen_schreiben({"outputDir": pfad})
+
+
+def _einstellungen_schreiben(neu: dict) -> None:
+ """Werte in die Einstellungen mischen — lesen, setzen, zurückschreiben.
+
+ `save_settings` überschreibt das GANZE JSON, deshalb erst lesen.
+ """
from rippy import config, store
werte = config.laden()
store.verbinden(config.datenbank_url(werte))
store.init_db()
aktuell = store.get_settings() or {}
- aktuell["outputDir"] = pfad
+ aktuell.update(neu)
store.save_settings(aktuell)
+def _beta_key_holen(melden=None) -> str:
+ """Den kostenlosen MakeMKV-Beta-Key aus dem Forum holen und ablegen.
+
+ ## Warum das ins Setup gehört (Commander 28.08.2026)
+
+ > „Bezüglich des MKV Beta Keys — der könnte theoretisch auch automatisch
+ > ausgelesen werden, ich glaube das web rippy kann das"
+
+ Er hat recht, es kann: `docker/api/makemkv_key.py` holt den Key aus dem
+ Forum-Thread t=1053 und läuft seit jeher täglich mit. Nachgemessen am
+ 28.08.2026: Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen.
+
+ Nur half das beim ERSTEN Rip nicht: Die Schleife startet 60 Sekunden nach
+ dem Serverstart. Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt
+ ohne Key — und MakeMKV fällt in den 30-Tage-Testmodus.
+
+ Ohne Key ist Rippy nicht kaputt; DVDs gehen weiterhin. Deshalb ist ein
+ Fehlschlag hier auch kein Abbruch, sondern eine Zeile im Protokoll.
+
+ Rechtlich unverändert: Das ist die öffentliche Beta-LIZENZ der Software,
+ NICHT ein Disc-Schlüssel. Rippy liefert und verteilt keine Disc-Schlüssel.
+ """
+ melden = melden or (lambda *a, **k: None)
+ try:
+ import sys
+
+ from rippy.daemon import _api_pfad
+
+ # `_api_pfad` gibt den Pfad NUR zurueck — eingehaengt wird er in
+ # daemon.starten(). Hier laeuft der Assistent, nicht der Dienst.
+ pfad = _api_pfad()
+ if pfad and pfad not in sys.path:
+ sys.path.insert(0, pfad)
+ import makemkv_key
+
+ key = makemkv_key.fetch_current_key()
+ except Exception as ex: # noqa: BLE001
+ melden(" Beta-Key nicht erreichbar (%s) — nachtragbar unter "
+ "Einstellungen." % type(ex).__name__)
+ return ""
+ if not key:
+ melden(" Kein Beta-Key im Forum gefunden — nachtragbar unter "
+ "Einstellungen.")
+ return ""
+ _einstellungen_schreiben({"makemkvAppKey": key})
+ melden(" MakeMKV-Beta-Key geholt (…%s)." % key[-6:])
+ return key
+
+
def starten() -> dict:
"""Öffnet den Assistenten. Gibt zurück, was daraus geworden ist.
@@ -426,6 +492,15 @@ SEITE = r"""
+
@@ -532,6 +607,7 @@ SEITE = r"""
await pywebview.api.installieren({
ziel: $('ziel').value,
ablage: $('ablage').value,
+ arbeit: $('arbeit').value,
port: Number($('port').value),
autostart: $('autostart').checked,
verknuepfungen: $('verknuepfungen').checked,
@@ -578,6 +654,7 @@ SEITE = r"""
const v = await pywebview.api.vorgaben();
$('ziel').value = v.ziel;
$('ablage').value = v.ablage;
+ $('arbeit').value = v.arbeit;
$('port').value = v.port;
$('autostart').checked = v.autostart;
$('verknuepfungen').checked = v.verknuepfungen;
diff --git a/src/rippy/test_einrichtung.py b/src/rippy/test_einrichtung.py
index dbf80a5..948ad0a 100644
--- a/src/rippy/test_einrichtung.py
+++ b/src/rippy/test_einrichtung.py
@@ -238,11 +238,12 @@ def test_vorgaben_sind_vollstaendig():
import pytest
pytest.skip("Registry-Vorgaben gibt es nur unter Windows")
v = e.vorgaben()
- assert set(v) == {"ziel", "ablage", "port", "autostart",
+ assert set(v) == {"ziel", "ablage", "arbeit", "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"
+ assert v["ziel"] and v["ablage"] and v["arbeit"]
+ for feld in ("ablage", "arbeit"):
+ assert "/app/" not in v[feld], "Container-Pfad in einer Windows-Vorgabe"
def test_netzwerkpfad_als_administrator_wird_erklaert():
diff --git a/src/rippy/test_setup_fenster.py b/src/rippy/test_setup_fenster.py
index c80ff76..f45f1fc 100644
--- a/src/rippy/test_setup_fenster.py
+++ b/src/rippy/test_setup_fenster.py
@@ -284,3 +284,96 @@ def test_updates_melden_was_offen_ist(monkeypatch):
assert [o["titel"] for o in stand["offen"]] == ["MakeMKV"]
assert stand["offen"][0]["installiert"] == "1.18.2"
assert stand["offen"][0]["neueste"] == "1.18.4"
+
+
+# ── Der Arbeitsordner im Setup (Commander 28.08.2026) ───────────────────
+#
+# > „kannst du noch einbauen das man den arbeitsordner usw. in den
+# > Einstellungen setzen kann und direkt im setup?"
+#
+# Warum das nicht Kosmetik ist: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
+# gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat
+# es zu spaet erfahren — am 25.07.2026 lief genau so die VM-Platte voll.
+
+
+def test_die_seite_fragt_nach_dem_arbeitsordner():
+ assert setup_fenster.seite_enthaelt('id="arbeit"')
+ assert setup_fenster.seite_enthaelt("Arbeitsordner für die Rohdaten")
+
+
+def test_der_arbeitsordner_hat_einen_durchsuchen_knopf():
+ """Einen Pfad abzutippen ist die Sorte Bedienung, die niemand macht."""
+ assert setup_fenster.seite_enthaelt("waehlen('arbeit')")
+
+
+def test_jede_vorgabe_hat_ein_feld_UND_wird_mitgeschickt():
+ """Der Waechter gegen halb angeschlossene Felder.
+
+ Ein Feld kann auf drei Arten kaputt sein, und alle drei sehen im Fenster
+ gleich aus: kein Eingabefeld, keine Vorbelegung, oder es wird beim
+ Installieren nicht mitgeschickt. Der dritte Fall ist der gemeinste — man
+ waehlt etwas, es passiert nichts, und niemand sagt warum.
+ """
+ from rippy import einrichtung
+
+ # Schalter heissen im HTML anders (checkbox) und werden separat geprueft.
+ pfade_und_zahlen = {"ziel", "ablage", "arbeit", "port"}
+ for name in pfade_und_zahlen:
+ assert name in einrichtung.vorgaben(), "%s fehlt in vorgaben()" % name
+ assert setup_fenster.seite_enthaelt('id="%s"' % name), \
+ "kein Eingabefeld fuer %s" % name
+ assert setup_fenster.seite_enthaelt("$('%s').value = v.%s" % (name, name)), \
+ "%s wird nicht vorbelegt" % name
+ assert setup_fenster.seite_enthaelt("%s: " % name), \
+ "%s wird beim Installieren nicht mitgeschickt" % name
+
+
+def test_der_arbeitsordner_landet_als_workDir_in_den_einstellungen(monkeypatch):
+ """Der Name muss stimmen: Die Oberflaeche und der Worker lesen `workDir`."""
+ geschrieben = {}
+ monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
+ lambda neu: geschrieben.update(neu))
+ monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
+
+ class StilleWerkzeuge:
+ @staticmethod
+ def sicherstellen(**k):
+ return {"bereit": True}
+
+ @staticmethod
+ def bericht(b):
+ return "ok"
+
+ import sys
+ import types
+
+ modul = types.ModuleType("rippy.tools.einrichten")
+ modul.sicherstellen = StilleWerkzeuge.sicherstellen
+ modul.bericht = StilleWerkzeuge.bericht
+ monkeypatch.setitem(sys.modules, "rippy.tools.einrichten", modul)
+
+ from rippy import windows_app
+ monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
+
+ b = setup_fenster.Bruecke()
+ b._installieren_wirklich({"ablage": r"D:\Filme", "arbeit": r"E:\Roh",
+ "werkzeuge_holen": True})
+
+ assert geschrieben.get("workDir") == r"E:\Roh"
+ assert geschrieben.get("outputDir") == r"D:\Filme"
+ assert b.ergebnis["ok"] is True
+
+
+def test_ohne_angabe_wird_NICHTS_geschrieben(monkeypatch):
+ """Leer heisst „nimm die Vorgabe" — dann darf kein leerer Pfad in die
+ Einstellungen wandern und die Berechnung ueberschreiben."""
+ geschrieben = {}
+ monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
+ lambda neu: geschrieben.update(neu))
+ monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
+ from rippy import windows_app
+ monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
+
+ b = setup_fenster.Bruecke()
+ b._installieren_wirklich({"ablage": "", "arbeit": "", "werkzeuge_holen": False})
+ assert geschrieben == {}