From c9ee689985039f5633725426a5d60dec4f154ff6 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Tue, 23 Jun 2026 09:53:56 +0200 Subject: [PATCH 1/2] feat: add compose signature support --- app/blitztext_linux.py | 13 +++++- app/compose_window.py | 56 +++++++++++++++++++++++ app/config.py | 22 ++++++++++ app/i18n.py | 8 ++++ tests/test_compose_window.py | 83 +++++++++++++++++++++++++++++++---- tests/test_config.py | 36 +++++++++++++++ tests/test_settings_dialog.py | 2 + 7 files changed, 211 insertions(+), 9 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 51bd141..cdf69e8 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -372,6 +372,15 @@ def init_ui(self) -> None: form_general.addRow(t("settings.ui_language.label"), self.combo_ui_language) form_general.addRow(create_help_label(t("settings.ui_language.help"))) + self.edit_compose_signature = QPlainTextEdit() + self.edit_compose_signature.setPlainText(self.config.compose_signature_text) + self.edit_compose_signature.setMaximumHeight(80) + form_general.addRow(t("settings.general.signature_label"), self.edit_compose_signature) + + self.check_compose_signature_auto_append = QCheckBox(t("settings.general.signature_auto_append")) + self.check_compose_signature_auto_append.setChecked(self.config.compose_signature_auto_append) + form_general.addRow(self.check_compose_signature_auto_append) + self.btn_open_config = QPushButton(t("settings.open_config.button")) self.btn_open_config.clicked.connect(self._open_config_file) form_general.addRow(self.btn_open_config) @@ -496,6 +505,8 @@ def save_settings(self) -> None: self.config.notes_folder = self.edit_notes_folder.text().strip() self.config.history_size = int(self.spin_history_size.currentText()) self.config.ui_language = self.combo_ui_language.currentData() + self.config.compose_signature_text = self.edit_compose_signature.toPlainText() + self.config.compose_signature_auto_append = self.check_compose_signature_auto_append.isChecked() self.config.save() set_language(self.config.ui_language) @@ -1103,7 +1114,7 @@ def show_tts_window(self) -> None: def _ensure_compose_window(self) -> ComposeWindow: if self._compose_window is None: - window = ComposeWindow(self.llm_service, self.paste_service) + window = ComposeWindow(self.llm_service, self.paste_service, self.config) try: from app import theme window.setWindowIcon(theme.create_app_icon()) diff --git a/app/compose_window.py b/app/compose_window.py index 31e8f41..aa2efb0 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import re from typing import Optional from PyQt6.QtCore import QObject, QThread, Qt, pyqtSignal, pyqtSlot @@ -22,6 +23,7 @@ from app.i18n import t from app.llm_service import LLMService +from app.config import Config from app.paste_service import PasteService, PasteServiceError from app.workflows import WorkflowType from app.writing_presets import WRITING_PRESET_KEYS, preset_index @@ -95,11 +97,13 @@ def __init__( self, llm_service: LLMService, paste_service: PasteService, + config: Config, parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) self._llm_service = llm_service self._paste_service = paste_service + self._config = config self._worker_thread: Optional[QThread] = None self._worker: Optional[_ComposeWorker] = None self._detached_threads: list[QThread] = [] @@ -221,6 +225,10 @@ def _setup_ui(self) -> None: self.btnPaste.clicked.connect(self._on_paste_clicked) footer_row.addWidget(self.btnPaste) + self.btnSignature = QPushButton() + self.btnSignature.clicked.connect(self._on_append_signature_clicked) + footer_row.addWidget(self.btnSignature) + self.btnClose = QPushButton() self.btnClose.clicked.connect(self.close) footer_row.addWidget(self.btnClose) @@ -332,6 +340,15 @@ def _sync_state(self) -> None: self.btnCopy.setEnabled(has_output) self.btnPaste.setEnabled(has_output) + sig = self._config.compose_signature_text + if has_output and sig.strip(): + text = self.txtOutput.toPlainText() + self.btnSignature.setEnabled(not text.endswith(sig)) + self.btnSignature.setVisible(True) + else: + self.btnSignature.setEnabled(False) + self.btnSignature.setVisible(not not sig.strip()) + def _update_variant_nav(self) -> None: total = len(self._variants) has_variants = total > 0 @@ -394,6 +411,41 @@ def _on_output_text_changed(self) -> None: self._variants[self._variant_index] = self.txtOutput.toPlainText() self._sync_state() + def _append_signature(self) -> None: + raw_sig = self._config.compose_signature_text + if not raw_sig.strip(): + return + + sig = raw_sig.rstrip() # Entferne versehentliche Zeilenumbrüche/Tabs am Ende + text = self.txtOutput.toPlainText() + + # Falls die KI einen Platzhalter wie [Dein Name] oder [Ihr Name] eingebaut hat, + # ersetzen wir diesen direkt mit der Signatur, statt sie nur anzuhängen. + # Wir matchen auch ein optionales Komma danach, um hängende Kommas zu vermeiden. + placeholder_pattern = r'\[(?:Dein\s+|Ihr\s+)?Name\]\,?' + if re.search(placeholder_pattern, text, flags=re.IGNORECASE): + text = re.sub(placeholder_pattern, sig, text, flags=re.IGNORECASE) + else: + # Falls kein Platzhalter existiert, klassisch unten anhängen + if text.endswith(sig) or text.endswith(raw_sig): + return + + if text and not text.endswith("\n\n"): + if text.endswith("\n"): + text += "\n" + else: + text += "\n\n" + text += sig + + if 0 <= self._variant_index < len(self._variants): + self._variants[self._variant_index] = text + self._set_output_guarded(text) + self._sync_state() + + @pyqtSlot() + def _on_append_signature_clicked(self) -> None: + self._append_signature() + def set_input_text(self, text: str) -> None: self.txtInput.setPlainText(text) self._variants = [] @@ -418,6 +470,8 @@ def retranslate_ui(self) -> None: self.btnCopy.setText(t("compose.button.copy")) self.btnPaste.setText(t("compose.button.insert_close")) self.btnClose.setText(t("compose.button.close")) + self.btnSignature.setText(t("compose.btn_append_signature")) + self.btnSignature.setToolTip(t("compose.tooltip_append_signature")) self.btnPrev.setToolTip(t("compose.variant.prev")) self.btnNext.setToolTip(t("compose.variant.next")) @@ -491,6 +545,8 @@ def _on_improve_clicked(self) -> None: def _on_worker_result(self, result_text: str) -> None: logger.info("Compose rewrite success (%d chars)", len(result_text)) self._append_variant(result_text) + if self._config.compose_signature_auto_append: + self._append_signature() self._set_busy(False) @pyqtSlot(str) diff --git a/app/config.py b/app/config.py index 727f313..fffea9e 100644 --- a/app/config.py +++ b/app/config.py @@ -49,6 +49,8 @@ "writing_preset": DEFAULT_PRESET_KEY, }, "ui_language": I18N_DEFAULT_LANGUAGE, + "compose_signature_text": "", + "compose_signature_auto_append": False, } VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"} @@ -400,6 +402,22 @@ def custom_terms(self) -> list[str]: def custom_terms(self, value: list[str]) -> None: self._data["workflows"]["custom_terms"] = _sanitize_terms(value) + @property + def compose_signature_text(self) -> str: + return self._data.get("compose_signature_text", "") + + @compose_signature_text.setter + def compose_signature_text(self, value: str) -> None: + self._data["compose_signature_text"] = value + + @property + def compose_signature_auto_append(self) -> bool: + return bool(self._data.get("compose_signature_auto_append", False)) + + @compose_signature_auto_append.setter + def compose_signature_auto_append(self, value: bool) -> None: + self._data["compose_signature_auto_append"] = bool(value) + def as_dict(self) -> dict[str, Any]: return copy.deepcopy(self._data) @@ -435,6 +453,10 @@ def _validate_and_sanitize(self) -> None: self._data["tts_openai_voice"] = DEFAULTS["tts_openai_voice"] self._data["tts_openai_consent"] = bool(self._data.get("tts_openai_consent", False)) + 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)) + 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 4aea72b..ca4df55 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -64,6 +64,8 @@ "settings.notes_folder.help": "Ordner für Diktat-Notizen (muss innerhalb von ~ liegen). Leer = Speichern deaktiviert.", "settings.history_size.label": "Verlauf-Größe:", "settings.history_size.help": "Maximale Anzahl der im Verlauf gespeicherten Einträge.", + "settings.general.signature_label": "Signatur für Compose-Fenster", + "settings.general.signature_auto_append": "Automatisch nach Generierung anfügen", "settings.ui_language.label": "Oberflächen-Sprache:", "settings.ui_language.help": "Sprache für Fenster, Tray-Menü und Dialoge. Änderung wird nach dem Speichern übernommen.", "settings.open_config.button": "📄 Konfigurationsdatei öffnen", @@ -149,6 +151,8 @@ "compose.button.copy": "Kopieren", "compose.button.insert_close": "Einfügen & Schließen", "compose.button.close": "Schließen", + "compose.btn_append_signature": "Signatur", + "compose.tooltip_append_signature": "Signaturblock am Ende anfügen", "compose.status.processing": "Verbessere…", "compose.status.error": "Fehler: {message}", "compose.status.empty_input": "Bitte zuerst einen Text eingeben.", @@ -242,6 +246,8 @@ "settings.notes_folder.help": "Folder for dictation notes (must be inside ~). Empty = saving disabled.", "settings.history_size.label": "History size:", "settings.history_size.help": "Maximum number of entries stored in history.", + "settings.general.signature_label": "Compose Window Signature", + "settings.general.signature_auto_append": "Auto-append after generation", "settings.ui_language.label": "Interface language:", "settings.ui_language.help": "Language for windows, tray menu, and dialogs. Changes apply after saving.", "settings.open_config.button": "📄 Open configuration file", @@ -327,6 +333,8 @@ "compose.button.copy": "Copy", "compose.button.insert_close": "Insert & Close", "compose.button.close": "Close", + "compose.btn_append_signature": "Signature", + "compose.tooltip_append_signature": "Append signature block at the end", "compose.status.processing": "Improving…", "compose.status.error": "Error: {message}", "compose.status.empty_input": "Enter some text first.", diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 9bcccf0..200a8d5 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -7,6 +7,7 @@ import pytest from app.compose_window import MAX_COMPOSE_VARIANTS, ComposeWindow +from app.config import Config from app.i18n import DEFAULT_LANGUAGE, missing_keys, set_language, t from app.workflows import WorkflowType @@ -68,7 +69,7 @@ def qapp(): def compose_window(qapp): llm = _FakeLLMService() paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() yield window, llm, paste @@ -97,7 +98,7 @@ def _wait_until(qapp, predicate, timeout_ms: int = 2500) -> None: ) def test_window_texts_follow_language(qapp, language, title): set_language(language) - window = ComposeWindow(_FakeLLMService(), _FakePasteService()) + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) try: assert window.windowTitle() == title assert window.btnAction.text() == t("compose.button.improve") @@ -162,7 +163,7 @@ def test_copy_writes_result_to_clipboard(compose_window, monkeypatch, qapp): def test_insert_calls_paste_service_and_closes(qapp): llm = _FakeLLMService() paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() @@ -193,7 +194,7 @@ def test_empty_input_disables_improve(compose_window): def test_errors_are_visible_and_scrubbed(qapp): llm = _FakeLLMService(error=RuntimeError("boom DUMMY_COMPOSE_SECRET_TOKEN_123")) paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() @@ -350,7 +351,7 @@ def test_copy_and_paste_use_displayed_variant_after_navigation(qapp, monkeypatch llm = _FakeLLMService() paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() clipboard = _FakeClipboard() @@ -398,7 +399,7 @@ def test_set_input_text_clears_variant_history(compose_window, qapp): def test_error_run_creates_no_variant(qapp): llm = _FakeLLMService(error=RuntimeError("boom")) paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() @@ -456,7 +457,7 @@ def rewrite_text(self, workflow, text, writing_preset=None): llm = _BlockingLLM(result="Var 2") paste = _FakePasteService() - window = ComposeWindow(llm, paste) + window = ComposeWindow(llm, paste, Config()) window.show() qapp.processEvents() @@ -489,7 +490,7 @@ def rewrite_text(self, workflow, text, writing_preset=None): @pytest.mark.parametrize("language", ["de", "en"]) def test_variant_i18n_keys_present_and_complete(qapp, language): set_language(language) - window = ComposeWindow(_FakeLLMService(), _FakePasteService()) + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) try: assert missing_keys() == set() counter = t("compose.variant.counter").format(current=1, total=2) @@ -499,3 +500,69 @@ def test_variant_i18n_keys_present_and_complete(qapp, language): finally: window.close() qapp.processEvents() + + +@gui_only +def test_compose_manual_append(compose_window, qapp): + window, llm, _paste = compose_window + window._config.compose_signature_text = "Best,\nTim" + window.txtOutput.setPlainText("Hello world") + window._append_variant("Hello world") + + # Simulate button click + window.btnSignature.click() + + assert window.txtOutput.toPlainText() == "Hello world\n\nBest,\nTim" + assert window._variants[window._variant_index] == "Hello world\n\nBest,\nTim" + + +@gui_only +def test_compose_double_append_prevention(compose_window, qapp): + window, llm, _paste = compose_window + window._config.compose_signature_text = "Best,\nTim" + window.txtOutput.setPlainText("Hello world\n\nBest,\nTim") + window._append_variant("Hello world\n\nBest,\nTim") + + # Sync state will disable the button if it ends with the signature + window._sync_state() + assert window.btnSignature.isEnabled() is False + + # Clicking it shouldn't append anything + window.btnSignature.click() + assert window.txtOutput.toPlainText() == "Hello world\n\nBest,\nTim" + + +@gui_only +def test_compose_auto_append(compose_window, qapp): + window, llm, _paste = compose_window + window._config.compose_signature_text = "Best,\nTim" + window._config.compose_signature_auto_append = True + + window.txtInput.setPlainText("Hallo Welt") + window.btnAction.click() + + _wait_until( + qapp, + lambda: not window._busy and window._worker_thread is None, + ) + + # Result from fake LLM is "OK", so it should auto-append + assert window.txtOutput.toPlainText() == "OK\n\nBest,\nTim" + assert window._variants[window._variant_index] == "OK\n\nBest,\nTim" + + +@gui_only +def test_compose_empty_signature_noop(compose_window, qapp): + window, llm, _paste = compose_window + window._config.compose_signature_text = " " + window._config.compose_signature_auto_append = True + + window.txtOutput.setPlainText("Hello") + window._append_variant("Hello") + window._sync_state() + + assert window.btnSignature.isVisible() is False + + # Even if clicked manually + window.btnSignature.click() + assert window.txtOutput.toPlainText() == "Hello" diff --git a/tests/test_config.py b/tests/test_config.py index 848ffdc..026ff2c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -359,3 +359,39 @@ def test_ui_language_missing_defaults_to_de(self, config_dir): loaded = BlitztextConfig(config_dir=config_dir) assert loaded.ui_language == "de" + + +class TestComposeSignature: + """Tests für compose_signature_text und compose_signature_auto_append Config-Properties.""" + + def test_signature_defaults(self, config): + """Defaults sind leerer Text und False.""" + assert config.compose_signature_text == "" + assert config.compose_signature_auto_append is False + + def test_signature_persists_on_save_load(self, config, config_dir): + """Gesetzter Signaturtext und Auto-Append werden gespeichert und geladen.""" + config.compose_signature_text = "Best,\nTim" + config.compose_signature_auto_append = True + config.save() + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.compose_signature_text == "Best,\nTim" + assert loaded.compose_signature_auto_append is True + + def test_signature_sanitized_fallback(self, config_dir): + """Ungültige Typen fallen auf sichere Defaults zurück.""" + config_dir.mkdir(parents=True, exist_ok=True) + import json + (config_dir / "config.json").write_text( + json.dumps({ + "model": "base", + "compose_signature_text": ["Not", "a", "string"], + "compose_signature_auto_append": None + }), + encoding="utf-8" + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.compose_signature_text == "" + assert loaded.compose_signature_auto_append is False diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index b5501a5..669906c 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -157,6 +157,8 @@ def _fake_save_self(config_dir, preset_key, ui_language="de"): text="English" if ui_language == "en" else "Deutsch", data=ui_language, ), + edit_compose_signature=_Edit(""), + check_compose_signature_auto_append=_Check(False), accept=lambda: None, ) From e73a5cc4a2d6bcac4b05694e6e63f762d12a5981 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Tue, 23 Jun 2026 18:25:17 +0200 Subject: [PATCH 2/2] fix: robust signature placeholder handling in compose window Signature insertion did not reliably replace AI placeholders. The previous pattern only matched [Ihr/Dein Name], so English placeholders ([Your Name]) and Vorname variants were left in place while the signature was appended a second time below them. - Add precompiled SIGNATURE_PLACEHOLDER_PATTERN covering curated DE+EN placeholders ([Name], [Ihr/Dein/Mein Name], [Vorname], [Vorname Nachname], [Nachname], [Absender], [Your/My/Full Name], [Unterschrift], [Signature]) plus an optional trailing comma; unrelated brackets stay untouched. - Use subn() so the signature is either substituted or appended, never both. - Strip trailing whitespace from the saved signature; keep _sync_state and _append_signature consistent so the button disables correctly. - Add 7 placeholder tests (logic was previously untested). --- app/compose_window.py | 65 +++++++++++++++--------- tests/test_compose_window.py | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/app/compose_window.py b/app/compose_window.py index aa2efb0..5784797 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -40,6 +40,20 @@ # session. Oldest variants are trimmed once the cap is exceeded. MAX_COMPOSE_VARIANTS = 10 +# Curated signature placeholders an LLM commonly emits at the end of an email, +# in German and English. We deliberately anchor on the known closing tokens +# (Name / Vorname / Nachname / Absender / Sender / Signature) rather than any +# bracketed text, so unrelated bracketed content is never replaced. An optional +# trailing comma is swallowed to avoid a dangling comma after substitution. +SIGNATURE_PLACEHOLDER_PATTERN = re.compile( + r"\[\s*" + r"(?:(?:dein[er]?|deine|ihr[er]?|ihre|mein[er]?|meine|your|my)\s+)?" + r"(?:vorname|nachname|full\s+name|name|absender|sender|signature|unterschrift)" + r"(?:\s+nachname)?" + r"\s*\]\s*,?", + re.IGNORECASE, +) + def _scrub_secret(text: str, secret: str) -> str: if secret and text: @@ -340,14 +354,18 @@ def _sync_state(self) -> None: self.btnCopy.setEnabled(has_output) self.btnPaste.setEnabled(has_output) - sig = self._config.compose_signature_text - if has_output and sig.strip(): + raw_sig = self._config.compose_signature_text + if has_output and raw_sig.strip(): + sig = raw_sig.rstrip() text = self.txtOutput.toPlainText() - self.btnSignature.setEnabled(not text.endswith(sig)) + # Enable only while appending would actually change something: + # a placeholder is still present, or the signature isn't there yet. + has_placeholder = SIGNATURE_PLACEHOLDER_PATTERN.search(text) is not None + self.btnSignature.setEnabled(has_placeholder or not text.endswith(sig)) self.btnSignature.setVisible(True) else: self.btnSignature.setEnabled(False) - self.btnSignature.setVisible(not not sig.strip()) + self.btnSignature.setVisible(bool(raw_sig.strip())) def _update_variant_nav(self) -> None: total = len(self._variants) @@ -415,32 +433,31 @@ def _append_signature(self) -> None: raw_sig = self._config.compose_signature_text if not raw_sig.strip(): return - - sig = raw_sig.rstrip() # Entferne versehentliche Zeilenumbrüche/Tabs am Ende - text = self.txtOutput.toPlainText() - - # Falls die KI einen Platzhalter wie [Dein Name] oder [Ihr Name] eingebaut hat, - # ersetzen wir diesen direkt mit der Signatur, statt sie nur anzuhängen. - # Wir matchen auch ein optionales Komma danach, um hängende Kommas zu vermeiden. - placeholder_pattern = r'\[(?:Dein\s+|Ihr\s+)?Name\]\,?' - if re.search(placeholder_pattern, text, flags=re.IGNORECASE): - text = re.sub(placeholder_pattern, sig, text, flags=re.IGNORECASE) - else: - # Falls kein Platzhalter existiert, klassisch unten anhängen - if text.endswith(sig) or text.endswith(raw_sig): - return + # Strip trailing whitespace/newlines/tabs the user may have saved by + # accident, so substitution never leaves dangling blank lines. + sig = raw_sig.rstrip() + original = self.txtOutput.toPlainText() + + # If the LLM left a closing placeholder like [Ihr Name] or [Your Name], + # replace it in place (incl. an optional trailing comma) instead of + # appending a second signature below it. + text, replaced = SIGNATURE_PLACEHOLDER_PATTERN.subn(sig, original) + if not replaced: + # No placeholder: append classically at the bottom. + if text.endswith(sig): + return if text and not text.endswith("\n\n"): - if text.endswith("\n"): - text += "\n" - else: - text += "\n\n" + text += "\n" if text.endswith("\n") else "\n\n" text += sig + if text == original: + return + + self._set_output_guarded(text) if 0 <= self._variant_index < len(self._variants): self._variants[self._variant_index] = text - self._set_output_guarded(text) - self._sync_state() + self._sync_state() @pyqtSlot() def _on_append_signature_clicked(self) -> None: diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 200a8d5..70ec400 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -508,6 +508,7 @@ def test_compose_manual_append(compose_window, qapp): window._config.compose_signature_text = "Best,\nTim" window.txtOutput.setPlainText("Hello world") window._append_variant("Hello world") + window._sync_state() # Simulate button click window.btnSignature.click() @@ -522,6 +523,7 @@ def test_compose_double_append_prevention(compose_window, qapp): window._config.compose_signature_text = "Best,\nTim" window.txtOutput.setPlainText("Hello world\n\nBest,\nTim") window._append_variant("Hello world\n\nBest,\nTim") + window._sync_state() # Sync state will disable the button if it ends with the signature window._sync_state() @@ -560,9 +562,105 @@ def test_compose_empty_signature_noop(compose_window, qapp): window.txtOutput.setPlainText("Hello") window._append_variant("Hello") window._sync_state() + window._sync_state() assert window.btnSignature.isVisible() is False # Even if clicked manually window.btnSignature.click() assert window.txtOutput.toPlainText() == "Hello" + + +@gui_only +def test_compose_signature_replaces_german_placeholder(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim Baumann" + body = "Vielen Dank.\n\nMit freundlichen Grüßen,\n[Ihr Name]" + window._append_variant(body) + window._sync_state() + + window.btnSignature.click() + + expected = "Vielen Dank.\n\nMit freundlichen Grüßen,\nTim Baumann" + assert window.txtOutput.toPlainText() == expected + assert window._variants[window._variant_index] == expected + + +@gui_only +def test_compose_signature_replaces_placeholder_trailing_comma(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim Baumann" + window._append_variant("Grüße,\n[Dein Name],") + window._sync_state() + + window.btnSignature.click() + + # The dangling comma after the placeholder is swallowed. + assert window.txtOutput.toPlainText() == "Grüße,\nTim Baumann" + + +@gui_only +def test_compose_signature_replaces_english_placeholder(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim Baumann" + window._append_variant("Best regards,\n[Your Name]") + window._sync_state() + + window.btnSignature.click() + + # English placeholder is replaced, not left behind with a second signature. + assert window.txtOutput.toPlainText() == "Best regards,\nTim Baumann" + + +@gui_only +def test_compose_signature_replaces_vorname_placeholder(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim" + window._append_variant("Liebe Grüße\n[Dein Vorname]") + window._sync_state() + + window.btnSignature.click() + + assert window.txtOutput.toPlainText() == "Liebe Grüße\nTim" + + +@gui_only +def test_compose_signature_no_double_after_placeholder(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim Baumann" + window._append_variant("Mit freundlichen Grüßen,\n[Ihr Name]") + window._sync_state() + + window.btnSignature.click() + # After replacement the signature is in place; the button must disable + # and a second click must not append a duplicate signature. + window._sync_state() + assert window.btnSignature.isEnabled() is False + window.btnSignature.click() + assert window.txtOutput.toPlainText() == "Mit freundlichen Grüßen,\nTim Baumann" + + +@gui_only +def test_compose_signature_strips_saved_whitespace(compose_window, qapp): + window, _llm, _paste = compose_window + # Signature saved with accidental trailing newline/tab. + window._config.compose_signature_text = "Tim Baumann\t\n" + window._append_variant("Hallo") + window._sync_state() + + window.btnSignature.click() + + assert window.txtOutput.toPlainText() == "Hallo\n\nTim Baumann" + + +@gui_only +def test_compose_signature_leaves_unrelated_brackets(compose_window, qapp): + window, _llm, _paste = compose_window + window._config.compose_signature_text = "Tim Baumann" + window._append_variant("Siehe [Anhang] und [Datum].") + window._sync_state() + + window.btnSignature.click() + + # Unrelated bracketed tokens are never treated as a signature placeholder. + assert window.txtOutput.toPlainText() == "Siehe [Anhang] und [Datum].\n\nTim Baumann"