From 0e3932ae5694446f206db56dc7b9333e5dc7e844 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Tue, 23 Jun 2026 23:44:50 +0200 Subject: [PATCH] feat: surface tone vs. writing-style template in compose window (Paket J) Macht die bislang unsichtbare Ton-/Vorlagen-Logik im Compose-Fenster sichtbar und trennt sie sauber: - Tonfall-Selektor (locker/neutral/professionell) direkt im Compose-Fenster. Aktiv nur bei Blitztext+ + Vorlage "Standard"; bei E-Mail/Spezial/Eigener Vorlage sichtbar, aber ausgegraut (Tooltip erklaert die Ueberschreibung); bei Dampf/Emoji ausgeblendet. Default aus config.text_improver_tone. - Ton + Freitext-Prompt werden ueber rewrite_text(tone=None, custom_prompt=None) durchgereicht; vollstaendig rueckwaertskompatibel (None -> bisheriges Verhalten). - Anzeige "professionell" statt "formal" ueber neue i18n-Ton-Labels; interner Wert bleibt "formal" (VALID_TONES unveraendert, keine Config-Migration). Settings-Ton-Combo nutzt jetzt ebenfalls die Labels via currentData(). - Neue Vorlage "Eigene Vorlage..." (freier Prompt) im Compose-Preset-Dropdown plus Config-Feld compose_custom_preset_text, editierbar im Einstellungen-Dialog. - Nur das Compose-Fenster betroffen; Hauptfenster/Tray unveraendert. Tests: Tonfall-Sichtbarkeit/-Aktivierung je Workflow+Vorlage, Default aus Config, Label-Mapping professionell->formal, Durchreichung an den Worker, Freitext-Prompt, i18n DE+EN-Paritaet, Config-Persistenz und Settings-Speicherung. Compose-Tests erhalten eine HOME-Isolations-Fixture, damit die lokale User-Config (Signatur) nicht mehr in die Assertions leckt (CI war bereits sauber). QT_QPA_PLATFORM=offscreen WHISPER_GUI_TESTS=1 pytest: 371 passed. --- app/blitztext_linux.py | 19 ++- app/compose_window.py | 103 +++++++++++++++- app/config.py | 12 ++ app/i18n.py | 20 ++++ app/llm_service.py | 30 ++++- tests/test_compose_window.py | 215 +++++++++++++++++++++++++++++++++- tests/test_config.py | 33 ++++++ tests/test_llm_service.py | 72 ++++++++++++ tests/test_settings_dialog.py | 28 ++++- 9 files changed, 519 insertions(+), 13 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index cdf69e8..9fd0a18 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -277,14 +277,24 @@ def init_ui(self) -> None: self.edit_llm_model.setPlaceholderText(DEFAULTS["llm_model"]) self.combo_tone = QComboBox() - self.combo_tone.addItems(["formal", "neutral", "locker"]) - self.combo_tone.setCurrentText(self.config.text_improver_tone) + for value in ("locker", "neutral", "formal"): + self.combo_tone.addItem(t(f"tone.{value}"), value) + tone_index = self.combo_tone.findData(self.config.text_improver_tone) + if tone_index < 0: + tone_index = self.combo_tone.findData("neutral") + self.combo_tone.setCurrentIndex(tone_index if tone_index >= 0 else 0) self.combo_writing_preset = QComboBox() for key in WRITING_PRESET_KEYS: self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key) self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset)) + self.edit_compose_custom_preset = QPlainTextEdit() + self.edit_compose_custom_preset.setPlainText(self.config.compose_custom_preset_text) + self.edit_compose_custom_preset.setPlaceholderText(t("settings.compose_custom_preset.placeholder")) + self.edit_compose_custom_preset.setMinimumHeight(60) + self.edit_compose_custom_preset.setMaximumHeight(110) + self.combo_emoji = QComboBox() self.combo_emoji.addItems(["wenig", "mittel", "viel"]) self.combo_emoji.setCurrentText(self.config.emoji_density) @@ -332,6 +342,8 @@ def init_ui(self) -> None: form_llm.addRow(t("settings.tone.label"), self.combo_tone) form_llm.addRow(t("settings.writing_preset.label"), self.combo_writing_preset) form_llm.addRow(create_help_label(t("settings.writing_preset.help"))) + form_llm.addRow(t("settings.compose_custom_preset.label"), self.edit_compose_custom_preset) + form_llm.addRow(create_help_label(t("settings.compose_custom_preset.help"))) form_llm.addRow(t("settings.emoji_density.label"), self.combo_emoji) form_llm.addRow(t("settings.dampf_prompt.label"), self.edit_dampf_prompt) @@ -495,8 +507,9 @@ def save_settings(self) -> None: self.config.llm_provider = self.combo_llm_provider.currentData() self.config.llm_base_url = self.edit_base_url.text().strip() self.config.llm_model = self.edit_llm_model.text().strip() - self.config.text_improver_tone = self.combo_tone.currentText() + self.config.text_improver_tone = self.combo_tone.currentData() self.config.writing_preset = self.combo_writing_preset.currentData() + self.config.compose_custom_preset_text = self.edit_compose_custom_preset.toPlainText() self.config.emoji_density = self.combo_emoji.currentText() self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip() self.config.custom_terms = self._collect_custom_terms() diff --git a/app/compose_window.py b/app/compose_window.py index 5784797..8da66a7 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -23,10 +23,10 @@ from app.i18n import t from app.llm_service import LLMService -from app.config import Config +from app.config import Config, VALID_TONES from app.paste_service import PasteService, PasteServiceError from app.workflows import WorkflowType -from app.writing_presets import WRITING_PRESET_KEYS, preset_index +from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS, preset_index logger = logging.getLogger("blitztext.compose") @@ -36,6 +36,15 @@ WorkflowType.EMOJI_TEXT, ) +# Reihenfolge des Tonfall-Selektors im Compose-Fenster. Die Werte bleiben intern +# unverändert (formal/neutral/locker); nur die Anzeige erfolgt über i18n-Labels +# (z. B. "professionell" für "formal"). +COMPOSE_TONE_ORDER: tuple[str, ...] = ("locker", "neutral", "formal") + +# Sentinel-Schlüssel für den zusätzlichen Compose-Eintrag „Eigene Vorlage…“. +# Bewusst kein echtes Writing-Preset, damit Hauptfenster/Tray unberührt bleiben. +COMPOSE_CUSTOM_PRESET_KEY = "__compose_custom__" + # In-memory ring buffer of successful generations for the current window # session. Oldest variants are trimmed once the cap is exceeded. MAX_COMPOSE_VARIANTS = 10 @@ -73,12 +82,16 @@ def __init__( workflow: WorkflowType, text: str, writing_preset: str, + tone: Optional[str] = None, + custom_prompt: Optional[str] = None, ) -> None: super().__init__() self._service = service self._workflow = workflow self._text = text self._writing_preset = writing_preset + self._tone = tone + self._custom_prompt = custom_prompt self._cancelled = False def request_cancel(self) -> None: @@ -93,6 +106,8 @@ def run(self) -> None: self._workflow, self._text, writing_preset=self._writing_preset, + tone=self._tone, + custom_prompt=self._custom_prompt, ) if self._cancelled or QThread.currentThread().isInterruptionRequested(): return @@ -157,6 +172,13 @@ def _setup_ui(self) -> None: self.cmbPreset.setMinimumWidth(180) header_row.addWidget(self.cmbPreset, 1) + self.lblTone = QLabel() + header_row.addWidget(self.lblTone) + + self.cmbTone = QComboBox() + self.cmbTone.setMinimumWidth(130) + header_row.addWidget(self.cmbTone, 1) + self.chkVoiceRouting = QCheckBox() self.chkVoiceRouting.setEnabled(False) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) @@ -259,6 +281,10 @@ def _setup_ui(self) -> None: self._install_shortcuts() self._populate_workflow_combo() self._populate_preset_combo() + self._populate_tone_combo() + self.cmbWorkflow.currentIndexChanged.connect(self._on_selectors_changed) + self.cmbPreset.currentIndexChanged.connect(self._on_selectors_changed) + self._update_tone_state() def _install_shortcuts(self) -> None: self._add_shortcut(self.txtInput, "Ctrl+Return", self._on_improve_clicked) @@ -292,6 +318,8 @@ def _populate_preset_combo(self, selected: Optional[str] = None) -> None: self.cmbPreset.clear() for key in WRITING_PRESET_KEYS: self.cmbPreset.addItem(t(f"preset.{key}.name"), key) + # Zusätzlicher, nur im Compose-Fenster sichtbarer Freitext-Eintrag. + self.cmbPreset.addItem(t("compose.preset.custom"), COMPOSE_CUSTOM_PRESET_KEY) target = selected or preset_index(self._llm_service.writing_preset) if isinstance(target, str): index = self.cmbPreset.findData(target) @@ -310,6 +338,54 @@ def _selected_preset(self) -> str: return preset return self._llm_service.writing_preset + def _populate_tone_combo(self, selected: Optional[str] = None) -> None: + if selected is None and hasattr(self, "cmbTone"): + selected = self._selected_tone() + self.cmbTone.blockSignals(True) + self.cmbTone.clear() + for value in COMPOSE_TONE_ORDER: + self.cmbTone.addItem(t(f"tone.{value}"), value) + target = selected or self._config.text_improver_tone + index = self.cmbTone.findData(target) + if index < 0: + index = self.cmbTone.findData("neutral") + self.cmbTone.setCurrentIndex(index if index >= 0 else 0) + self.cmbTone.blockSignals(False) + + def _selected_tone(self) -> str: + tone = self.cmbTone.currentData() + if isinstance(tone, str) and tone in VALID_TONES: + return tone + return self._config.text_improver_tone + + def _is_custom_preset(self) -> bool: + return self._selected_preset() == COMPOSE_CUSTOM_PRESET_KEY + + @pyqtSlot() + def _on_selectors_changed(self) -> None: + self._update_tone_state() + + def _update_tone_state(self) -> None: + """Sichtbarkeit/Aktivierung des Tonfall-Selektors je nach Workflow+Vorlage. + + Tonfall ist nur sinnvoll im Text-Verbesserer (Blitztext+) mit der Vorlage + „Standard“. Bei anderen Vorlagen bestimmt die Vorlage den Stil selbst, der + Selektor bleibt sichtbar, aber ausgegraut. Bei Dampf/Emoji entfällt er. + """ + workflow = self._selected_workflow() + is_text_improver = workflow == WorkflowType.TEXT_IMPROVER + self.lblTone.setVisible(is_text_improver) + self.cmbTone.setVisible(is_text_improver) + if not is_text_improver: + return + is_standard = self._selected_preset() == DEFAULT_PRESET_KEY + self.cmbTone.setEnabled(is_standard) + self.cmbTone.setToolTip( + t("compose.tone.tooltip_active") + if is_standard + else t("compose.tone.tooltip_preset_overrides") + ) + def _has_input(self) -> bool: return bool(self.txtInput.toPlainText().strip()) @@ -475,10 +551,12 @@ def retranslate_ui(self) -> None: """Refresh visible text to the active UI language.""" current_workflow = self._selected_workflow() current_preset = self._selected_preset() + current_tone = self._selected_tone() self.setWindowTitle(t("compose.window_title")) self.lblWorkflow.setText(t("compose.workflow.label")) self.lblPreset.setText(t("compose.preset.label")) + self.lblTone.setText(t("compose.tone.label")) self.chkVoiceRouting.setText(t("compose.voice_routing.label")) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) self.lblInput.setText(t("compose.input.label")) @@ -494,6 +572,8 @@ def retranslate_ui(self) -> None: self._populate_workflow_combo(current_workflow) self._populate_preset_combo(current_preset) + self._populate_tone_combo(current_tone) + self._update_tone_state() if self._busy: self._show_status(t("compose.status.processing")) @@ -504,10 +584,25 @@ def retranslate_ui(self) -> None: def _start_worker(self, text: str) -> None: workflow = self._selected_workflow() - writing_preset = self._selected_preset() + tone = self._selected_tone() + custom_prompt: Optional[str] = None + if self._is_custom_preset(): + # Freitext-Vorlage: Basis bleibt „Standard“, der freie System-Prompt + # wird separat durchgereicht (greift nur im Text-Verbesserer). + writing_preset = DEFAULT_PRESET_KEY + custom_prompt = self._config.compose_custom_preset_text + else: + writing_preset = self._selected_preset() thread = QThread(self) - worker = _ComposeWorker(self._llm_service, workflow, text, writing_preset) + worker = _ComposeWorker( + self._llm_service, + workflow, + text, + writing_preset, + tone=tone, + custom_prompt=custom_prompt, + ) worker.moveToThread(thread) thread.started.connect(worker.run) worker.finished.connect(self._on_worker_result) diff --git a/app/config.py b/app/config.py index fffea9e..9ff4c2c 100644 --- a/app/config.py +++ b/app/config.py @@ -51,6 +51,7 @@ "ui_language": I18N_DEFAULT_LANGUAGE, "compose_signature_text": "", "compose_signature_auto_append": False, + "compose_custom_preset_text": "", } VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"} @@ -418,6 +419,15 @@ def compose_signature_auto_append(self) -> bool: def compose_signature_auto_append(self, value: bool) -> None: self._data["compose_signature_auto_append"] = bool(value) + @property + def compose_custom_preset_text(self) -> str: + value = self._data.get("compose_custom_preset_text", "") + return value if isinstance(value, str) else "" + + @compose_custom_preset_text.setter + def compose_custom_preset_text(self, value: str) -> None: + self._data["compose_custom_preset_text"] = value if isinstance(value, str) else "" + def as_dict(self) -> dict[str, Any]: return copy.deepcopy(self._data) @@ -456,6 +466,8 @@ def _validate_and_sanitize(self) -> None: if not isinstance(self._data.get("compose_signature_text", ""), str): self._data["compose_signature_text"] = "" self._data["compose_signature_auto_append"] = bool(self._data.get("compose_signature_auto_append", False)) + if not isinstance(self._data.get("compose_custom_preset_text", ""), str): + self._data["compose_custom_preset_text"] = "" self._data["openai_api_key_env"] = _normalize_env_var_name( self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"]) diff --git a/app/i18n.py b/app/i18n.py index ca4df55..5b57fc4 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -49,6 +49,9 @@ "settings.tone.label": "Text-Verbesserer Tonfall:", "settings.writing_preset.label": "Schreibstil-Vorlage:", "settings.writing_preset.help": "Vorlage für den Text-Verbesserer (z. B. E-Mail formell, Stichpunkte). Bei 'Standard' greift der Tonfall oben; jede andere Vorlage bestimmt den Schreibstil selbst und ersetzt den Tonfall.", + "settings.compose_custom_preset.label": "Eigene Vorlage (Compose):", + "settings.compose_custom_preset.help": "Freier System-Prompt für die Vorlage „Eigene Vorlage…“ im Compose-Fenster.", + "settings.compose_custom_preset.placeholder": "z. B. Formuliere den Text als sachliche Pressemitteilung…", "settings.emoji_density.label": "Emoji-Dichte:", "settings.dampf_prompt.label": "Dampf-Umschreiber Prompt:", "settings.dampf_prompt.placeholder": "Standard-Systemprompt verwenden...", @@ -143,6 +146,13 @@ "compose.window_title": "Text verfassen", "compose.workflow.label": "Workflow:", "compose.preset.label": "Schreibstil:", + "compose.preset.custom": "Eigene Vorlage…", + "compose.tone.label": "Tonfall:", + "compose.tone.tooltip_active": "Tonfall des Text-Verbesserers (nur bei Vorlage „Standard“).", + "compose.tone.tooltip_preset_overrides": "Diese Vorlage bestimmt den Schreibstil selbst — der Tonfall wird ignoriert.", + "tone.locker": "locker", + "tone.neutral": "neutral", + "tone.formal": "professionell", "compose.voice_routing.label": "Sprachaufnahme hierher umleiten", "compose.voice_routing.help": "TODO/Future-Hook: In Phase I-1 noch nicht verdrahtet.", "compose.input.label": "Entwurf", @@ -231,6 +241,9 @@ "settings.tone.label": "Text improver tone:", "settings.writing_preset.label": "Writing style preset:", "settings.writing_preset.help": "Preset for the text improver (e.g. formal email, bullet points). With 'Standard', the tone above is used; every other preset determines the writing style itself and replaces the tone.", + "settings.compose_custom_preset.label": "Custom preset (Compose):", + "settings.compose_custom_preset.help": "Free system prompt for the 'Custom preset…' entry in the compose window.", + "settings.compose_custom_preset.placeholder": "e.g. Rewrite the text as a factual press release…", "settings.emoji_density.label": "Emoji density:", "settings.dampf_prompt.label": "Dampf rewrite prompt:", "settings.dampf_prompt.placeholder": "Use default system prompt...", @@ -325,6 +338,13 @@ "compose.window_title": "Compose Text", "compose.workflow.label": "Workflow:", "compose.preset.label": "Style:", + "compose.preset.custom": "Custom preset…", + "compose.tone.label": "Tone:", + "compose.tone.tooltip_active": "Text improver tone (only with the 'Standard' preset).", + "compose.tone.tooltip_preset_overrides": "This preset defines the writing style itself — the tone is ignored.", + "tone.locker": "casual", + "tone.neutral": "neutral", + "tone.formal": "professional", "compose.voice_routing.label": "Route voice input here", "compose.voice_routing.help": "TODO/future hook: not wired in phase I-1 yet.", "compose.input.label": "Draft", diff --git a/app/llm_service.py b/app/llm_service.py index b2a7498..eef6fbe 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -147,6 +147,8 @@ def _rewrite_for_workflow( workflow: WorkflowType, text: str, writing_preset: Optional[str] = None, + tone: Optional[str] = None, + custom_prompt: Optional[str] = None, ) -> str: if workflow not in LLM_WORKFLOWS: raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}") @@ -157,8 +159,15 @@ def _rewrite_for_workflow( if workflow == WorkflowType.DAMPF_ABLASSEN: return self.dampf_ablassen(text, custom_system_prompt=self.dampf_system_prompt) if workflow == WorkflowType.TEXT_IMPROVER: - preset = get_preset(writing_preset or self.writing_preset) - return self.text_improver(text, tone=self.tone, custom_prompt=preset.system_prompt) + # Ein expliziter Freitext-Prompt (z. B. „Eigene Vorlage…“) hat + # Vorrang vor der gewählten Vorlage. Sonst gilt die Vorlage; nur + # bei „Standard“ (leerer Preset-Prompt) greift der Tonfall. + effective_tone = tone if tone is not None else self.tone + if custom_prompt is not None and custom_prompt.strip(): + system_prompt = custom_prompt + else: + system_prompt = get_preset(writing_preset or self.writing_preset).system_prompt + return self.text_improver(text, tone=effective_tone, custom_prompt=system_prompt) if workflow == WorkflowType.EMOJI_TEXT: return self.emoji_text(text, density=self.emoji_density) raise LLMServiceError(f"Unsupported workflow: {workflow}") @@ -244,6 +253,19 @@ def rewrite_text( workflow: WorkflowType, text: str, writing_preset: Optional[str] = None, + tone: Optional[str] = None, + custom_prompt: Optional[str] = None, ) -> str: - """Direct text entry point for the compose window.""" - return self._rewrite_for_workflow(workflow, text, writing_preset=writing_preset) + """Direct text entry point for the compose window. + + ``tone`` und ``custom_prompt`` sind optional und rückwärtskompatibel: + ``tone=None`` nutzt weiterhin den global konfigurierten Ton des Service, + ``custom_prompt=None`` lässt die gewählte Vorlage unverändert greifen. + """ + return self._rewrite_for_workflow( + workflow, + text, + writing_preset=writing_preset, + tone=tone, + custom_prompt=custom_prompt, + ) diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 70ec400..ebb9b62 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -20,6 +20,10 @@ def __init__(self, result: str = "OK", error: Exception | None = None) -> None: self.result = result self.error = error self.calls: list[tuple[WorkflowType, str, str | None]] = [] + # Separate records keep the legacy 3-tuple ``calls`` assertions intact + # while still exposing the new tone/custom_prompt plumbing. + self.tone_calls: list[str | None] = [] + self.custom_prompt_calls: list[str | None] = [] self.api_key = "DUMMY_COMPOSE_SECRET_TOKEN_123" self.writing_preset = "standard" @@ -28,12 +32,24 @@ def rewrite_text( workflow: WorkflowType, text: str, writing_preset: str | None = None, + tone: str | None = None, + custom_prompt: str | None = None, ) -> str: self.calls.append((workflow, text, writing_preset)) + self.tone_calls.append(tone) + self.custom_prompt_calls.append(custom_prompt) if self.error is not None: raise self.error return self.result + @property + def last_tone(self) -> str | None: + return self.tone_calls[-1] if self.tone_calls else None + + @property + def last_custom_prompt(self) -> str | None: + return self.custom_prompt_calls[-1] if self.custom_prompt_calls else None + class _FakePasteService: def __init__(self) -> None: @@ -58,6 +74,20 @@ def reset_language(): set_language(DEFAULT_LANGUAGE) +@pytest.fixture(autouse=True) +def _isolated_config_home(tmp_path, monkeypatch): + """Keep the developer's real ~/.config/blitztext-linux out of these tests. + + ``ComposeWindow`` reads the live ``Config`` (signature, auto-append, custom + preset text). Pointing ``HOME`` at a temp dir guarantees clean defaults, so + assertions like ``output == "OK"`` stay deterministic regardless of locally + saved settings. CI already runs against a clean home; this makes local runs + match it. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + yield + + @pytest.fixture def qapp(): from PyQt6.QtWidgets import QApplication @@ -450,8 +480,10 @@ def test_navigation_disabled_while_busy(qapp): release = threading.Event() class _BlockingLLM(_FakeLLMService): - def rewrite_text(self, workflow, text, writing_preset=None): + def rewrite_text(self, workflow, text, writing_preset=None, tone=None, custom_prompt=None): self.calls.append((workflow, text, writing_preset)) + self.tone_calls.append(tone) + self.custom_prompt_calls.append(custom_prompt) release.wait(2.0) return self.result @@ -664,3 +696,184 @@ def test_compose_signature_leaves_unrelated_brackets(compose_window, qapp): # Unrelated bracketed tokens are never treated as a signature placeholder. assert window.txtOutput.toPlainText() == "Siehe [Anhang] und [Datum].\n\nTim Baumann" + + +# --- Paket J: Tonfall-Selektor & Eigene Vorlage im Compose-Fenster ---------- + +from app.compose_window import COMPOSE_CUSTOM_PRESET_KEY # noqa: E402 + + +def _select_workflow(window, workflow: WorkflowType) -> None: + window.cmbWorkflow.setCurrentIndex(window.cmbWorkflow.findData(workflow)) + + +def _select_preset(window, preset_key: str) -> None: + window.cmbPreset.setCurrentIndex(window.cmbPreset.findData(preset_key)) + + +@gui_only +def test_tone_selector_visible_and_enabled_for_standard(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, "standard") + qapp.processEvents() + assert window.cmbTone.isVisible() is True + assert window.cmbTone.isEnabled() is True + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_tone_selector_disabled_for_nonstandard_preset(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, "email_formal") + qapp.processEvents() + assert window.cmbTone.isVisible() is True + assert window.cmbTone.isEnabled() is False + assert window.cmbTone.toolTip() == t("compose.tone.tooltip_preset_overrides") + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_tone_selector_disabled_for_custom_preset(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, COMPOSE_CUSTOM_PRESET_KEY) + qapp.processEvents() + assert window.cmbTone.isVisible() is True + assert window.cmbTone.isEnabled() is False + finally: + window.close() + qapp.processEvents() + + +@gui_only +@pytest.mark.parametrize("workflow", [WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT]) +def test_tone_selector_hidden_for_non_text_improver(qapp, workflow): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, workflow) + qapp.processEvents() + assert window.cmbTone.isVisible() is False + assert window.lblTone.isVisible() is False + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_tone_default_comes_from_config(qapp): + config = Config() + config.text_improver_tone = "formal" + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), config) + try: + assert window.cmbTone.currentData() == "formal" + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_tone_labels_show_professionell_not_formal(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + try: + labels = [window.cmbTone.itemText(i) for i in range(window.cmbTone.count())] + assert "professionell" in labels + assert "formal" not in labels + # Internal value stays "formal" for backward compatibility. + idx = window.cmbTone.findText("professionell") + assert window.cmbTone.itemData(idx) == "formal" + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_selected_tone_is_passed_to_worker(qapp): + llm = _FakeLLMService() + window = ComposeWindow(llm, _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, "standard") + window.cmbTone.setCurrentIndex(window.cmbTone.findData("formal")) + window.txtInput.setPlainText("Hallo Welt") + window.btnAction.click() + _wait_until(qapp, lambda: not window._busy and window._worker_thread is None and llm.calls) + assert llm.last_tone == "formal" + assert llm.last_custom_prompt is None + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_custom_preset_passes_config_prompt_to_worker(qapp): + config = Config() + config.compose_custom_preset_text = "FREITEXT-PROMPT" + llm = _FakeLLMService() + window = ComposeWindow(llm, _FakePasteService(), config) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, COMPOSE_CUSTOM_PRESET_KEY) + window.txtInput.setPlainText("Hallo Welt") + window.btnAction.click() + _wait_until(qapp, lambda: not window._busy and window._worker_thread is None and llm.calls) + # Base preset falls back to "standard"; the free prompt is plumbed separately. + assert llm.calls[-1][2] == "standard" + assert llm.last_custom_prompt == "FREITEXT-PROMPT" + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_custom_preset_entry_present_in_combo(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + try: + assert window.cmbPreset.findData(COMPOSE_CUSTOM_PRESET_KEY) >= 0 + idx = window.cmbPreset.findData(COMPOSE_CUSTOM_PRESET_KEY) + assert window.cmbPreset.itemText(idx) == t("compose.preset.custom") + finally: + window.close() + qapp.processEvents() + + +@gui_only +@pytest.mark.parametrize("language", ["de", "en"]) +def test_tone_i18n_keys_present_and_complete(qapp, language): + set_language(language) + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + try: + for key in ( + "compose.tone.label", + "compose.tone.tooltip_active", + "compose.tone.tooltip_preset_overrides", + "compose.preset.custom", + "tone.locker", + "tone.neutral", + "tone.formal", + ): + assert t(key) != key + assert not missing_keys() + finally: + window.close() + qapp.processEvents() diff --git a/tests/test_config.py b/tests/test_config.py index 026ff2c..b3fc41e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -395,3 +395,36 @@ def test_signature_sanitized_fallback(self, config_dir): loaded = BlitztextConfig(config_dir=config_dir) assert loaded.compose_signature_text == "" assert loaded.compose_signature_auto_append is False + + +class TestComposeCustomPreset: + """Tests für das Config-Feld compose_custom_preset_text (Paket J).""" + + def test_custom_preset_default_is_empty(self, config): + assert config.compose_custom_preset_text == "" + + def test_custom_preset_persists_on_save_load(self, config, config_dir): + config.compose_custom_preset_text = "Schreibe als sachliche Pressemitteilung." + config.save() + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.compose_custom_preset_text == "Schreibe als sachliche Pressemitteilung." + + def test_custom_preset_sanitized_fallback(self, config_dir): + """Ungültige Typen fallen auf den sicheren Default zurück.""" + config_dir.mkdir(parents=True, exist_ok=True) + import json + (config_dir / "config.json").write_text( + json.dumps({ + "model": "base", + "compose_custom_preset_text": {"not": "a string"}, + }), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.compose_custom_preset_text == "" + + def test_custom_preset_setter_rejects_non_string(self, config): + config.compose_custom_preset_text = 123 # type: ignore[arg-type] + assert config.compose_custom_preset_text == "" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 3698673..b90f890 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -228,3 +228,75 @@ def test_transcript_stays_in_user_message_not_system(self, mock_client): user_message = next(m["content"] for m in messages if m["role"] == "user") assert RAW_TRANSCRIPT in user_message assert RAW_TRANSCRIPT not in system_message + + +class TestComposeTonePlumbing: + """Paket J: tone- und custom_prompt-Durchreichung über rewrite_text.""" + + def _system_message(self, mock_client): + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + return next(m["content"] for m in messages if m["role"] == "system") + + def test_tone_override_used_for_standard_preset(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") + service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + writing_preset="standard", + tone="formal", + ) + assert "Ton: formal" in self._system_message(mock_client) + # Service-State bleibt unverändert (rückwärtskompatibel). + assert service.tone == "neutral" + + def test_tone_none_falls_back_to_service_tone(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="locker") + service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + writing_preset="standard", + tone=None, + ) + assert "Ton: locker" in self._system_message(mock_client) + + def test_custom_prompt_override_used_as_system(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + writing_preset="standard", + custom_prompt="Schreibe als sachliche Pressemitteilung.", + ) + system_message = self._system_message(mock_client) + assert "Schreibe als sachliche Pressemitteilung." in system_message + assert "Formuliere es zu einem sauberen" not in system_message + + def test_empty_custom_prompt_falls_back_to_preset(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + writing_preset="standard", + custom_prompt=" ", + ) + assert "Formuliere es zu einem sauberen" in self._system_message(mock_client) + + def test_custom_terms_still_applied_with_custom_prompt(self, mock_client): + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + custom_terms=CUSTOM_TERMS, + ) + service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + custom_prompt="Freier Prompt.", + ) + system_message = self._system_message(mock_client) + assert "Freier Prompt." in system_message + assert ", ".join(CUSTOM_TERMS) in system_message + + def test_tone_and_custom_prompt_default_none_keeps_legacy_behavior(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") + service.rewrite_text(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + assert "Ton: neutral" in self._system_message(mock_client) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 669906c..36b30a3 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -145,8 +145,9 @@ def _fake_save_self(config_dir, preset_key, ui_language="de"): combo_llm_provider=_Combo(text="OpenRouter", data="openrouter"), edit_base_url=_Edit("https://openrouter.ai/api/v1"), edit_llm_model=_Edit("openai/gpt-4o"), - combo_tone=_Combo("neutral"), + combo_tone=_Combo(text="neutral", data="neutral"), combo_writing_preset=_Combo(text="E-Mail – formell", data=preset_key), + edit_compose_custom_preset=_Edit(""), combo_emoji=_Combo("mittel"), edit_dampf_prompt=_Edit(""), _collect_custom_terms=lambda: [], @@ -320,6 +321,31 @@ def test_save_settings_keeps_standard_preset(tmp_path): assert fake.config.writing_preset == "standard" +def test_save_settings_persists_tone_via_data(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + # Anzeige "professionell" → interner Wert "formal" (currentData). + fake.combo_tone = _Combo(text="professionell", data="formal") + + SettingsDialog.save_settings(fake) + + assert fake.config.text_improver_tone == "formal" + reloaded = BlitztextConfig(config_dir=config_dir) + assert reloaded.text_improver_tone == "formal" + + +def test_save_settings_persists_compose_custom_preset(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + fake.edit_compose_custom_preset = _Edit("Freier Compose-Prompt.") + + SettingsDialog.save_settings(fake) + + assert fake.config.compose_custom_preset_text == "Freier Compose-Prompt." + reloaded = BlitztextConfig(config_dir=config_dir) + assert reloaded.compose_custom_preset_text == "Freier Compose-Prompt." + + def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch): secret_value = "dummy-openai-key" monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value)