From 571969b69b672692357b81784405f88ae71963ec Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 20:26:03 +0200 Subject: [PATCH] test: verify and document desktop ui --- CHANGELOG.md | 4 + README.md | 15 ++- docs/CHATGPT_HANDOFF.md | 26 ++--- docs/DECISIONS.md | 1 + docs/DESKTOP_UI.md | 87 ++++++++++++++++ docs/NEXT_SESSION.md | 10 +- docs/PROJECT_STATUS.md | 21 +++- scripts/start-javis-gui.ps1 | 20 ++++ scripts/start-javis.ps1 | 7 +- src/javis/ui/app.py | 9 ++ src/javis/ui/chat_controller.py | 6 +- src/javis/ui/main_window.py | 8 +- tests/unit/test_desktop_controller.py | 24 ++++- tests/unit/test_desktop_gui.py | 141 ++++++++++++++++++++++++++ tests/unit/test_start_script.py | 18 ++++ 15 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 docs/DESKTOP_UI.md create mode 100644 scripts/start-javis-gui.ps1 create mode 100644 tests/unit/test_desktop_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cabd9af..cea9a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen Gedankenfilter, lesbarer Terminalausgabe und sicherem Strg+C-Abbruch. - Automatische lokale Sitzungstitel, `/rename`, nummerierte `/sessions`, `/load ` und begrenzte `/search`-Suche. +- PySide6-Desktop-MVP mit Sitzungsleiste, Markdownchat, Qt-Streamingworker, + sicherem Abbruch, Datenschutzdialog und bereinigter Statusspalte. +- Sicherer Startweg `scripts/start-javis-gui.ps1`, der die vorhandene + Loopback-/Prozesslogik des CLI-Starters wiederverwendet. ### Changed diff --git a/README.md b/README.md index cfe70ae..1e0a8da 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers ## Aktueller Stand Der lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`. -Ein optionaler Gemini-Free-Provider ist auf einem Feature-Branch vorbereitet; -Datenschutzrouter, Nullkostenmodus und lokales Fallback sind automatisiert -getestet, aber noch nicht von Pascal mit einem echten Key abgenommen. +Gemini-Free-Hybridrouting, Datenschutz, Streaming und Sitzungsnavigation sind +implementiert. Zusätzlich steht ein erster PySide6-Desktopclient zur manuellen +Abnahme bereit. Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile Client und Living Mind. @@ -33,6 +33,15 @@ entsprechenden `JAVIS_*`-Umgebungsvariablen möglich. Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen sicheren benutzerspezifischen Plattformstandard. +Desktopoberfläche: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1 +``` + +Alternativ: `uv run javis gui`. Die [Desktop-Anleitung](docs/DESKTOP_UI.md) +beschreibt Tastenkürzel, Datenschutzdialog und den manuellen Abnahmetest. + Gemini bleibt standardmäßig aus. Die spätere lokale Einrichtung erfolgt ohne Key in der Konfigurationsdatei: diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 1293155..fcd6456 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -115,6 +115,8 @@ Rootserver: Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet. - `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`; `never` erreicht weiterhin keinen Cloudfreigabedialog. +- Startwege `uv run javis gui` und `scripts/start-javis-gui.ps1` funktionieren; + der GUI-Starter verwendet die sichere Loopback-/Prozesslogik des CLI-Starters. ## Aktuelle Architektur @@ -195,8 +197,12 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 97 bestanden; zusätzlich GUI-Streamingereignisse, atomarer Abbruch - und alle drei Ergebnisse des Datenschutzdialogs abgedeckt +- Tests: 101 bestanden; zusätzlich Qt-Offscreen-Fenster, Dialogzuordnung und + sichere Wiederverwendung des Startskripts abgedeckt +- `uv run javis gui` im Offscreen-Modus: erfolgreich, Exitcode 0 +- echter lokaler GUI-Smoke: erfolgreich; 1 Sitzung, 2 Nachrichten, Provider Ollama +- GUI-Startskript mit prozesslokalem ExecutionPolicy-Bypass: erfolgreich +- manuelle Desktop-Abnahme durch Pascal: noch offen - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -243,21 +249,14 @@ Abnahmestatus: - aktueller Arbeitsbranch: `feat/desktop-ui` - stabiler Ausgangsstand: `main` bei `d3e85f2` -- stabiler Ausgangsstand: `main` bei `77b510b` -- medizinischer Datenschutz-Fix: `9148193` -- Provider- und CLI-Streaming: `00a5a6c` -- SQLite-Titel und Migration: `112ae6d` +- GUI-Grundgerüst und Kernadapter: `1658e04` +- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148` - Sitzungsnavigation: `43c1f76` - Dokumentation: `328dcd9` - Chatkomfort-Merge nach `main`: `cbba102` - 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden - Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht - `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert -- CLI-Verdrahtung: `af9426e` -- Startskript: `141ff2e` -- Resttests: `a0af15a` -- Routing-/Reporting-Korrektur: `7a14fe8` -- Gemini-/Datenschutz-Merge nach `main`: `564a3fd` - `origin` verwendet HTTPS - kein Force-Push und keine umgeschriebene Historie @@ -289,8 +288,9 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Auf `feat/desktop-ui` als Nächstes Offscreen-GUI-Tests, sicheres Startskript, -Desktopanleitung und anschließend echten lokalen GUI-Smoke ergänzen. +Auf `feat/desktop-ui` als Nächstes vollständige Abschlussprüfungen ausführen, +Dokumentation committen und Feature-Branch pushen. Danach für Pascals manuelle +Desktop-Abnahme stoppen; noch nicht nach `main` mergen. Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. Noch keine Obsidian-Integration oder Tools beginnen. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 995754b..a5b32bd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -9,6 +9,7 @@ | ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen | | ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen | | ADR-0007 | Gemini kostenlos bevorzugen, sensible Inhalte lokal halten und niemals Billing aktivieren | angenommen | +| UI-0001 | Desktop-MVP verwendet PySide6 und denselben Javis-Kern wie die CLI | angenommen | Der normale Runtime-Secret-Speicher ist mit dem Betriebssystem-Keyring festgelegt; keine XML-Secret-Datei. Offen bleiben insbesondere der genaue diff --git a/docs/DESKTOP_UI.md b/docs/DESKTOP_UI.md new file mode 100644 index 0000000..88e003e --- /dev/null +++ b/docs/DESKTOP_UI.md @@ -0,0 +1,87 @@ +# Javis Desktop + +## Installation und Start + +Die Desktopoberfläche verwendet PySide6 und denselben Python-Kern wie der CLI-Chat. + +```powershell +& 'D:\Javis-Tools\uv\uv.exe' sync --dev +& 'D:\Javis-Tools\uv\uv.exe' run javis gui +``` + +Der empfohlene Windows-Start prüft zusätzlich Repository, lokale Werkzeuge und +Ollama und setzt ausschließlich Prozessvariablen: + +```powershell +.\scripts\start-javis-gui.ps1 +``` + +Falls Windows den direkten Skriptaufruf per Richtlinie blockiert, funktioniert +ohne systemweite Änderung der prozesslokale Start: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1 +``` + +Der bisherige Start über `.\scripts\start-javis.ps1` beziehungsweise +`uv run javis chat` bleibt unverändert verfügbar. + +## Bedienung + +- `Enter`: Nachricht senden +- `Shift+Enter`: neue Zeile +- `Ctrl+N`: neue Sitzung +- `Ctrl+K`: Sitzungssuche fokussieren +- `Esc`: laufende Antwort abbrechen +- Doppelklick: Sitzung laden +- Kontextmenü einer Sitzung: umbenennen + +Die linke Spalte verwaltet Sitzungen. In der Mitte erscheinen Verlauf, +Markdownantwort und Eingabe. Rechts stehen nur bereinigte technische Metadaten: +Zustand, Modus, Provider, Ollama-Erreichbarkeit, Modell, Datenschutzentscheidung, +Cloudzähler, Fallback und Datenverzeichnis. + +## Provider und Datenschutz + +`Hybrid/Auto` verwendet weiterhin den vorhandenen lokalen Datenschutzrouter. +`Lokal` verhindert Cloudnutzung. Bei `ask` stehen drei Entscheidungen bereit: + +- `Einmal erlauben`: minimierten notwendigen Kontext einmalig an Gemini senden +- `Lokal beantworten`: ohne Cloud mit Ollama antworten +- `Abbrechen`: Anfrage ohne Speicherung beenden + +`never` wird ausschließlich lokal verarbeitet und zeigt keinen Freigabedialog. +Der Gemini-Key bleibt im Betriebssystem-Keyring und wird in der Oberfläche weder +ausgegeben noch dokumentiert. + +## Streaming und Abbruch + +Die Modellgenerierung läuft in einem Qt-Worker. Es kann nur eine Antwort +gleichzeitig laufen. `Stoppen`, `Esc` oder kontrolliertes Schließen fordern den +Abbruch an. Ein unvollständiges Benutzer-/Assistentenpaar wird nicht gespeichert. + +## Bekannte Grenzen + +- keine Sprache, kein Mikrofon und kein Wakeword +- keine Betriebssystem-, App-, Obsidian-, Server- oder Laptopsteuerung +- keine autonomen Aktionen und keine neuen Cloudanbieter +- Schließen kann kurz warten, bis der lokale Netzwerkstream kontrolliert reagiert +- medizinische Antwortqualität ist ein späteres Sicherheits-/Systemprompt-Thema + +## Manueller Abnahmetest für Pascal + +1. `.\scripts\start-javis-gui.ps1` +2. Prüfen, dass sich das Fenster öffnet. +3. Eine lokale Frage stellen. +4. Die gestreamte Antwort prüfen. +5. Eine neue Sitzung anlegen. +6. Die alte Sitzung laden. +7. Eine Antwort mit `Esc` oder `Stoppen` abbrechen. +8. Den Modus `Lokal` testen. +9. Eine öffentliche Technikfrage im Hybridmodus testen. +10. Eine `ask`-Anfrage lokal beantworten lassen. +11. Anwendung schließen und neu starten. +12. Die gespeicherte Sitzung erneut laden. + +Abnahmestatus: automatisierte Tests und echter lokaler GUI-Smoke durch Codex +bestanden; manuelle Abnahme durch Pascal steht noch aus. diff --git a/docs/NEXT_SESSION.md b/docs/NEXT_SESSION.md index ff20d01..4a113ad 100644 --- a/docs/NEXT_SESSION.md +++ b/docs/NEXT_SESSION.md @@ -10,14 +10,14 @@ Chatkomfort umfasst sicheres Ollama-/Gemini-Streaming, Strg+C ohne Teilpersisten medizinisches `never`, automatische und manuelle Sitzungstitel, nummerierte Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten. -91 Unit-Tests, Ruff, Struktur-, Secret-, Gitignore- und Auditprüfungen sowie -echte Ollama- und Gemini-Streaming-Smokes sind erfolgreich. +Das Desktop-MVP auf `feat/desktop-ui` verwendet denselben Kern wie die CLI. +101 Tests, Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke sind erfolgreich. +Die manuelle Abnahme durch Pascal steht noch aus. ## Nächster sinnvoller Schritt -Der Chatkomfort-Branch ist nach `main` gemergt und dort erneut geprüft. Den -finalen Stand aus `docs/CHATGPT_HANDOFF.md` übernehmen und mit Pascal ein -einzelnes nächstes Paket festlegen; noch keine Folgephase automatisch beginnen. +Nur den Desktop-MVP nach `docs/DESKTOP_UI.md` manuell abnehmen. Erst danach mit +Pascal ein einzelnes nächstes Paket festlegen; keine Folgephase automatisch beginnen. ## Weiterhin nicht beginnen diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 67b4389..32eec05 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026. +Stand: Desktop-MVP automatisiert und mit lokalem Modell geprüft, 30.07.2026. ## Erreicht @@ -28,7 +28,7 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026. Inhalte werden nicht wörtlich in automatische Titel übernommen - echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem neuen Prozess bestätigt -- 91 Unit-Tests, Ruff, Struktur-, Gitignore-, Secret- und Auditprüfung erfolgreich +- 101 Tests, Ruff, Struktur-, Gitignore-, Secret- und Auditprüfung erfolgreich - lokaler Core-Chat am 30.07.2026 zusätzlich von Pascal manuell erfolgreich geprüft: deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart, `/load `, vollständiger Verlauf und Fortsetzung @@ -96,9 +96,24 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026. Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client, Living-Mind-Frontend. +## Desktop-MVP + +- PySide6-Client verwendet Runtime, Chatservice, Hybridprovider, Datenschutz, + SQLite, Audit und Secret-Provider des bestehenden Kerns +- Sitzungen, Suche, Umbenennen, Markdown, Streaming, Abbruch, Modus, + Dreifach-Freigabedialog und bereinigter Status implementiert +- `uv run javis gui` und `scripts/start-javis-gui.ps1` vorhanden +- Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke bestanden +- automatisiert durch Codex getestet: ja +- echter lokaler GUI-Smoke durch Codex: ja; 1 Sitzung, 2 Nachrichten, Ollama +- manuell durch Pascal abgenommen: noch nicht + ## Git -- aktueller Branch: `main` +- aktueller Branch: `feat/desktop-ui` +- Desktop-Basis: `main` bei `d3e85f2` +- GUI-Grundgerüst: `1658e04` +- Sitzungen/Streaming/Datenschutz: `cf35148` - Basis: `main` bei `77b510b` - medizinisches Routing: `9148193` - Streaming: `00a5a6c` diff --git a/scripts/start-javis-gui.ps1 b/scripts/start-javis-gui.ps1 new file mode 100644 index 0000000..b9bed5e --- /dev/null +++ b/scripts/start-javis-gui.ps1 @@ -0,0 +1,20 @@ +[CmdletBinding()] +param( + [string]$UvPath, + [string]$OllamaPath, + [string]$OllamaModels, + [string]$DataDir, + [string]$UvCacheDir, + [string]$PythonInstallDir +) + +$ErrorActionPreference = 'Stop' +$env:JAVIS_START_INTERFACE = 'gui' + +$sharedStarter = Join-Path $PSScriptRoot 'start-javis.ps1' +if (-not (Test-Path -LiteralPath $sharedStarter -PathType Leaf)) { + throw "Das gemeinsame sichere Javis-Startskript wurde nicht gefunden." +} + +& $sharedStarter @PSBoundParameters +exit $LASTEXITCODE diff --git a/scripts/start-javis.ps1 b/scripts/start-javis.ps1 index b80374d..453c397 100644 --- a/scripts/start-javis.ps1 +++ b/scripts/start-javis.ps1 @@ -113,7 +113,12 @@ if (-not (Test-OllamaReady)) { Push-Location $repositoryRoot try { - & $resolvedUv run javis chat + if ($env:JAVIS_START_INTERFACE -eq 'gui') { + & $resolvedUv run javis gui + } + else { + & $resolvedUv run javis chat + } exit $LASTEXITCODE } finally { diff --git a/src/javis/ui/app.py b/src/javis/ui/app.py index e0a57ab..6d77815 100644 --- a/src/javis/ui/app.py +++ b/src/javis/ui/app.py @@ -2,9 +2,11 @@ from __future__ import annotations +import os import sys from collections.abc import Sequence +from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication, QMessageBox from javis.config.settings import ConfigurationError, Settings @@ -32,6 +34,13 @@ def run_gui(argv: Sequence[str] | None = None) -> int: return 2 window = MainWindow(DesktopController(runtime), approval_bridge) window.show() + smoke_exit = os.environ.get("JAVIS_GUI_SMOKE_EXIT_MS") + if smoke_exit: + try: + delay = max(1, min(int(smoke_exit), 10_000)) + except ValueError: + delay = 500 + QTimer.singleShot(delay, application.quit) return application.exec() diff --git a/src/javis/ui/chat_controller.py b/src/javis/ui/chat_controller.py index 6565825..459b187 100644 --- a/src/javis/ui/chat_controller.py +++ b/src/javis/ui/chat_controller.py @@ -8,7 +8,7 @@ from collections.abc import Iterator from javis.core.runtime import JavisRuntime from javis.memory.sqlite_store import ChatSession -from javis.providers.base import ProviderError +from javis.providers.base import ProviderError, ResponseAbortedError from javis.ui.models import ( DesktopStatus, GenerationState, @@ -87,6 +87,10 @@ class DesktopController: self.state = GenerationState.ANSWERING yield StreamEvent(StreamEventKind.STATE, state=self.state) yield StreamEvent(StreamEventKind.CHUNK, text=chunk) + except ResponseAbortedError: + self.state = GenerationState.ABORTED + yield StreamEvent(StreamEventKind.STATE, state=self.state) + return except ProviderError as exc: if cancel_event.is_set(): self.state = GenerationState.ABORTED diff --git a/src/javis/ui/main_window.py b/src/javis/ui/main_window.py index e443a22..dedea24 100644 --- a/src/javis/ui/main_window.py +++ b/src/javis/ui/main_window.py @@ -4,7 +4,7 @@ from __future__ import annotations from html import escape -from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtCore import QSignalBlocker, Qt, QThread, QTimer, Signal from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QShortcut from PySide6.QtWidgets import ( QComboBox, @@ -375,6 +375,12 @@ class MainWindow(QMainWindow): self._show_error(str(exc)) return self._set_state(status.state) + target_mode = "local" if status.mode == "Lokal" else "auto" + mode_index = self.mode_combo.findData(target_mode) + if mode_index >= 0 and mode_index != self.mode_combo.currentIndex(): + blocker = QSignalBlocker(self.mode_combo) + self.mode_combo.setCurrentIndex(mode_index) + del blocker self.status_labels["provider"].setText(status.provider) self.status_labels["ollama"].setText("Ja" if status.ollama_reachable else "Nein") self.status_labels["model"].setText(status.local_model) diff --git a/tests/unit/test_desktop_controller.py b/tests/unit/test_desktop_controller.py index 2f3490c..53cbe2c 100644 --- a/tests/unit/test_desktop_controller.py +++ b/tests/unit/test_desktop_controller.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from javis.core.chat_service import ChatService from javis.memory.sqlite_store import SQLiteSessionStore -from javis.providers.base import ChatMessage +from javis.providers.base import ChatMessage, ResponseAbortedError from javis.ui.chat_controller import DesktopController, sanitized_error from javis.ui.models import GenerationState, StreamEventKind @@ -64,6 +64,12 @@ class _StreamingProvider: self.mode = mode +class _AbortingProvider(_StreamingProvider): + def stream_chat(self, _messages): + raise ResponseAbortedError("Vom Dialog abgebrochen") + yield + + class DesktopControllerTests(unittest.TestCase): def setUp(self) -> None: self.runtime = SimpleNamespace( @@ -131,3 +137,19 @@ class DesktopControllerTests(unittest.TestCase): self.assertEqual(events[-1].state, GenerationState.ABORTED) session = service.list_sessions()[0] self.assertEqual(service.load_session(session.id).messages, []) + + def test_provider_abort_is_reported_as_aborted_not_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + provider = _AbortingProvider() + service = ChatService( + SQLiteSessionStore(Path(directory) / "sessions.sqlite3"), + provider, + ) + controller = DesktopController( + SimpleNamespace(service=service, hybrid_provider=provider) + ) + + events = list(controller.stream_message("Hallo", threading.Event())) + + self.assertEqual(events[-1].state, GenerationState.ABORTED) + self.assertNotIn(StreamEventKind.ERROR, [event.kind for event in events]) diff --git a/tests/unit/test_desktop_gui.py b/tests/unit/test_desktop_gui.py new file mode 100644 index 0000000..aed8b0c --- /dev/null +++ b/tests/unit/test_desktop_gui.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import os +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from javis.core.provider_router import ApprovalChoice +from javis.providers.base import ChatMessage +from javis.security.privacy import CloudPolicy, PrivacyDecision +from javis.ui.main_window import MainWindow +from javis.ui.models import DesktopStatus, GenerationState +from javis.ui.workers import ApprovalBridge + + +class _Controller: + def __init__(self) -> None: + self.active_session_id = None + self.state = GenerationState.READY + self.session = SimpleNamespace( + id="session-one", + title="Desktop-Test", + last_provider="ollama", + message_count=2, + updated_at="2026-07-30T20:00:00+00:00", + ) + + def list_sessions(self, search: str = ""): + return [self.session] if not search or search in self.session.title else [] + + def load_session(self, session_id: str): + self.active_session_id = session_id + return SimpleNamespace( + session=self.session, + messages=[ + ChatMessage("user", "Hallo"), + ChatMessage("assistant", "Guten Tag"), + ], + ) + + def new_session(self): + self.active_session_id = self.session.id + return self.session + + def rename_active_session(self, title: str): + self.session.title = title + return self.session + + def set_mode(self, _mode: str) -> None: + return + + def status(self) -> DesktopStatus: + return DesktopStatus( + state=self.state, + mode="Lokal", + provider="ollama", + ollama_reachable=True, + local_model="test-model", + privacy_policy="never", + cloud_requests_today=0, + local_fallback=False, + data_dir="D:\\Testdaten", + ) + + +class _MessageBox: + Question = object() + AcceptRole = object() + RejectRole = object() + DestructiveRole = object() + selected_label = "" + + def __init__(self, _parent) -> None: + self.buttons: dict[str, object] = {} + + def setWindowTitle(self, _text: str) -> None: + return + + def setIcon(self, _icon: object) -> None: + return + + def setText(self, _text: str) -> None: + return + + def setInformativeText(self, _text: str) -> None: + return + + def addButton(self, label: str, _role: object) -> object: + button = object() + self.buttons[label] = button + return button + + def exec(self) -> None: + return + + def clickedButton(self) -> object | None: + return self.buttons.get(type(self).selected_label) + + +class DesktopGuiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.application = QApplication.instance() or QApplication([]) + + def setUp(self) -> None: + self.bridge = ApprovalBridge() + self.window = MainWindow(_Controller(), self.bridge) + self.window.show() + self.application.processEvents() + + def tearDown(self) -> None: + self.window.close() + self.application.processEvents() + + def test_offscreen_window_loads_session_and_core_widgets(self) -> None: + self.assertEqual(self.window.windowTitle(), "Javis Desktop") + self.assertEqual(self.window.session_list.count(), 1) + self.assertIn("Hallo", self.window.chat_view.toPlainText()) + self.assertTrue(self.window.send_button.isEnabled()) + self.assertFalse(self.window.stop_button.isEnabled()) + self.assertEqual(self.window.status_labels["provider"].text(), "ollama") + + def test_approval_dialog_maps_all_three_choices(self) -> None: + decision = PrivacyDecision(CloudPolicy.ASK, "persönliche Information") + self.bridge.resolve = Mock() + expected = { + "Einmal erlauben": ApprovalChoice.ALLOW, + "Lokal beantworten": ApprovalChoice.LOCAL, + "Abbrechen": ApprovalChoice.CANCEL, + } + + with patch("javis.ui.main_window.QMessageBox", _MessageBox): + for label, choice in expected.items(): + with self.subTest(label=label): + _MessageBox.selected_label = label + self.window._show_approval(decision) + self.bridge.resolve.assert_called_with(choice) diff --git a/tests/unit/test_start_script.py b/tests/unit/test_start_script.py index 996a3be..6d012f7 100644 --- a/tests/unit/test_start_script.py +++ b/tests/unit/test_start_script.py @@ -25,6 +25,24 @@ class StartScriptTests(unittest.TestCase): ): self.assertNotIn(forbidden, normalized) + def test_gui_script_reuses_safe_starter_without_system_changes(self) -> None: + repository_root = Path(__file__).resolve().parents[2] + script = (repository_root / "scripts" / "start-javis-gui.ps1").read_text(encoding="utf-8") + normalized = script.lower() + + self.assertIn("start-javis.ps1", normalized) + self.assertIn("javis_start_interface", normalized) + self.assertIn("'gui'", normalized) + self.assertNotIn("c:\\users\\", normalized) + for forbidden in ( + "set-executionpolicy", + "setx ", + "new-service", + "schtasks", + "hkey_", + ): + self.assertNotIn(forbidden, normalized) + if __name__ == "__main__": unittest.main()