From 1819b94824c34ac3cf16d08adaf0ddbc1f6f75a2 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 00:06:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20Hauptfenster,=20Tray=20und=20Notif?= =?UTF-8?q?ications=20vollst=C3=A4ndig=20=C3=BCbersetzen=20(Paket-G-Nachzu?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paket G hatte mehrere UI-Call-Sites bei der String-Extraktion ausgelassen, sodass die Oberfläche trotz Sprachwahl "English" teils deutsch blieb. Der i18n-Completeness-Test prüft nur Key-Parität zwischen de/en, nicht ob alle sichtbaren Strings durch t() laufen — darum unbemerkt durch die CI. Übersetzt (über t(), neue Keys in app/i18n.py, de==en je 149): - Hauptfenster (main_window.py): Status "Bereit/Fehler/Aufnahme läuft…/ Transkribiere…/Verarbeite mit KI…", Buttons "Verwerfen/Diktat", History- Button mit Zähler, Tooltips "Vorlesen/Einstellungen". - Tray (blitztext_linux.py): Actions "Diktat-Modus/Verlauf…/Vorlesen…", Tray-Tooltips (Fehler/Aufnahme/Transkribiere/Verarbeite), Verlauf- Fenstertitel, Diktat-/Fehler-Notifications, Aufnahme-Fehlermeldungen. Nicht angefasst: Start/Stopp-Text des Shutter-Buttons (reiner Test-/Logik- Text, gemalt wird der Glyph), Logger-Ausgaben, Docstrings. Regressionsschutz: test_smoke_launch prüft beim Offscreen-Boot je Sprache (de/en) jetzt zusätzlich, dass Hauptfenster- und Tray-Texte der aktiven Sprache entsprechen — fängt künftige vergessene Call-Sites ab. Assertions bewusst im bestehenden Boot (kein zweiter BlitztextApp/QObject-Leak). Volle Suite 262 grün (offscreen, WHISPER_GUI_TESTS=1), stabil in deterministischer und randomisierter Reihenfolge. --- app/blitztext_linux.py | 34 +++++++++++++-------------- app/i18n.py | 48 ++++++++++++++++++++++++++++++++++++++ app/main_window.py | 24 +++++++++---------- tests/test_smoke_launch.py | 26 ++++++++++++++++++++- 4 files changed, 102 insertions(+), 30 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index fa32bc1..0b2ad9c 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -720,18 +720,18 @@ def setup_tray(self) -> None: self.menu.addSeparator() # Diktat-Modus (Toggle): sammelt Transkripte als Notizen - self.action_dictation = QAction("🎤 Diktat-Modus", self) + self.action_dictation = QAction(t("tray.dictation_mode"), self) self.action_dictation.setCheckable(True) self.action_dictation.toggled.connect(self._on_dictation_toggled) self.menu.addAction(self.action_dictation) # Verlauf anzeigen - self.action_history = QAction("📋 Verlauf…", self) + self.action_history = QAction(t("tray.history"), self) self.action_history.triggered.connect(self.show_history_panel) self.menu.addAction(self.action_history) # Vorlesen (TTS) - self.action_tts = QAction("🔊 Vorlesen…", self) + self.action_tts = QAction(t("tray.tts"), self) self.action_tts.triggered.connect(self.show_tts_window) self.menu.addAction(self.action_tts) @@ -918,7 +918,7 @@ def _start_recording(self, workflow: WorkflowType) -> None: self._set_state("RECORDING", f"workflow {workflow.value} started") except AudioRecorderError as e: logger.error("Failed to start recording: %s", e) - self.show_tray_error("Aufnahme-Fehler", f"Aufnahme konnte nicht gestartet werden: {e}") + self.show_tray_error(t("error.recording.title"), t("error.recording.start_failed").format(error=e)) self.current_workflow = None self._set_state("IDLE", "recording start failed") @@ -988,7 +988,7 @@ def _stop_recording_and_process(self) -> None: except AudioRecorderError as e: logger.error("Failed to stop recording: %s", e) - self.show_tray_error("Aufnahme-Fehler", f"Aufnahme konnte nicht sauber gestoppt werden: {e}") + self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(error=e)) self.current_workflow = None self._set_state("IDLE", "recording stop failed") @@ -1007,8 +1007,8 @@ def _on_worker_result(self, result_text: str) -> None: self._add_to_history(result_text, is_dictation=self._dictation_mode) if self._dictation_mode: notify_service.notify( - "Blitztext Diktat", - "Eintrag gespeichert ({} Wörter).".format(len(result_text.split())), + t("notify.dictation.title"), + t("notify.dictation.entry_saved").format(count=len(result_text.split())), ) self.current_workflow = None self._set_state("IDLE", "worker result") @@ -1016,8 +1016,8 @@ def _on_worker_result(self, result_text: str) -> None: @pyqtSlot(str) def _on_worker_error(self, err_msg: str) -> None: logger.error("Worker error: %s", err_msg) - self.show_tray_error("Blitztext Fehler", err_msg) - notify_service.notify("Blitztext Fehler", err_msg, urgency="critical") + self.show_tray_error(t("notify.error.title"), err_msg) + notify_service.notify(t("notify.error.title"), err_msg, urgency="critical") self.current_workflow = None self._set_state("IDLE", "worker error", keep_error=True) @@ -1031,7 +1031,7 @@ def _ensure_history_panel(self) -> HistoryPanel: max_entries=self.config.history_size, notes_folder=self.config.notes_folder, ) - panel.setWindowTitle("Blitztext – Verlauf") + panel.setWindowTitle(t("history.window_title")) panel.resize(320, 440) panel.merged.connect(self._on_dictation_merged) panel.count_changed.connect(self._on_history_count_changed) @@ -1075,12 +1075,12 @@ def set_dictation_mode(self, enabled: bool) -> None: self._ensure_history_panel() self.show_history_panel() notify_service.notify( - "Blitztext Diktat", - "Diktat-Modus aktiv. Aufnahmen werden als Notizen gesammelt.", + t("notify.dictation.title"), + t("notify.dictation.mode_active"), ) def _on_dictation_merged(self, path: str) -> None: - notify_service.notify("Blitztext Diktat", f"Zusammengeführt und gespeichert:\n{path}") + notify_service.notify(t("notify.dictation.title"), t("notify.dictation.merged").format(path=path)) def show_tts_window(self) -> None: if self._tts_window is None: @@ -1156,20 +1156,20 @@ def _on_state_changed(self) -> None: def update_tray_state(self) -> None: if self._tray_error_message: self.tray_icon.setIcon(self._tray_icons["ERROR"]) - self.tray_icon.setToolTip(f"Blitztext Fehler: {self._tray_error_message}") + self.tray_icon.setToolTip(t("tray.tooltip.error").format(message=self._tray_error_message)) elif self.state == "IDLE": self.tray_icon.setIcon(self._tray_icons["IDLE"]) self.tray_icon.setToolTip(t("app.name")) elif self.state == "RECORDING": self.tray_icon.setIcon(self._tray_icons["RECORDING"]) wf_name = self.current_workflow.value if self.current_workflow else "" - self.tray_icon.setToolTip(f"Aufnahme läuft… ({wf_name})") + self.tray_icon.setToolTip(t("tray.tooltip.recording").format(workflow=wf_name)) elif self.state == "TRANSCRIBING": self.tray_icon.setIcon(self._tray_icons["TRANSCRIBING"]) - self.tray_icon.setToolTip("Transkribiere…") + self.tray_icon.setToolTip(t("mainwindow.status.transcribing")) elif self.state == "LLM_REWRITING": self.tray_icon.setIcon(self._tray_icons["LLM_REWRITING"]) - self.tray_icon.setToolTip("Verarbeite mit KI…") + self.tray_icon.setToolTip(t("mainwindow.status.processing")) @pyqtSlot(str) def _on_hotkey_error(self, err_msg: str) -> None: diff --git a/app/i18n.py b/app/i18n.py index e454b89..e8e867c 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -126,6 +126,30 @@ "history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_", "tray.show_window": "Fenster anzeigen", "tray.writing_preset": "Schreibstil-Vorlage", + "tray.dictation_mode": "🎤 Diktat-Modus", + "tray.history": "📋 Verlauf…", + "tray.tts": "🔊 Vorlesen…", + "mainwindow.button.discard": "↺ Verwerfen", + "mainwindow.button.dictation": "✎ Diktat", + "mainwindow.button.history": "≡ Verlauf ({count})", + "mainwindow.tooltip.tts": "Vorlesen", + "mainwindow.tooltip.settings": "Einstellungen", + "mainwindow.status.ready": "Bereit", + "mainwindow.status.error": "Fehler", + "mainwindow.status.recording": "Aufnahme läuft…", + "mainwindow.status.transcribing": "Transkribiere…", + "mainwindow.status.processing": "Verarbeite mit KI…", + "history.window_title": "Blitztext – Verlauf", + "notify.dictation.title": "Blitztext Diktat", + "notify.dictation.entry_saved": "Eintrag gespeichert ({count} Wörter).", + "notify.dictation.mode_active": "Diktat-Modus aktiv. Aufnahmen werden als Notizen gesammelt.", + "notify.dictation.merged": "Zusammengeführt und gespeichert:\n{path}", + "notify.error.title": "Blitztext Fehler", + "tray.tooltip.error": "Blitztext Fehler: {message}", + "tray.tooltip.recording": "Aufnahme läuft… ({workflow})", + "error.recording.title": "Aufnahme-Fehler", + "error.recording.start_failed": "Aufnahme konnte nicht gestartet werden: {error}", + "error.recording.stop_failed": "Aufnahme konnte nicht sauber gestoppt werden: {error}", "workflow.transcription.name": "🎙 Blitztext", "workflow.local.name": "🔒 Blitztext Lokal", "workflow.text_improver.name": "✨ Blitztext+", @@ -253,6 +277,30 @@ "history.note.merged_filename_prefix": "Dictation-merged_", "tray.show_window": "Show window", "tray.writing_preset": "Writing style preset", + "tray.dictation_mode": "🎤 Dictation mode", + "tray.history": "📋 History…", + "tray.tts": "🔊 Read aloud…", + "mainwindow.button.discard": "↺ Discard", + "mainwindow.button.dictation": "✎ Dictation", + "mainwindow.button.history": "≡ History ({count})", + "mainwindow.tooltip.tts": "Read aloud", + "mainwindow.tooltip.settings": "Settings", + "mainwindow.status.ready": "Ready", + "mainwindow.status.error": "Error", + "mainwindow.status.recording": "Recording…", + "mainwindow.status.transcribing": "Transcribing…", + "mainwindow.status.processing": "Processing with AI…", + "history.window_title": "Blitztext – History", + "notify.dictation.title": "Blitztext Dictation", + "notify.dictation.entry_saved": "Entry saved ({count} words).", + "notify.dictation.mode_active": "Dictation mode active. Recordings are collected as notes.", + "notify.dictation.merged": "Merged and saved:\n{path}", + "notify.error.title": "Blitztext Error", + "tray.tooltip.error": "Blitztext Error: {message}", + "tray.tooltip.recording": "Recording… ({workflow})", + "error.recording.title": "Recording error", + "error.recording.start_failed": "Recording could not be started: {error}", + "error.recording.stop_failed": "Recording could not be stopped cleanly: {error}", "workflow.transcription.name": "🎙 Blitztext", "workflow.local.name": "🔒 Blitztext Local", "workflow.text_improver.name": "✨ Blitztext+", diff --git a/app/main_window.py b/app/main_window.py index ddffae3..78089c0 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -198,7 +198,7 @@ def _setup_ui(self) -> None: self._rec_indicator = QLabel("●") self._rec_indicator.setStyleSheet(f"color: {theme.STATE_IDLE}; font-size: 10px;") status_row.addWidget(self._rec_indicator) - self._status_label = QLabel("Bereit") + self._status_label = QLabel(t("mainwindow.status.ready")) self._status_label.setStyleSheet("font-size: 12px; font-weight: 600;") status_row.addWidget(self._status_label) self._timer_label = QLabel("00:00") @@ -213,14 +213,14 @@ def _setup_ui(self) -> None: # Sekundaerzeile: Verwerfen + Diktat (Pills) sec_row = QHBoxLayout() sec_row.setSpacing(6) - self._btn_discard = QPushButton("↺ Verwerfen") + self._btn_discard = QPushButton(t("mainwindow.button.discard")) self._btn_discard.setMinimumHeight(28) self._btn_discard.setStyleSheet("border-radius: 14px; font-weight: 600;") self._btn_discard.setEnabled(False) self._btn_discard.clicked.connect(self._on_discard_clicked) sec_row.addWidget(self._btn_discard) - self._btn_dictation = QPushButton("✎ Diktat") + self._btn_dictation = QPushButton(t("mainwindow.button.dictation")) self._btn_dictation.setMinimumHeight(28) self._btn_dictation.setStyleSheet("border-radius: 14px; font-weight: 600;") self._btn_dictation.setCheckable(True) @@ -231,7 +231,7 @@ def _setup_ui(self) -> None: # Unterzeile: Verlauf (mit Zaehler), Vorlesen, Einstellungen bottom_row = QHBoxLayout() bottom_row.setSpacing(6) - self._btn_history = QPushButton("≡ Verlauf (0)") + self._btn_history = QPushButton(t("mainwindow.button.history").format(count=0)) self._btn_history.setMinimumHeight(28) self._btn_history.setStyleSheet(f"border-radius: 14px; color: {theme.APP_TEXT_DIM};") self._btn_history.clicked.connect(self._controller.show_history_panel) @@ -242,7 +242,7 @@ def _setup_ui(self) -> None: self._btn_tts.setStyleSheet( f"border-radius: 14px; font-size: 13px; color: {theme.APP_TEXT_DIM}; padding: 0;" ) - self._btn_tts.setToolTip("Vorlesen") + self._btn_tts.setToolTip(t("mainwindow.tooltip.tts")) self._btn_tts.clicked.connect(self._controller.show_tts_window) bottom_row.addWidget(self._btn_tts) @@ -251,7 +251,7 @@ def _setup_ui(self) -> None: self._btn_settings.setStyleSheet( f"border-radius: 14px; font-size: 13px; color: {theme.APP_TEXT_DIM}; padding: 0;" ) - self._btn_settings.setToolTip("Einstellungen") + self._btn_settings.setToolTip(t("mainwindow.tooltip.settings")) self._btn_settings.clicked.connect(self._controller.show_settings_dialog) bottom_row.addWidget(self._btn_settings) layout.addLayout(bottom_row) @@ -295,16 +295,16 @@ def update_state(self, state: str, workflow: Optional[WorkflowType], error: Opti self._workflow_combo.setEnabled(state == "IDLE") if error: - self._set_status("Fehler", theme.STATE_ERROR) + self._set_status(t("mainwindow.status.error"), theme.STATE_ERROR) elif recording: # Der Workflow steht bereits im Dropdown darueber — kein Suffix noetig. - self._set_status("Aufnahme läuft…", theme.STATE_RECORDING) + self._set_status(t("mainwindow.status.recording"), theme.STATE_RECORDING) elif state == "TRANSCRIBING": - self._set_status("Transkribiere…", theme.STATE_PROCESSING) + self._set_status(t("mainwindow.status.transcribing"), theme.STATE_PROCESSING) elif state == "LLM_REWRITING": - self._set_status("Verarbeite mit KI…", theme.STATE_PROCESSING) + self._set_status(t("mainwindow.status.processing"), theme.STATE_PROCESSING) else: - self._set_status("Bereit", theme.STATE_IDLE) + self._set_status(t("mainwindow.status.ready"), theme.STATE_IDLE) if recording: if self._rec_start is None: @@ -336,7 +336,7 @@ def _set_status(self, text: str, color: str) -> None: self._rec_indicator.setStyleSheet(f"color: {color}; font-size: 10px;") def set_history_count(self, count: int) -> None: - self._btn_history.setText(f"≡ Verlauf ({count})") + self._btn_history.setText(t("mainwindow.button.history").format(count=count)) def set_dictation_checked(self, checked: bool) -> None: self._btn_dictation.blockSignals(True) diff --git a/tests/test_smoke_launch.py b/tests/test_smoke_launch.py index cd45bb6..0c175f6 100644 --- a/tests/test_smoke_launch.py +++ b/tests/test_smoke_launch.py @@ -21,7 +21,7 @@ import pytest -from app.i18n import DEFAULT_LANGUAGE, get_language, set_language +from app.i18n import DEFAULT_LANGUAGE, get_language, set_language, t _GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" gui_only = pytest.mark.skipif(not _GUI, reason="benötigt WHISPER_GUI_TESTS=1 (Display)") @@ -56,6 +56,30 @@ def test_app_boots_idles_and_exits_clean(ui_language, tmp_path): assert exit_code == 0 assert get_language() == ui_language + + # Regressionsschutz: Hauptfenster- und Tray-Texte laufen über t() und + # spiegeln die aktive Sprache. Fängt vergessene Call-Sites ab, die der + # reine Key-Vollständigkeitstest (test_i18n) nicht erkennt. Bewusst im + # selben Boot wie der Smoke-Test (kein zweiter BlitztextApp im Prozess, + # um QObject-Leaks in Folgetests zu vermeiden). + win = app._main_window + assert win._btn_discard.text() == t("mainwindow.button.discard") + assert win._btn_dictation.text() == t("mainwindow.button.dictation") + assert win._btn_history.text() == t("mainwindow.button.history").format(count=0) + assert win._btn_tts.toolTip() == t("mainwindow.tooltip.tts") + assert win._btn_settings.toolTip() == t("mainwindow.tooltip.settings") + assert win._status_label.text() == t("mainwindow.status.ready") + assert app.action_dictation.text() == t("tray.dictation_mode") + assert app.action_history.text() == t("tray.history") + assert app.action_tts.text() == t("tray.tts") + + # Sprachabhängigkeit echt verankern (nicht nur Tautologie über t()). + if ui_language == "en": + assert "Discard" in win._btn_discard.text() + assert "History" in app.action_history.text() + else: + assert "Verwerfen" in win._btn_discard.text() + assert "Verlauf" in app.action_history.text() finally: # Idempotentes Cleanup, damit kein Thread in Folgetests nachhaengt. if app is not None: