Skip to content

Commit 9ffc975

Browse files
Lukas Geigerclaude
andcommitted
fix(bugsweep): 3 Bugs behoben – LSP-Stop-Crash, Editor-Selection-Wrap, Enum-Hygiene
Bug B-009 (lsp_client.py): subprocess.TimeoutExpired nach process.kill() wurde nicht gefangen, was _reader_thread.join() übersprungen hätte. Zweiter wait()-Aufruf jetzt in try/except eingebettet; finally-Block schließt Streams und setzt self.process = None. Bug B-010 (core/editor.py): Beim Auto-Close mit aktiver Textauswahl wurde die Auswahl verworfen statt umschlossen. Jetzt wird selectedText() mit dem Bracket-Paar umhüllt; U+2029 (Qt-Paragraph-Separator in mehrzeiligen Selections) wird vor dem Einfügen zu \n normalisiert. Bug B-008 (ui/main_window.py): closeEvent verwendete veraltete QMessageBox.Yes/No- Kurznamen statt QMessageBox.StandardButton.Yes/No (Deprecation-Hygiene für PySide6 6.x). Tests: 8 neue Regression-Tests in 3 Dateien (46+1 skipped gesamt, alles grün). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e649b1a commit 9ffc975

6 files changed

Lines changed: 175 additions & 5 deletions

File tree

core/editor.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,16 @@ def keyPressEvent(self, event):
148148
cursor = self.textCursor()
149149
pos = cursor.position()
150150
text = self.toPlainText()
151+
# Wrap selection in brackets/quotes instead of discarding it
152+
if cursor.hasSelection():
153+
selected = cursor.selectedText().replace('
', '\n')
154+
cursor.insertText(event.text() + selected + close_char)
155+
self.setTextCursor(cursor)
156+
return
151157
# Skip over existing closing char when open == close (quotes)
152158
if (event.text() == close_char
153159
and pos < len(text)
154-
and text[pos] == close_char
155-
and not cursor.hasSelection()):
160+
and text[pos] == close_char):
156161
cursor.movePosition(QTextCursor.MoveOperation.Right)
157162
self.setTextCursor(cursor)
158163
return

features/lsp_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@ def stop(self):
160160
process.wait(timeout=3)
161161
except subprocess.TimeoutExpired:
162162
process.kill()
163-
process.wait(timeout=3)
163+
try:
164+
process.wait(timeout=3)
165+
except subprocess.TimeoutExpired:
166+
pass
164167
finally:
165168
for stream in (process.stdin, process.stdout, process.stderr):
166169
if stream:

tests/test_editor_auto_close.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,46 @@ def test_open_bracket_auto_closes_normally(self):
7575
self.assertEqual(text, "()", f"Erwartet '()', bekommen: {text!r}")
7676
self.assertEqual(self.editor.textCursor().position(), 1)
7777

78+
def test_selection_wrapped_with_brackets(self):
79+
"""Regression B-010: Markierter Text + ( soll '(text)' ergeben, nicht '()'."""
80+
self.editor.setPlainText("hello")
81+
cursor = self.editor.textCursor()
82+
cursor.setPosition(0)
83+
cursor.setPosition(5, cursor.MoveMode.KeepAnchor)
84+
self.editor.setTextCursor(cursor)
85+
86+
self.editor.keyPressEvent(_key_event("("))
87+
text = self.editor.toPlainText()
88+
self.assertEqual(text, "(hello)", f"Erwartete '(hello)', bekommen: {text!r}")
89+
90+
def test_selection_wrapped_with_double_quotes(self):
91+
"""Regression B-010b: Markierter Text + \" soll '\"text\"' ergeben."""
92+
self.editor.setPlainText("world")
93+
cursor = self.editor.textCursor()
94+
cursor.setPosition(0)
95+
cursor.setPosition(5, cursor.MoveMode.KeepAnchor)
96+
self.editor.setTextCursor(cursor)
97+
98+
self.editor.keyPressEvent(_key_event('"'))
99+
text = self.editor.toPlainText()
100+
self.assertEqual(text, '"world"', f"Erwartete '\"world\"', bekommen: {text!r}")
101+
102+
def test_multiline_selection_wrapped_without_u2029(self):
103+
"""Regression B-010c: Mehrzeilige Markierung + ( darf kein U+2029 in den Text einfügen.
104+
selectedText() liefert U+2029 für Zeilenumbrüche — muss zu \\n normalisiert werden."""
105+
self.editor.setPlainText("line1\nline2")
106+
cursor = self.editor.textCursor()
107+
cursor.setPosition(0)
108+
cursor.movePosition(cursor.MoveOperation.End, cursor.MoveMode.KeepAnchor)
109+
self.editor.setTextCursor(cursor)
110+
111+
self.editor.keyPressEvent(_key_event("("))
112+
text = self.editor.toPlainText()
113+
self.assertNotIn('
', text,
114+
f"U+2029 darf nicht im Dokument erscheinen: {text!r}")
115+
self.assertEqual(text, "(line1\nline2)",
116+
f"Erwartete '(line1\\nline2)', bekommen: {text!r}")
117+
78118

79119
if __name__ == "__main__":
80120
unittest.main()

tests/test_lsp_client.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,72 @@ def test_unknown_language_is_unavailable(self):
5252
self.assertIsNone(client._resolve_command())
5353

5454

