Fix Qt worker lifecycle crash
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -13,8 +15,8 @@ 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
|
||||
from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
|
||||
from javis.ui.workers import ApprovalBridge, StreamWorker
|
||||
|
||||
|
||||
class _Controller:
|
||||
@@ -28,6 +30,15 @@ class _Controller:
|
||||
message_count=2,
|
||||
updated_at="2026-07-30T20:00:00+00:00",
|
||||
)
|
||||
self.messages = [
|
||||
ChatMessage("user", "Hallo"),
|
||||
ChatMessage("assistant", "Guten Tag"),
|
||||
]
|
||||
self.approval: PrivacyDecision | None = None
|
||||
self.queued_approval: ApprovalChoice | None = None
|
||||
self.started = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.release.set()
|
||||
|
||||
def list_sessions(self, search: str = ""):
|
||||
return [self.session] if not search or search in self.session.title else []
|
||||
@@ -36,10 +47,7 @@ class _Controller:
|
||||
self.active_session_id = session_id
|
||||
return SimpleNamespace(
|
||||
session=self.session,
|
||||
messages=[
|
||||
ChatMessage("user", "Hallo"),
|
||||
ChatMessage("assistant", "Guten Tag"),
|
||||
],
|
||||
messages=list(self.messages),
|
||||
)
|
||||
|
||||
def new_session(self):
|
||||
@@ -53,6 +61,38 @@ class _Controller:
|
||||
def set_mode(self, _mode: str) -> None:
|
||||
return
|
||||
|
||||
def approval_decision(self, _text: str) -> PrivacyDecision | None:
|
||||
return self.approval
|
||||
|
||||
def queue_approval(self, choice: ApprovalChoice) -> None:
|
||||
self.queued_approval = choice
|
||||
|
||||
def stream_message(self, text: str, cancel_event: threading.Event):
|
||||
self.started.set()
|
||||
self.state = GenerationState.THINKING
|
||||
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||
while not self.release.wait(0.005):
|
||||
if cancel_event.is_set():
|
||||
self.state = GenerationState.ABORTED
|
||||
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||
return
|
||||
if cancel_event.is_set():
|
||||
self.state = GenerationState.ABORTED
|
||||
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||
return
|
||||
self.state = GenerationState.ANSWERING
|
||||
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||
yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort")
|
||||
self.messages.extend(
|
||||
[
|
||||
ChatMessage("user", text),
|
||||
ChatMessage("assistant", "Testantwort"),
|
||||
]
|
||||
)
|
||||
self.session.message_count = len(self.messages)
|
||||
self.state = GenerationState.READY
|
||||
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
|
||||
|
||||
def status(self) -> DesktopStatus:
|
||||
return DesktopStatus(
|
||||
state=self.state,
|
||||
@@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.bridge = ApprovalBridge()
|
||||
self.window = MainWindow(_Controller(), self.bridge)
|
||||
self.controller = _Controller()
|
||||
self.window = MainWindow(self.controller, self.bridge)
|
||||
self.window.show()
|
||||
self.application.processEvents()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.controller.release.set()
|
||||
self.window._stop_generation()
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
self.window.close()
|
||||
self.application.processEvents()
|
||||
|
||||
def _process_until(self, condition, timeout: float = 2.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while not condition() and time.monotonic() < deadline:
|
||||
self.application.processEvents()
|
||||
time.sleep(0.005)
|
||||
self.application.processEvents()
|
||||
self.assertTrue(condition())
|
||||
|
||||
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)
|
||||
@@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertIs(self.window._show_approval(decision), choice)
|
||||
|
||||
def test_local_completion_and_second_question_keep_gui_reusable(self) -> None:
|
||||
for text in ("Erste Frage", "Zweite Frage"):
|
||||
self.window.message_edit.setPlainText(text)
|
||||
self.window._send()
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
|
||||
self.assertEqual(self.controller.session.message_count, 6)
|
||||
self.assertIn("Zweite Frage", self.window.chat_view.toPlainText())
|
||||
self.assertTrue(self.window.send_button.isEnabled())
|
||||
|
||||
def test_approval_is_decided_before_worker_starts(self) -> None:
|
||||
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
|
||||
self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW)
|
||||
|
||||
self.window.message_edit.setPlainText("Private Testfrage")
|
||||
self.window._send()
|
||||
|
||||
self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW)
|
||||
self.window._show_approval.assert_called_once()
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
|
||||
def test_local_approval_choice_is_queued_before_worker_starts(self) -> None:
|
||||
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
|
||||
self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL)
|
||||
|
||||
self.window.message_edit.setPlainText("Lokal beantworten")
|
||||
self.window._send()
|
||||
|
||||
self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL)
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
|
||||
def test_stop_during_streaming_discards_partial_exchange(self) -> None:
|
||||
self.controller.release.clear()
|
||||
before = list(self.controller.messages)
|
||||
self.window.message_edit.setPlainText("Nicht speichern")
|
||||
self.window._send()
|
||||
self._process_until(self.controller.started.is_set)
|
||||
|
||||
self.window._stop_generation()
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
|
||||
self.assertEqual(self.controller.messages, before)
|
||||
self.assertEqual(self.controller.state, GenerationState.ABORTED)
|
||||
|
||||
def test_stop_immediately_after_start_is_safe(self) -> None:
|
||||
self.controller.release.clear()
|
||||
self.window.message_edit.setPlainText("Sofort stoppen")
|
||||
self.window._send()
|
||||
self.window._stop_generation()
|
||||
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
self.assertEqual(self.controller.session.message_count, 2)
|
||||
|
||||
def test_completion_is_idempotent_and_retains_qt_references(self) -> None:
|
||||
worker = object()
|
||||
thread = object()
|
||||
self.window._worker = worker
|
||||
self.window._thread = thread
|
||||
self.window._generation_finalized = False
|
||||
|
||||
self.window._generation_complete()
|
||||
self.window._generation_complete()
|
||||
|
||||
self.assertIs(self.window._worker, worker)
|
||||
self.assertIs(self.window._thread, thread)
|
||||
self.window._thread_finished()
|
||||
self.assertIsNone(self.window._worker)
|
||||
self.assertIsNone(self.window._thread)
|
||||
|
||||
def test_worker_emits_completion_only_once(self) -> None:
|
||||
worker = StreamWorker(self.controller, "Nur einmal")
|
||||
completions: list[bool] = []
|
||||
worker.completed.connect(lambda: completions.append(True))
|
||||
|
||||
worker.run()
|
||||
worker.run()
|
||||
|
||||
self.assertEqual(completions, [True])
|
||||
|
||||
def test_close_during_generation_cancels_then_closes(self) -> None:
|
||||
self.controller.release.clear()
|
||||
self.window.message_edit.setPlainText("Beim Schließen stoppen")
|
||||
self.window._send()
|
||||
self._process_until(self.controller.started.is_set)
|
||||
|
||||
self.window.close()
|
||||
self.assertTrue(self.window._close_when_finished)
|
||||
self._process_until(lambda: self.window._thread is None)
|
||||
self._process_until(lambda: not self.window.isVisible())
|
||||
|
||||
Reference in New Issue
Block a user