diff --git a/requirements-dev.txt b/requirements-dev.txt index c30816c..f34a495 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,6 @@ PyQt6 evdev openai pytest +# Pillow: nur fuer scripts/_make_screenshots.py (Banner-/Screenshot-Generator) +# und dessen Unit-Tests (tests/test_make_screenshots.py). +Pillow diff --git a/scripts/_make_screenshots.py b/scripts/_make_screenshots.py index 340dae4..0f89caf 100644 --- a/scripts/_make_screenshots.py +++ b/scripts/_make_screenshots.py @@ -1,77 +1,385 @@ -"""Rendert die BlitztextLinux-GUI-Komponenten als PNG-Screenshots. +"""Generate README screenshots and banner assets for Blitztext Linux. -Einmaliges Hilfsskript fuer die README-Dokumentation. Instanziiert die -Fenster/Dialoge direkt und greift sie per QWidget.grab() ab — kein laufender -Tray noetig. +The script renders the current PyQt6 widgets offscreen and builds language-specific +banner images from real UI screenshots. -Aufruf: PYTHONPATH=. .venv/bin/python scripts/_make_screenshots.py +Usage: + PYTHONPATH=. QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/_make_screenshots.py [out_dir] + +Default output directory: + docs/screenshots/linux """ from __future__ import annotations +import os import sys +import tempfile +import time from pathlib import Path from types import SimpleNamespace +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication -from app.config import Config -from app.main_window import MainWindow -from app.blitztext_linux import SettingsDialog +from app.config import BlitztextConfig from app.history_panel import HistoryPanel +from app.i18n import set_language, t +from app.main_window import MainWindow from app.tts_window import TtsWindow +from app.blitztext_linux import BlitztextApp, Config, SettingsDialog + +SCREENSHOT_DIR = Path("docs/screenshots/linux") +CANVAS_SIZE = (1280, 640) +BACKGROUND_TOP = "#07111f" +BACKGROUND_BOTTOM = "#02060d" +ACCENT = "#2db2ff" +CARD_BG = (14, 24, 38, 214) +CARD_BORDER = (50, 127, 194, 140) +TEXT_PRIMARY = "#eef6ff" +TEXT_SECONDARY = "#8ea6c1" +LABEL_BG = (37, 174, 255, 220) + +LANG_COPY = { + "en": { + "banner": "Banner-en.png", + "social": "Banner.png", + "hero": "Your local AI voice assistant for KDE Plasma & Wayland", + "sub": "Record speech, transcribe locally or online, optionally rewrite it with AI, and paste it directly into the active app.", + "flow": "Record • Transcribe • Rewrite • Paste", + "feature_title": "What is new in v0.4.0", + "tag_new": "NEW", + "chips": [ + ("Multilingual UI", "Switch the whole app between English and German."), + ("Writing-style presets", "Ready-made Blitztext+ presets for common writing tasks."), + ("Offline-ready", "Local Whisper and privacy-friendly workflows stay available."), + ("Global hotkeys", "Capture dictation from anywhere in KDE Plasma."), + ], + "labels": { + "main": "Main window", + "general": "Settings → General", + "workflows": "Settings → AI Workflows", + "tray": "Tray presets", + }, + "history_entries": [ + ("Please move tomorrow's team sync to 10:00.", False), + ("Could you send me the updated rollout plan afterwards?", True), + ("The draft is ready and stored in the shared project folder.", False), + ], + "tts_text": "Read this short summary aloud with the current voice settings.", + }, + "de": { + "banner": "Banner-de.png", + "social": None, + "hero": "Dein lokaler KI-Sprachassistent für KDE Plasma & Wayland", + "sub": "Sprache aufnehmen, lokal oder online transkribieren, optional mit KI umformulieren und direkt in die aktive Anwendung einfügen.", + "flow": "Aufnehmen • Transkribieren • Umformulieren • Direkt einfügen", + "feature_title": "Neu im Stand v0.4.0", + "tag_new": "NEU", + "chips": [ + ("Mehrsprachige Oberfläche", "Die komplette App lässt sich zwischen Deutsch und Englisch umschalten."), + ("Schreibstil-Vorlagen", "Vorgefertigte Blitztext+-Presets für typische Schreibaufgaben."), + ("Offline-fähig", "Lokale Whisper-Workflows bleiben für datensparsame Nutzung verfügbar."), + ("Globale Hotkeys", "Diktat direkt aus jeder Anwendung unter KDE Plasma starten."), + ], + "labels": { + "main": "Hauptfenster", + "general": "Einstellungen → Allgemein", + "workflows": "Einstellungen → KI-Workflows", + "tray": "Tray-Presets", + }, + "history_entries": [ + ("Bitte verschiebe das Team-Meeting morgen auf 10 Uhr.", False), + ("Kannst du mir danach den aktualisierten Rollout-Plan schicken?", True), + ("Der Entwurf ist fertig und liegt im gemeinsamen Projektordner.", False), + ], + "tts_text": "Lies diese kurze Zusammenfassung mit den aktuellen Spracheinstellungen vor.", + }, +} + + +def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", + ] + for path in candidates: + if Path(path).is_file(): + return ImageFont.truetype(path, size=size) + return ImageFont.load_default() + + +FONT_TITLE = _font(56, bold=True) +FONT_SUBTITLE = _font(25, bold=False) +FONT_FLOW = _font(24, bold=True) +FONT_FEATURE_TITLE = _font(21, bold=True) +FONT_CARD_TITLE = _font(19, bold=True) +FONT_CARD_TEXT = _font(15, bold=False) +FONT_LABEL = _font(15, bold=True) + + +app: QApplication | None = None + + +def _process_events(cycles: int = 10) -> None: + assert app is not None + for _ in range(cycles): + app.processEvents() def _grab(widget, path: Path) -> None: widget.show() - app = QApplication.instance() - for _ in range(8): - app.processEvents() + _process_events() widget.grab().save(str(path)) widget.hide() print(f" ✓ {path.name}") +def _rounded_panel(base: Image.Image, box: tuple[int, int, int, int], radius: int = 24) -> None: + overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + draw.rounded_rectangle(box, radius=radius, fill=CARD_BG, outline=CARD_BORDER, width=2) + glow = overlay.filter(ImageFilter.GaussianBlur(18)) + base.alpha_composite(glow) + base.alpha_composite(overlay) + + +def _resize_card(image_path: Path, size: tuple[int, int]) -> Image.Image: + if not image_path.exists(): + raise FileNotFoundError(f"Required screenshot missing for banner composite: {image_path}") + try: + image = Image.open(image_path).convert("RGBA") + except OSError as exc: + raise OSError(f"Failed to load screenshot {image_path}: {exc}") from exc + image.thumbnail(size, Image.Resampling.LANCZOS) + panel = Image.new("RGBA", size, (0, 0, 0, 0)) + x = (size[0] - image.width) // 2 + y = (size[1] - image.height) // 2 + panel.alpha_composite(image, (x, y)) + return panel + + +def _draw_multiline(draw: ImageDraw.ImageDraw, text: str, xy: tuple[int, int], width: int, font, fill: str, line_gap: int = 6) -> int: + x, y = xy + words = text.split() + lines: list[str] = [] + current = "" + for word in words: + trial = f"{current} {word}".strip() + if draw.textlength(trial, font=font) <= width or not current: + current = trial + else: + lines.append(current) + current = word + if current: + lines.append(current) + for line in lines: + draw.text((x, y), line, font=font, fill=fill) + y += font.size + line_gap + return y + + +def _capture_tray_menu(config: BlitztextConfig, lang: str, path: Path) -> None: + assert app is not None + original_start = BlitztextApp.start_hotkey_worker + original_load = Config.load + + def fake_start(self) -> None: # pragma: no cover - helper only + self.hotkey_worker = None + self.hotkey_thread = None + + def fake_load(cls, path: Path | None = None): # pragma: no cover - helper only + return config + + BlitztextApp.start_hotkey_worker = fake_start + Config.load = classmethod(fake_load) + try: + tray = BlitztextApp(app) + tray.stop_hotkey_worker() + tray.config.writing_preset = "kurz_praezise" + tray._refresh_preset_menu() + tray.menu.ensurePolished() + tray.menu_preset.ensurePolished() + tray.menu.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) + tray.menu_preset.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) + tray.menu.show() + tray.menu_preset.show() + _process_events(12) + + menu_img = tray.menu.grab().toImage() + submenu_img = tray.menu_preset.grab().toImage() + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as menu_file: + menu_tmp = Path(menu_file.name) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as submenu_file: + submenu_tmp = Path(submenu_file.name) + try: + menu_img.save(str(menu_tmp)) + submenu_img.save(str(submenu_tmp)) + menu = Image.open(menu_tmp).convert("RGBA") + submenu = Image.open(submenu_tmp).convert("RGBA") + finally: + menu_tmp.unlink(missing_ok=True) + submenu_tmp.unlink(missing_ok=True) + + canvas = Image.new("RGBA", (menu.width + submenu.width + 32, max(menu.height, submenu.height) + 12), (0, 0, 0, 0)) + canvas.alpha_composite(menu, (0, 6)) + canvas.alpha_composite(submenu, (menu.width + 32, 24)) + canvas.save(path) + print(f" ✓ {path.name}") + + tray.menu.close() + tray.menu_preset.close() + tray.tray_icon.hide() + finally: + BlitztextApp.start_hotkey_worker = original_start + Config.load = original_load + + +def _make_banner(lang: str, out_dir: Path) -> None: + copy = LANG_COPY[lang] + canvas = Image.new("RGBA", CANVAS_SIZE, BACKGROUND_BOTTOM) + bg = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg) + for i in range(CANVAS_SIZE[1]): + ratio = i / max(1, CANVAS_SIZE[1] - 1) + top = tuple(int(int(BACKGROUND_TOP[j:j + 2], 16) * (1 - ratio) + int(BACKGROUND_BOTTOM[j:j + 2], 16) * ratio) for j in (1, 3, 5)) + bg_draw.line((0, i, CANVAS_SIZE[0], i), fill=top + (255,)) + canvas.alpha_composite(bg) + + draw = ImageDraw.Draw(canvas) + draw.ellipse((54, 52, 126, 124), fill=(13, 150, 255, 255), outline=(70, 194, 255, 255), width=3) + draw.ellipse((79, 74, 101, 96), fill=(8, 16, 30, 255)) + draw.rectangle((88, 94, 92, 116), fill=(8, 16, 30, 255)) + draw.arc((68, 100, 112, 132), 20, 160, fill=(70, 194, 255, 255), width=4) + + draw.text((150, 54), "Blitztext Linux", font=FONT_TITLE, fill=TEXT_PRIMARY) + draw.text((150, 126), copy["hero"], font=FONT_SUBTITLE, fill=TEXT_SECONDARY) + draw.text((150, 170), copy["sub"], font=FONT_CARD_TEXT, fill="#c7d8ea") + draw.text((150, 208), copy["flow"], font=FONT_FLOW, fill=ACCENT) + draw.text((150, 258), copy["feature_title"], font=FONT_FEATURE_TITLE, fill=TEXT_PRIMARY) + + card_positions = [ + (60, 304), + (396, 304), + (60, 444), + (396, 444), + ] + card_w = 304 + card_h = 118 + for index, ((title, desc), (x0, y0)) in enumerate(zip(copy["chips"], card_positions, strict=False)): + x1 = x0 + card_w + y1 = y0 + card_h + _rounded_panel(canvas, (x0, y0, x1, y1), radius=18) + draw.rounded_rectangle((x0 + 16, y0 + 16, x0 + 70, y0 + 44), radius=10, fill=LABEL_BG) + draw.text((x0 + 27, y0 + 21), copy["tag_new"] if index < 2 else "OK", font=FONT_LABEL, fill="#03111f") + draw.text((x0 + 16, y0 + 58), title, font=FONT_CARD_TITLE, fill=TEXT_PRIMARY) + _draw_multiline(draw, desc, (x0 + 16, y0 + 84), card_w - 30, FONT_CARD_TEXT, TEXT_SECONDARY, line_gap=4) + + screenshots = { + "main": out_dir / f"main-window-{lang}.png", + "general": out_dir / f"settings-general-{lang}.png", + "workflows": out_dir / f"settings-ai-workflows-{lang}.png", + "tray": out_dir / f"tray-menu-{lang}.png", + } + + placements = [ + (screenshots["main"], (824, 74), (220, 260), copy["labels"]["main"]), + (screenshots["general"], (1046, 74), (194, 260), copy["labels"]["general"]), + (screenshots["workflows"], (790, 350), (250, 236), copy["labels"]["workflows"]), + (screenshots["tray"], (1054, 332), (186, 254), copy["labels"]["tray"]), + ] + + for image_path, (x, y), size, label in placements: + box = (x - 12, y - 12, x + size[0] + 12, y + size[1] + 42) + _rounded_panel(canvas, box, radius=22) + card = _resize_card(image_path, size) + canvas.alpha_composite(card, (x, y)) + label_w = int(draw.textlength(label, font=FONT_LABEL)) + 24 + draw.rounded_rectangle((x + 12, y + size[1] + 8, x + 12 + label_w, y + size[1] + 34), radius=10, fill=(7, 22, 39, 220)) + draw.text((x + 24, y + size[1] + 13), label, font=FONT_LABEL, fill="#dcecff") + + out_path = out_dir / copy["banner"] + canvas.convert("RGB").save(out_path, quality=95) + print(f" ✓ {out_path.name}") + if copy["social"]: + social_path = out_dir / copy["social"] + canvas.convert("RGB").save(social_path, quality=95) + print(f" ✓ {social_path.name}") + + +def _tab_index(tabs, key: str) -> int: + """Resolve a settings tab by its i18n key, independent of tab order or language.""" + target = t(key) + for index in range(tabs.count()): + if tabs.tabText(index) == target: + return index + raise ValueError(f"Settings tab not found for key {key!r} ({target!r})") + + +def _render_language_set(out_dir: Path, lang: str) -> None: + assert app is not None + copy = LANG_COPY[lang] + set_language(lang) + with tempfile.TemporaryDirectory(prefix=f"blitztext-assets-{lang}-") as tmp_dir: + config = BlitztextConfig(config_dir=Path(tmp_dir)) + config.ui_language = lang + config.writing_preset = "kurz_praezise" + config.llm_provider = "openai" + config.tts_provider = "openai" + config.tts_openai_consent = True + config.notes_folder = str(Path.home() / "Blitztext-Notes") + + controller = SimpleNamespace( + gui_toggle_recording=lambda *a, **k: None, + gui_discard=lambda *a, **k: None, + set_dictation_mode=lambda *a, **k: None, + show_history_panel=lambda *a, **k: None, + show_settings_dialog=lambda *a, **k: None, + show_tts_window=lambda *a, **k: None, + ) + + main_window = MainWindow(controller) + _grab(main_window, out_dir / f"main-window-{lang}.png") + main_window.update_state("RECORDING", None, None) + _grab(main_window, out_dir / f"main-window-recording-{lang}.png") + main_window.close() + + settings = SettingsDialog(config) + settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.general")) + _grab(settings, out_dir / f"settings-general-{lang}.png") + settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.workflows")) + _grab(settings, out_dir / f"settings-ai-workflows-{lang}.png") + settings.close() + + history = HistoryPanel(max_entries=50, notes_folder="") + history.resize(420, 460) + for text, merged in copy["history_entries"]: + history.add_entry(text, is_dictation=merged) + _grab(history, out_dir / f"history-{lang}.png") + # Let entry-add animations settle before the panel is torn down + time.sleep(0.9) + _process_events(6) + history.close() + + tts = TtsWindow(config) + tts.set_text(copy["tts_text"]) + _grab(tts, out_dir / f"tts-{lang}.png") + tts.close() + + _capture_tray_menu(config, lang, out_dir / f"tray-menu-{lang}.png") + _make_banner(lang, out_dir) + + def main() -> int: - out_dir = Path(sys.argv[1]).resolve() + global app + out_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else SCREENSHOT_DIR.resolve() out_dir.mkdir(parents=True, exist_ok=True) - + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") app = QApplication(sys.argv) - config = Config.load() - - # 1) Hauptfenster - controller = SimpleNamespace( - gui_toggle_recording=lambda *a, **k: None, - gui_discard=lambda *a, **k: None, - set_dictation_mode=lambda *a, **k: None, - show_history_panel=lambda *a, **k: None, - show_settings_dialog=lambda *a, **k: None, - show_tts_window=lambda *a, **k: None, - ) - win = MainWindow(controller) - _grab(win, out_dir / "main-window.png") - - # 2) Einstellungen — Tab 1 (Whisper & Audio, der Default-Tab) - settings = SettingsDialog(config) - _grab(settings, out_dir / "settings-whisper.png") - # weitere Tabs einzeln - for idx in range(1, settings.tabs.count()): - settings.tabs.setCurrentIndex(idx) - name = settings.tabs.tabText(idx).lower().replace(" ", "-").replace("&", "und") - _grab(settings, out_dir / f"settings-{name}.png") - - # 3) Verlauf — mit Beispiel-Eintraegen - history = HistoryPanel(max_entries=50, notes_folder="") - history.resize(420, 460) - history.add_entry("Treffen mit dem Team morgen um 10 Uhr verschieben.", is_dictation=False) - history.add_entry("Bitte sende mir den aktualisierten Projektplan zu.", is_dictation=True) - history.add_entry("Die Praesentation ist fertig und liegt im Ordner.", is_dictation=False) - _grab(history, out_dir / "history.png") - - # 4) Vorlesen (TTS) - tts = TtsWindow(config) - _grab(tts, out_dir / "tts.png") - - print("Fertig.") + for lang in ("en", "de"): + print(f"Generating assets for {lang} …") + _render_language_set(out_dir, lang) + print("Done.") return 0 diff --git a/tests/test_make_screenshots.py b/tests/test_make_screenshots.py new file mode 100644 index 0000000..6263b83 --- /dev/null +++ b/tests/test_make_screenshots.py @@ -0,0 +1,184 @@ +"""Unit-Tests für die reinen Hilfsfunktionen aus ``scripts/_make_screenshots.py``. + +Das Skript ist ein manuell ausgeführter Asset-Generator (Banner + Screenshots) +und kein Teil der ausgelieferten App. Getestet werden daher gezielt die reinen, +deterministischen Funktionen, die ohne ``QApplication`` auskommen: + +* ``_resize_card`` – Bild laden, einpassen, zentrieren (inkl. Fehlerpfade) +* ``_draw_multiline`` – Wortumbruch-Logik +* ``_font`` – Font-Fallback liefert immer eine nutzbare Schrift +* ``_tab_index`` – sprach- und reihenfolgenstabiler Settings-Tab-Lookup + +``scripts/`` ist kein Package; das Modul wird daher per ``importlib`` über den +Dateipfad geladen. Ein reiner Import erzeugt keine ``QApplication`` (verifiziert), +sodass die Tests ungated laufen können. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +# Pillow ist eine reine Dev-/Tooling-Abhängigkeit (siehe requirements-dev.txt). +# Fehlt sie in einer Teilumgebung, werden diese Tests sauber übersprungen statt +# die Collection der gesamten Suite abzubrechen. +pytest.importorskip("PIL") + +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + +from app.i18n import set_language, t # noqa: E402 + +_MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "_make_screenshots.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("_make_screenshots_under_test", _MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +screenshots = _load_module() + + +# --------------------------------------------------------------------------- # +# _resize_card +# --------------------------------------------------------------------------- # +def test_resize_card_centers_image_on_transparent_canvas(tmp_path): + # Arrange: ein 50x50-Bild, eingepasst in eine 100x100-Karte + source = tmp_path / "src.png" + Image.new("RGBA", (50, 50), (255, 0, 0, 255)).save(source) + + # Act + card = screenshots._resize_card(source, (100, 100)) + + # Assert: Kartengröße exakt, Inhalt zentriert (25px Rand), Ecken transparent + assert card.size == (100, 100) + assert card.getpixel((50, 50))[3] == 255 # Mitte deckend + assert card.getpixel((0, 0))[3] == 0 # Ecke transparent + + +def test_resize_card_preserves_aspect_ratio(tmp_path): + # Arrange: 200x100 (2:1) in eine 50x50-Karte + source = tmp_path / "wide.png" + Image.new("RGBA", (200, 100), (0, 128, 255, 255)).save(source) + + # Act + card = screenshots._resize_card(source, (50, 50)) + + # Assert: thumbnail vergrößert nie und hält das Seitenverhältnis (≤ Zielgröße) + assert card.size == (50, 50) + + +def test_resize_card_raises_filenotfound_for_missing_screenshot(tmp_path): + # Arrange / Act / Assert + missing = tmp_path / "does-not-exist.png" + with pytest.raises(FileNotFoundError): + screenshots._resize_card(missing, (100, 100)) + + +def test_resize_card_raises_oserror_for_corrupt_image(tmp_path): + # Arrange: vorhandene, aber kaputte PNG-Datei + corrupt = tmp_path / "corrupt.png" + corrupt.write_bytes(b"not a real png") + + # Act / Assert + with pytest.raises(OSError): + screenshots._resize_card(corrupt, (100, 100)) + + +# --------------------------------------------------------------------------- # +# _draw_multiline +# --------------------------------------------------------------------------- # +def test_draw_multiline_empty_string_returns_start_y(): + # Arrange + image = Image.new("RGBA", (200, 200), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + font = screenshots._font(15) + + # Act + end_y = screenshots._draw_multiline(draw, "", (10, 30), width=180, font=font, fill="#ffffff") + + # Assert: keine Zeile gezeichnet -> y unverändert + assert end_y == 30 + + +def test_draw_multiline_wraps_long_text_to_multiple_lines(): + # Arrange: Text, der bei schmaler Breite mehrere Zeilen erzwingt + image = Image.new("RGBA", (200, 200), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + font = screenshots._font(15) + text = "the quick brown fox jumps over the lazy dog repeatedly today" + + # Act + end_y = screenshots._draw_multiline(draw, text, (0, 0), width=80, font=font, fill="#ffffff") + + # Assert: mehrzeilig -> Endposition liegt mindestens zwei Zeilenhöhen tiefer + assert end_y > (font.size + 6) * 2 + + +# --------------------------------------------------------------------------- # +# _font +# --------------------------------------------------------------------------- # +def test_font_returns_usable_font_object(): + # Act + font = screenshots._font(24, bold=True) + + # Assert + assert font is not None + assert isinstance(font, (ImageFont.FreeTypeFont, ImageFont.ImageFont)) + + +def test_font_falls_back_to_default_when_no_files_present(monkeypatch): + # Arrange: keine Font-Datei wird gefunden -> Fallback auf load_default + monkeypatch.setattr(screenshots.Path, "is_file", lambda self: False) + + # Act + font = screenshots._font(24) + + # Assert: kein Crash, weiterhin eine nutzbare Schrift + assert isinstance(font, (ImageFont.FreeTypeFont, ImageFont.ImageFont)) + + +# --------------------------------------------------------------------------- # +# _tab_index (Regressionsschutz: vormals zeigte "ai-workflows" auf den +# falschen Index 2 = General statt 1 = Workflows) +# --------------------------------------------------------------------------- # +class _FakeTabs: + """Minimaler QTabWidget-Stub: bildet nur count() und tabText() ab.""" + + def __init__(self, titles: list[str]) -> None: + self._titles = titles + + def count(self) -> int: + return len(self._titles) + + def tabText(self, index: int) -> str: + return self._titles[index] + + +@pytest.mark.parametrize("lang", ["en", "de"]) +def test_tab_index_resolves_keys_independent_of_language(lang): + # Arrange: reale Tab-Reihenfolge speech, workflows, general in aktueller Sprache + set_language(lang) + tabs = _FakeTabs([ + t("settings.tab.speech"), + t("settings.tab.workflows"), + t("settings.tab.general"), + ]) + + # Act / Assert: Lookup trifft die korrekten Indizes (Workflows == 1, nicht 2) + assert screenshots._tab_index(tabs, "settings.tab.speech") == 0 + assert screenshots._tab_index(tabs, "settings.tab.workflows") == 1 + assert screenshots._tab_index(tabs, "settings.tab.general") == 2 + + +def test_tab_index_raises_for_unknown_tab(): + # Arrange + set_language("en") + tabs = _FakeTabs([t("settings.tab.general")]) + + # Act / Assert + with pytest.raises(ValueError): + screenshots._tab_index(tabs, "settings.tab.workflows")