55+
class LSPClientStopRobustnessTests(unittest.TestCase):
56+
def test_stop_does_not_raise_when_kill_wait_times_out(self):
57+
"""Regression B-009: LSPClient.stop() darf keine Exception werfen wenn
58+
process.wait() nach process.kill() erneut TimeoutExpired auslöst.
59+
Ohne Fix propagiert die Exception und _reader_thread.join() wird nie erreicht."""
60+
import subprocess
61+
import types
62+
63+
client = LSPClient("Python")
64+
client._running = True
65+
client._reader_thread = None
66+
67+
process = types.SimpleNamespace(
68+
terminate=lambda: None,
69+
kill=lambda: None,
70+
wait=unittest.mock.Mock(side_effect=subprocess.TimeoutExpired(cmd=[], timeout=3)),
71+
stdin=None,
72+
stdout=None,
73+
stderr=None,
74+
)
75+
client.process = process
76+
77+
try:
78+
client.stop()
79+
except subprocess.TimeoutExpired:
80+
self.fail("stop() hat TimeoutExpired propagiert — _reader_thread.join() wurde übersprungen")
81+
82+
self.assertIsNone(client.process)
83+
self.assertIsNone(client._reader_thread)
84+
85+
def test_stop_joins_reader_thread_even_after_stubborn_process(self):
86+
"""_reader_thread.join() muss aufgerufen werden, auch wenn der Prozess
87+
beim Warten auf das Ende beide Male TimeoutExpired wirft."""
88+
import subprocess
89+
import threading
90+
import types
91+
92+
client = LSPClient("Python")
93+
client._running = True
94+
95+
joined = []
96+
97+
class FakeThread:
98+
def is_alive(self):
99+
return True
100+
101+
def join(self, timeout=None):
102+
joined.append(timeout)
103+
104+
client._reader_thread = FakeThread()
105+
106+
process = types.SimpleNamespace(
107+
terminate=lambda: None,
108+
kill=lambda: None,
109+
wait=unittest.mock.Mock(side_effect=subprocess.TimeoutExpired(cmd=[], timeout=3)),
110+
stdin=None,
111+
stdout=None,
112+
stderr=None,
113+
)
114+
client.process = process
115+
116+
client.stop()
117+
118+
self.assertTrue(joined, "_reader_thread.join() wurde nach stubborn process NICHT aufgerufen (B-009)")
119+
120+
55121
class LSPReadLoopTerminationTests(unittest.TestCase):
56122
def test_read_loop_terminates_on_server_death(self):
57123
"""Regressions-Test: _read_loop darf bei Server-Tod NICHT in einer

tests/test_save_failure_guards.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,59 @@ def test_connect_cursor_emits_no_runtime_warning_on_reconnect():
200200
assert hasattr(tab, "_cursor_slot"), "_cursor_slot muss nach _connect_cursor gesetzt sein"
201201

202202
window.close()
203+
204+
205+
def test_close_event_blocks_when_user_clicks_no(tmp_path):
206+
"""Regression B-008: closeEvent muss QMessageBox.StandardButton verwenden.
207+
Wird QMessageBox.No (deprecated, Integer) benutzt, schlägt der Vergleich
208+
stumm fehl und der Dialog blockiert nie."""
209+
from PySide6.QtCore import QEvent
210+
_ensure_app()
211+
212+
with patch("features.terminal.TerminalWidget._start_shell", lambda self: None):
213+
window = MainWindow()
214+
215+
tab = window.tab_widget.current_tab()
216+
assert tab is not None
217+
tab.is_modified = True
218+
tab.file_path = tmp_path / "unsaved.py"
219+
220+
event = QEvent(QEvent.Type.Close)
221+
222+
with patch.object(QMessageBox, "question",
223+
return_value=QMessageBox.StandardButton.No):
224+
window.closeEvent(event)
225+
226+
assert not event.isAccepted(), (
227+
"closeEvent() muss das Schliessen blockieren, wenn der User 'No' wählt (B-008)"
228+
)
229+
230+
tab.is_modified = False
231+
window.close()
232+
233+
234+
def test_close_event_allows_when_user_clicks_yes(tmp_path):
235+
"""Regression B-008b: closeEvent muss Schliessen erlauben wenn User 'Yes' wählt."""
236+
from PySide6.QtCore import QEvent
237+
_ensure_app()
238+
239+
with patch("features.terminal.TerminalWidget._start_shell", lambda self: None):
240+
window = MainWindow()
241+
242+
tab = window.tab_widget.current_tab()
243+
assert tab is not None
244+
tab.is_modified = True
245+
tab.file_path = tmp_path / "unsaved.py"
246+
247+
event = QEvent(QEvent.Type.Close)
248+
249+
with patch.object(QMessageBox, "question",
250+
return_value=QMessageBox.StandardButton.Yes):
251+
window.closeEvent(event)
252+
253+
assert event.isAccepted(), (
254+
"closeEvent() muss Schliessen erlauben, wenn der User 'Yes' wählt (B-008b)"
255+
)
256+
257+
tab.is_modified = False
258+
window.close()

ui/main_window.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,9 @@ def closeEvent(self, event):
487487
reply = QMessageBox.question(
488488
self, "Beenden",
489489
f"Es gibt ungespeicherte Änderungen in:\n{names}\n\nTrotzdem beenden?",
490-
QMessageBox.Yes | QMessageBox.No
490+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
491491
)
492-
if reply == QMessageBox.No:
492+
if reply == QMessageBox.StandardButton.No:
493493
event.ignore()
494494
return
495495
# Terminal-Prozess sauber beenden

0 commit comments

Comments
 (0)