diff --git a/README.md b/README.md index b722a4d..4c44b69 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,23 @@ Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstell

+### Schreibstil-Vorlagen + +Für den Workflow **Blitztext+** (Text-Verbesserer) gibt es vorgefertigte Schreibstil-Vorlagen, die du unter **Einstellungen → KI-Workflows → „Schreibstil-Vorlage"** auswählst: + +| Vorlage | Wirkung | +| --- | --- | +| **Standard (Text verbessern)** | Bisheriges Verhalten – sauber formatierter Text, der gewählte **Tonfall** greift. | +| **E-Mail – formell** | Höfliche E-Mail in der Sie-Form mit klarer Struktur. | +| **E-Mail – locker** | Freundliche E-Mail in der Du-Form. | +| **Stichpunkte** | Gliedert den Inhalt in prägnante Stichpunkte. | +| **Zusammenfassung** | Knappe, sachliche Zusammenfassung der Kernaussagen. | +| **Persönlich (Du-Form)** | Klarer Text in der persönlichen Du-Form. | +| **Höflich (Sie-Form)** | Klarer Text in der höflichen Sie-Form. | +| **Kurz & präzise** | Maximal knapp, ohne Füllwörter und Wiederholungen. | + +> Bei **Standard** wird zusätzlich der eingestellte **Tonfall** angewendet. Jede andere Vorlage bringt ihren eigenen Schreibstil mit und ersetzt den Tonfall. Eigennamen/Begriffe bleiben in allen Vorlagen erhalten. + --- ## Tray-Symbol: Statusfarben @@ -284,7 +301,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei - Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable. - **autopaste**: Fügt per `ydotool` ein. - **audio_device**: Name der Audioquelle. -- **workflows**: Feintuning von Tonalität, Emojis und dem Dampf-Prompt. +- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`). --- diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index a8f44cf..c1212bb 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -32,6 +32,7 @@ from app.config import Config, VALID_HOTKEY_KEYS from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError +from app.writing_presets import WRITING_PRESETS, WRITING_PRESET_KEYS, preset_index from app.hotkey_service import HotkeyWorker from app.audio_recorder import AudioRecorder, AudioRecorderError from app.transcribe import transcribe, TranscribeError @@ -223,6 +224,11 @@ def init_ui(self) -> None: self.combo_tone.addItems(["formal", "neutral", "locker"]) self.combo_tone.setCurrentText(self.config.text_improver_tone) + self.combo_writing_preset = QComboBox() + for key in WRITING_PRESET_KEYS: + self.combo_writing_preset.addItem(WRITING_PRESETS[key].display_name, key) + self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset)) + self.combo_emoji = QComboBox() self.combo_emoji.addItems(["wenig", "mittel", "viel"]) self.combo_emoji.setCurrentText(self.config.emoji_density) @@ -258,6 +264,8 @@ def init_ui(self) -> None: form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen.")) form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone) + form_llm.addRow("Schreibstil-Vorlage:", self.combo_writing_preset) + form_llm.addRow("", create_help_label("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.")) form_llm.addRow("Emoji-Dichte:", self.combo_emoji) form_llm.addRow("Dampf-Umschreiber Prompt:", self.edit_dampf_prompt) @@ -383,6 +391,7 @@ def save_settings(self) -> None: self.config.openai_api_key_env = self.edit_api_key_env.text().strip() self.config.text_improver_tone = self.combo_tone.currentText() + self.config.writing_preset = self.combo_writing_preset.currentData() 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() @@ -497,6 +506,7 @@ def __init__(self, app: QApplication) -> None: dampf_system_prompt=self.config.dampf_system_prompt, custom_terms=self.config.custom_terms, api_key_env=self.config.openai_api_key_env, + writing_preset=self.config.writing_preset, ) self.audio_recorder = AudioRecorder() self.paste_service = PasteService(autopaste=self.config.autopaste) @@ -672,6 +682,7 @@ def show_settings_dialog(self) -> None: dampf_system_prompt=self.config.dampf_system_prompt, custom_terms=self.config.custom_terms, api_key_env=self.config.openai_api_key_env, + writing_preset=self.config.writing_preset, ) self.update_menu_availability() diff --git a/app/config.py b/app/config.py index 1828b78..0f2c0d2 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Any +from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS + logger = logging.getLogger("blitztext.config") DEFAULTS: dict[str, Any] = { @@ -34,6 +36,7 @@ "emoji_density": "mittel", "dampf_system_prompt": "", "custom_terms": [], + "writing_preset": DEFAULT_PRESET_KEY, }, } @@ -42,6 +45,7 @@ VALID_HOTKEY_MODES = {"toggle", "hold"} VALID_TONES = {"formal", "neutral", "locker"} VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"} +VALID_WRITING_PRESETS = set(WRITING_PRESET_KEYS) VALID_HOTKEY_KEYS = { "KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL", "KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16", @@ -267,6 +271,16 @@ def dampf_system_prompt(self) -> str: def dampf_system_prompt(self, value: str) -> None: self._data["workflows"]["dampf_system_prompt"] = value + @property + def writing_preset(self) -> str: + return self._data["workflows"].get("writing_preset", DEFAULT_PRESET_KEY) + + @writing_preset.setter + def writing_preset(self, value: str) -> None: + if value not in VALID_WRITING_PRESETS: + raise ValueError(f"Ungueltiges Schreib-Preset: {value!r}. Gueltig: {sorted(VALID_WRITING_PRESETS)}") + self._data["workflows"]["writing_preset"] = value + @property def custom_terms(self) -> list[str]: return list(self._data["workflows"].get("custom_terms", [])) @@ -323,6 +337,9 @@ def _validate_and_sanitize(self) -> None: wf["text_improver_tone"] = "neutral" if wf.get("emoji_density") not in VALID_EMOJI_DENSITIES: wf["emoji_density"] = "mittel" + preset_value = wf.get("writing_preset") + if not isinstance(preset_value, str) or preset_value not in VALID_WRITING_PRESETS: + wf["writing_preset"] = DEFAULT_PRESET_KEY wf["custom_terms"] = _sanitize_terms(wf.get("custom_terms")) diff --git a/app/llm_service.py b/app/llm_service.py index 58deb0a..f60fe76 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -5,6 +5,7 @@ from typing import Any, Optional from app.workflows import WorkflowType +from app.writing_presets import DEFAULT_PRESET_KEY, get_preset logger = logging.getLogger("blitztext.llm_service") @@ -51,6 +52,7 @@ def __init__( dampf_system_prompt: str = "", custom_terms: Optional[list[str]] = None, api_key_env: str = "OPENAI_API_KEY", + writing_preset: str = DEFAULT_PRESET_KEY, ) -> None: self.api_key = api_key or "" self.api_key_env = api_key_env or "OPENAI_API_KEY" @@ -58,6 +60,7 @@ def __init__( self.emoji_density = emoji_density self.dampf_system_prompt = dampf_system_prompt self.custom_terms = self._sanitize_terms(custom_terms) + self.writing_preset = writing_preset or DEFAULT_PRESET_KEY self._openai_installed = True self._client_is_fallback_mock = False @@ -202,7 +205,8 @@ def rewrite(self, workflow: WorkflowType, transcript: str) -> str: if workflow == WorkflowType.DAMPF_ABLASSEN: return self.dampf_ablassen(transcript, custom_system_prompt=self.dampf_system_prompt) if workflow == WorkflowType.TEXT_IMPROVER: - return self.text_improver(transcript, tone=self.tone) + preset = get_preset(self.writing_preset) + return self.text_improver(transcript, tone=self.tone, custom_prompt=preset.system_prompt) if workflow == WorkflowType.EMOJI_TEXT: return self.emoji_text(transcript, density=self.emoji_density) raise LLMServiceError(f"Unsupported workflow: {workflow}") diff --git a/app/writing_presets.py b/app/writing_presets.py new file mode 100644 index 0000000..36b20f6 --- /dev/null +++ b/app/writing_presets.py @@ -0,0 +1,109 @@ +"""Vorgefertigte Schreibstil-Vorlagen (Presets) für den Text-Verbesserer. + +Reine Domänendaten – keine Qt- oder OpenAI-Abhängigkeit, damit der Katalog +isoliert testbar bleibt. Ein Preset liefert einen vollständigen System-Prompt, +der im Text-Verbesserer-Workflow als ``custom_prompt`` verwendet wird. Das +Preset ``standard`` hat einen leeren Prompt und bewahrt damit exakt das +bisherige Verhalten (Standard-Template des Text-Verbesserers). +""" +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_PRESET_KEY = "standard" + +_COMMON_RULES = ( + " Behalte den Inhalt vollständig und erfinde nichts dazu. Korrigiere " + "Grammatik und Zeichensetzung. Gib NUR den fertigen Text zurück, ohne " + "Vorbemerkung oder Erklärung." +) + + +@dataclass(frozen=True) +class WritingPreset: + """Eine auswählbare Schreibstil-Vorlage. + + Attributes: + key: Stabiler Bezeichner (in der Config gespeichert). + display_name: Anzeigename für die Einstellungen. + system_prompt: System-Prompt für den Text-Verbesserer. Leer = Standard. + """ + + key: str + display_name: str + system_prompt: str + + +_PRESETS: tuple[WritingPreset, ...] = ( + WritingPreset( + "standard", + "Standard (Text verbessern)", + "", + ), + WritingPreset( + "email_formal", + "E-Mail – formell", + "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine " + "formelle, höfliche E-Mail in der Sie-Form mit klarer Struktur " + "(passende Anrede, Hauptteil, freundlicher Gruß)." + _COMMON_RULES, + ), + WritingPreset( + "email_locker", + "E-Mail – locker", + "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine " + "lockere, freundliche E-Mail in der Du-Form mit natürlichem, " + "persönlichem Ton." + _COMMON_RULES, + ), + WritingPreset( + "stichpunkte", + "Stichpunkte", + "Du erhältst ein gesprochenes Transkript. Gliedere den Inhalt in " + "prägnante Stichpunkte (eine Aussage pro Punkt, je mit '- ' " + "beginnend)." + _COMMON_RULES, + ), + WritingPreset( + "zusammenfassung", + "Zusammenfassung", + "Du erhältst ein gesprochenes Transkript. Fasse die Kernaussagen " + "knapp und sachlich in wenigen Sätzen zusammen." + _COMMON_RULES, + ), + WritingPreset( + "du_form", + "Persönlich (Du-Form)", + "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem " + "klaren, gut lesbaren Text in der persönlichen Du-Form um." + _COMMON_RULES, + ), + WritingPreset( + "sie_form", + "Höflich (Sie-Form)", + "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem " + "klaren, gut lesbaren Text in der höflichen Sie-Form um." + _COMMON_RULES, + ), + WritingPreset( + "kurz_praezise", + "Kurz & präzise", + "Du erhältst ein gesprochenes Transkript. Formuliere es maximal kurz " + "und präzise um: entferne Füllwörter und Wiederholungen, behalte aber " + "alle wesentlichen Informationen." + _COMMON_RULES, + ), +) + +WRITING_PRESETS: dict[str, WritingPreset] = {preset.key: preset for preset in _PRESETS} +WRITING_PRESET_KEYS: tuple[str, ...] = tuple(preset.key for preset in _PRESETS) + + +def get_preset(key: str) -> WritingPreset: + """Liefert das Preset zum Schlüssel, mit Fallback auf ``standard``.""" + return WRITING_PRESETS.get(key, WRITING_PRESETS[DEFAULT_PRESET_KEY]) + + +def preset_index(key: str) -> int: + """Position des Presets in ``WRITING_PRESET_KEYS`` (für Auswahl-Widgets). + + Unbekannte Schlüssel liefern den Index von ``standard``, sodass die UI + immer eine gültige Vorauswahl trifft. + """ + try: + return WRITING_PRESET_KEYS.index(key) + except ValueError: + return WRITING_PRESET_KEYS.index(DEFAULT_PRESET_KEY) diff --git a/tests/test_config.py b/tests/test_config.py index 7475247..743a765 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -83,6 +83,57 @@ def test_custom_terms_are_sanitized(self, config): assert config.custom_terms == ["Blitztext", "OpenRouter"] +class TestWritingPreset: + def test_default_writing_preset_is_standard(self, config): + assert config.writing_preset == "standard" + + def test_valid_preset_is_accepted_and_persists(self, config, config_dir): + config.writing_preset = "email_formal" + assert config.writing_preset == "email_formal" + config.save() + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.writing_preset == "email_formal" + + def test_invalid_preset_is_rejected(self, config): + with pytest.raises(ValueError): + config.writing_preset = "gibt-es-nicht" + + def test_unknown_preset_in_file_is_coerced_to_standard(self, config_dir): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"workflows": {"writing_preset": "kaputt"}}), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.writing_preset == "standard" + + def test_missing_preset_key_defaults_to_standard(self, config_dir): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"workflows": {"text_improver_tone": "formal"}}), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.writing_preset == "standard" + assert loaded.text_improver_tone == "formal" + + @pytest.mark.parametrize("bad_value", [[], {}, ["email_formal"], 42, None, True]) + def test_non_string_preset_value_is_coerced_without_crash(self, config_dir, bad_value): + # Manuell editierte config.json mit unhashbarem/falschem Typ darf den + # Start nicht mit TypeError abbrechen, sondern auf "standard" zurückfallen. + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"workflows": {"writing_preset": bad_value}}), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.writing_preset == "standard" + + class TestTranscriptionHotkey: def test_valid_hotkey_is_accepted(self, config): config.transcription_hotkey = "KEY_F13" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index c3c50ef..0e4e3fd 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -8,6 +8,7 @@ from app.llm_service import LLMService, LLMServiceError from app.workflows import WorkflowType +from app.writing_presets import WRITING_PRESETS DUMMY_API_KEY = "dummy-openai-key" @@ -97,7 +98,7 @@ class TestRewrite: def test_rewrite_routes_to_text_improver(self, service): with patch.object(service, "text_improver", return_value="verbessert") as patched: result = service.rewrite(WorkflowType.TEXT_IMPROVER, "roh") - patched.assert_called_once_with("roh", tone=service.tone) + patched.assert_called_once_with("roh", tone=service.tone, custom_prompt="") assert result == "verbessert" def test_openai_error_is_wrapped(self, service): @@ -111,3 +112,57 @@ def test_missing_openai_package_raises_clear_error(self, service): with pytest.raises(LLMServiceError, match="openai-Paket nicht installiert"): service.rewrite(WorkflowType.TEXT_IMPROVER, "test") + + +class TestWritingPreset: + def test_default_writing_preset_is_standard(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + assert service.writing_preset == "standard" + + def test_standard_preset_keeps_default_text_improver_prompt(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + # Standard verwendet weiterhin das Default-Template (kein Preset-Prompt). + assert "Formuliere es zu einem sauberen" in system_message + for key, preset in WRITING_PRESETS.items(): + if key == "standard": + continue + assert preset.system_prompt not in system_message + + def test_preset_prompt_is_passed_as_system_message(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="email_formal") + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + assert WRITING_PRESETS["email_formal"].system_prompt in system_message + + def test_unknown_preset_falls_back_to_standard_behavior(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="gibt-es-nicht") + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + assert "Formuliere es zu einem sauberen" in system_message + + def test_custom_terms_still_applied_with_preset(self, mock_client): + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset="stichpunkte", + custom_terms=CUSTOM_TERMS, + ) + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + assert WRITING_PRESETS["stichpunkte"].system_prompt in system_message + assert "muessen exakt so geschrieben werden" in system_message + assert ", ".join(CUSTOM_TERMS) in system_message + + def test_transcript_stays_in_user_message_not_system(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="email_formal") + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + 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 diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index e86c301..4b823db 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -92,6 +92,80 @@ def test_open_config_handles_open_failure(tmp_path): m_box.critical.assert_not_called() +class _Combo: + def __init__(self, text="", data=None): + self._text = text + self._data = data + + def currentText(self): + return self._text + + def currentData(self): + return self._data + + +class _Edit: + def __init__(self, text=""): + self._text = text + + def text(self): + return self._text + + def toPlainText(self): + return self._text + + +class _Check: + def __init__(self, checked=True): + self._checked = checked + + def isChecked(self): + return self._checked + + +def _fake_save_self(config_dir, preset_key): + config = BlitztextConfig(config_dir=config_dir) + return SimpleNamespace( + config=config, + combo_model=_Combo("base"), + combo_backend=_Combo("openai-whisper"), + edit_language=_Edit("de"), + edit_audio_device=_Edit("@DEFAULT_SOURCE@"), + combo_hotkey_mode=_Combo("hold"), + combo_transcription_key=_Combo("KEY_LEFTALT"), + edit_api_key_env=_Edit("OPENAI_API_KEY"), + combo_tone=_Combo("neutral"), + combo_writing_preset=_Combo(text="E-Mail – formell", data=preset_key), + combo_emoji=_Combo("mittel"), + edit_dampf_prompt=_Edit(""), + _collect_custom_terms=lambda: [], + check_autopaste=_Check(True), + edit_notes_folder=_Edit(""), + spin_history_size=_Combo("50"), + accept=lambda: None, + ) + + +def test_save_settings_persists_writing_preset(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "email_formal") + + SettingsDialog.save_settings(fake) + + assert fake.config.writing_preset == "email_formal" + reloaded = BlitztextConfig(config_dir=config_dir) + assert reloaded.writing_preset == "email_formal" + + +def test_save_settings_keeps_standard_preset(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + + SettingsDialog.save_settings(fake) + + assert fake.config.writing_preset == "standard" + + def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch): secret_value = "dummy-openai-key" monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value) diff --git a/tests/test_writing_presets.py b/tests/test_writing_presets.py new file mode 100644 index 0000000..0ad0903 --- /dev/null +++ b/tests/test_writing_presets.py @@ -0,0 +1,81 @@ +"""Tests für den Schreibstil-Preset-Katalog.""" +from __future__ import annotations + +import pytest + +from app.writing_presets import ( + DEFAULT_PRESET_KEY, + WRITING_PRESET_KEYS, + WRITING_PRESETS, + WritingPreset, + get_preset, + preset_index, +) + +EXPECTED_KEYS = ( + "standard", + "email_formal", + "email_locker", + "stichpunkte", + "zusammenfassung", + "du_form", + "sie_form", + "kurz_praezise", +) + + +class TestCatalogIntegrity: + def test_expected_keys_present_and_ordered(self): + assert WRITING_PRESET_KEYS == EXPECTED_KEYS + + def test_dict_matches_key_tuple(self): + assert set(WRITING_PRESETS) == set(WRITING_PRESET_KEYS) + assert len(WRITING_PRESETS) == len(WRITING_PRESET_KEYS) + + def test_default_key_is_standard(self): + assert DEFAULT_PRESET_KEY == "standard" + + def test_standard_prompt_is_empty(self): + assert WRITING_PRESETS["standard"].system_prompt == "" + + def test_non_standard_presets_have_prompt(self): + for key, preset in WRITING_PRESETS.items(): + if key == DEFAULT_PRESET_KEY: + continue + assert preset.system_prompt.strip(), f"{key} hat keinen Prompt" + + def test_every_preset_has_display_name(self): + for preset in WRITING_PRESETS.values(): + assert preset.display_name.strip() + + def test_preset_is_immutable(self): + preset = WRITING_PRESETS["standard"] + with pytest.raises(Exception): + preset.key = "geändert" # type: ignore[misc] + + def test_is_writing_preset_instances(self): + assert all(isinstance(p, WritingPreset) for p in WRITING_PRESETS.values()) + + +class TestGetPreset: + @pytest.mark.parametrize("key", EXPECTED_KEYS) + def test_known_keys_return_matching_preset(self, key): + assert get_preset(key).key == key + + def test_unknown_key_falls_back_to_standard(self): + assert get_preset("gibt-es-nicht").key == DEFAULT_PRESET_KEY + + def test_empty_key_falls_back_to_standard(self): + assert get_preset("").key == DEFAULT_PRESET_KEY + + +class TestPresetIndex: + @pytest.mark.parametrize("key", EXPECTED_KEYS) + def test_known_key_maps_to_its_position(self, key): + assert WRITING_PRESET_KEYS[preset_index(key)] == key + + def test_standard_is_first(self): + assert preset_index("standard") == 0 + + def test_unknown_key_selects_standard_index(self): + assert preset_index("gibt-es-nicht") == preset_index("standard")