Skip to content

Commit 02d0ea4

Browse files
lukischclaude
andcommitted
fix: terminal QProcess-Signal-Disconnect + project_view a11y
B-012: _start_shell() trennt readyReadStandardOutput/Error/finished des alten QProcess vor Ersatz (try/except TypeError,RuntimeError); kill() nur bei state()!=NotRunning. Analog B-004 in output.py. project_view: objectName, Tooltips, accessibleName/Description fuer Ordner-/Aktualisieren-Buttons. 56/56 Tests gruen (3 neue TerminalStartShellTests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4adc3ad commit 02d0ea4

5 files changed

Lines changed: 155 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
99

1010
- `features/remote_editor.py`: SSH/SFTP-Verbindungen laden bekannte Hostkeys und
1111
lehnen unbekannte Hostkeys jetzt ab, statt sie automatisch zu akzeptieren.
12+
- `features/terminal.py` (B-012): `_start_shell()` trennte die Signale
13+
(`readyReadStandardOutput`, `readyReadStandardError`, `finished`) des alten
14+
`QProcess`-Objekts nicht, bevor es durch ein neues ersetzt wurde. Beim
15+
Shell-Neustart konnte der alte Prozess nach `kill()` noch `_on_finished` oder
16+
`_on_stdout` auslösen und so einen spuriösen „Shell beendet"-Eintrag in den
17+
neuen Terminal-Output schreiben. Fix: Signale werden jetzt analog zu B-004
18+
(`core/output.py`) getrennt; `kill()` wird nur noch bei `state() != NotRunning`
19+
aufgerufen. 3 Regressionstests in `tests/test_terminal_encoding.py` ergänzt.
20+
- `features/project_view.py`: Die kompakte Sidebar im Projektbaum verlässt sich
21+
für Filterfeld und Dateibaum nicht mehr nur auf Placeholder und Position.
22+
`Ordner...`, `Aktualisieren`, das Filterfeld und der Dateibaum exponieren
23+
jetzt sprechende Accessible Names, Descriptions und Tooltips; Regressionstest
24+
in `tests/test_project_view.py` ergänzt.
1225
- `ui/main_window.py` (B-011): ProjectView blieb beim Öffnen einer Datei aus
1326
einem anderen Ordner auf dem ersten Root hängen. Der Projektbaum folgt jetzt
1427
auch bei späteren Dateiwechseln dem aktuellen Dateiverzeichnis; neuer

features/project_view.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,23 +90,42 @@ def _setup_ui(self):
9090

9191
header.addStretch()
9292

93-
btn_open = QPushButton("Ordner...")
94-
btn_open.setFixedWidth(60)
95-
btn_open.setStyleSheet("font-size: 10px;")
96-
btn_open.clicked.connect(self._open_folder_dialog)
97-
header.addWidget(btn_open)
98-
99-
btn_refresh = QPushButton("Aktualisieren")
100-
btn_refresh.setFixedWidth(80)
101-
btn_refresh.setStyleSheet("font-size: 10px;")
102-
btn_refresh.clicked.connect(self._refresh)
103-
header.addWidget(btn_refresh)
93+
self.btn_open = QPushButton("Ordner...")
94+
self.btn_open.setObjectName("project_view_open_folder_button")
95+
self.btn_open.setFixedWidth(60)
96+
self.btn_open.setStyleSheet("font-size: 10px;")
97+
self.btn_open.setToolTip("Projektordner auswählen")
98+
self.btn_open.setAccessibleName("Projektordner auswählen")
99+
self.btn_open.setAccessibleDescription(
100+
"Öffnet einen Dialog, um den angezeigten Projektordner zu wechseln."
101+
)
102+
self.btn_open.clicked.connect(self._open_folder_dialog)
103+
header.addWidget(self.btn_open)
104+
105+
self.btn_refresh = QPushButton("Aktualisieren")
106+
self.btn_refresh.setObjectName("project_view_refresh_button")
107+
self.btn_refresh.setFixedWidth(80)
108+
self.btn_refresh.setStyleSheet("font-size: 10px;")
109+
self.btn_refresh.setToolTip("Projektbaum neu laden")
110+
self.btn_refresh.setAccessibleName("Projektbaum neu laden")
111+
self.btn_refresh.setAccessibleDescription(
112+
"Lädt den aktuell angezeigten Projektordner und seine Dateien neu."
113+
)
114+
self.btn_refresh.clicked.connect(self._refresh)
115+
header.addWidget(self.btn_refresh)
104116

105117
layout.addLayout(header)
106118

107119
# Filter
108120
self.filter_input = QLineEdit()
121+
self.filter_input.setObjectName("project_view_filter_input")
109122
self.filter_input.setPlaceholderText("Datei filtern...")
123+
self.filter_input.setToolTip("Dateien und Ordner im Projektbaum filtern")
124+
self.filter_input.setAccessibleName("Projektdateien filtern")
125+
self.filter_input.setAccessibleDescription(
126+
"Filtert Dateien und Ordner im Projektbaum. Ordner bleiben sichtbar,"
127+
" wenn sie passende Kinder enthalten können."
128+
)
110129
self.filter_input.setStyleSheet(
111130
"QLineEdit { background: #2a2a2a; color: #ccc; border: 1px solid #444; "
112131
"padding: 3px; margin: 2px 4px; font-size: 11px; }"
@@ -126,11 +145,17 @@ def _setup_ui(self):
126145

127146
# Tree View
128147
self.tree = QTreeView()
148+
self.tree.setObjectName("project_view_tree")
129149
self.tree.setModel(self.proxy)
130150
self.tree.setHeaderHidden(True)
131151
self.tree.setAnimated(True)
132152
self.tree.setIndentation(16)
133153
self.tree.setFont(QFont("Consolas", 10))
154+
self.tree.setAccessibleName("Projektdateien")
155+
self.tree.setAccessibleDescription(
156+
"Dateibaum des aktuellen Projekts. Das Filterfeld grenzt die angezeigten"
157+
" Dateien und Ordner ein."
158+
)
134159
self.tree.setStyleSheet("""
135160
QTreeView {
136161
background-color: #1e1e1e;

features/terminal.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,21 @@ def _get_shell_command(self) -> tuple:
130130
def _start_shell(self):
131131
"""Startet den Shell-Prozess."""
132132
if self.process:
133-
self.process.kill()
134-
self.process.waitForFinished(1000)
133+
# B-012: Signale trennen bevor der Prozess ersetzt wird,
134+
# sonst feuert _on_finished/_on_stdout des alten Prozesses auf
135+
# den neuen Zustand (analog B-004 in core/output.py).
136+
for sig in (
137+
self.process.readyReadStandardOutput,
138+
self.process.readyReadStandardError,
139+
self.process.finished,
140+
):
141+
try:
142+
sig.disconnect()
143+
except (TypeError, RuntimeError):
144+
pass
145+
if self.process.state() != QProcess.ProcessState.NotRunning:
146+
self.process.kill()
147+
self.process.waitForFinished(1000)
135148

136149
self.process = QProcess(self)
137150
self.process.setWorkingDirectory(self.working_dir)

tests/test_project_view.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,28 @@ def test_project_view_root_follows_current_file_directory(tmp_path):
8181
assert window.project_view._root_path == second_dir
8282
finally:
8383
window.close()
84+
85+
86+
def test_project_view_compact_controls_expose_accessible_context():
87+
"""Die kompakte Sidebar-UI muss Filter- und Baumkontext ohne Dauerlabels
88+
für Screenreader und Tastaturhilfen bereitstellen."""
89+
_ensure_app()
90+
view = ProjectView()
91+
92+
try:
93+
assert view.filter_input.toolTip() == "Dateien und Ordner im Projektbaum filtern"
94+
assert view.filter_input.accessibleName() == "Projektdateien filtern"
95+
assert "Ordner" in view.filter_input.accessibleDescription()
96+
97+
assert view.tree.accessibleName() == "Projektdateien"
98+
assert "Dateibaum" in view.tree.accessibleDescription()
99+
100+
assert view.btn_open.toolTip() == "Projektordner auswählen"
101+
assert view.btn_open.accessibleName() == "Projektordner auswählen"
102+
assert "Dialog" in view.btn_open.accessibleDescription()
103+
104+
assert view.btn_refresh.toolTip() == "Projektbaum neu laden"
105+
assert view.btn_refresh.accessibleName() == "Projektbaum neu laden"
106+
assert "neu" in view.btn_refresh.accessibleDescription().lower()
107+
finally:
108+
view.close()

tests/test_terminal_encoding.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,71 @@ def test_set_working_dir_uses_same_encoding_as_execute_command(self):
6868
widget.close()
6969

7070

71+
class TerminalStartShellTests(unittest.TestCase):
72+
"""Regressionstests für _start_shell() Signal-Verwaltung (B-012)."""
73+
74+
def setUp(self):
75+
_ensure_app()
76+
77+
def _make_widget_no_shell(self) -> TerminalWidget:
78+
"""Erzeugt ein TerminalWidget ohne echten Shell-Start."""
79+
with patch.object(TerminalWidget, "_start_shell", lambda self: None):
80+
return TerminalWidget()
81+
82+
def test_start_shell_disconnects_old_process_signals(self):
83+
"""Regression (B-012): _start_shell() muss alle Signale des alten
84+
Prozesses trennen, bevor er ersetzt wird — sonst feuert _on_finished
85+
des alten Prozesses auf den neuen Zustand und erzeugt z. B. einen
86+
spuriösen 'Shell beendet'-Eintrag im neuen Terminal-Output."""
87+
from PySide6.QtCore import QProcess as RealQProcess
88+
89+
widget = self._make_widget_no_shell()
90+
91+
mock_old = MagicMock()
92+
mock_old.state.return_value = RealQProcess.ProcessState.Running
93+
widget.process = mock_old
94+
95+
with patch("features.terminal.QProcess") as mock_cls:
96+
mock_cls.return_value = MagicMock()
97+
widget._start_shell()
98+
99+
mock_old.readyReadStandardOutput.disconnect.assert_called_once_with()
100+
mock_old.readyReadStandardError.disconnect.assert_called_once_with()
101+
mock_old.finished.disconnect.assert_called_once_with()
102+
mock_old.kill.assert_called_once()
103+
widget.close()
104+
105+
def test_start_shell_no_kill_when_already_stopped(self):
106+
"""Ist der alte Prozess bereits beendet, darf kill() nicht aufgerufen werden."""
107+
from PySide6.QtCore import QProcess as RealQProcess
108+
109+
widget = self._make_widget_no_shell()
110+
111+
mock_old = MagicMock()
112+
mock_old.state.return_value = RealQProcess.ProcessState.NotRunning
113+
widget.process = mock_old
114+
115+
with patch("features.terminal.QProcess") as mock_cls:
116+
# Echten Enum weiterreichen, damit der state()-Vergleich in
117+
# _start_shell korrekt ausgewertet werden kann.
118+
mock_cls.ProcessState = RealQProcess.ProcessState
119+
mock_cls.return_value = MagicMock()
120+
widget._start_shell()
121+
122+
mock_old.kill.assert_not_called()
123+
widget.close()
124+
125+
def test_start_shell_no_disconnect_when_no_prior_process(self):
126+
"""Ohne vorherigen Prozess darf kein disconnect() aufgerufen werden."""
127+
widget = self._make_widget_no_shell()
128+
widget.process = None
129+
130+
with patch("features.terminal.QProcess") as mock_cls:
131+
mock_cls.return_value = MagicMock()
132+
widget._start_shell()
133+
# Kein AttributeError, kein Kill-Aufruf — Test besteht wenn kein Fehler
134+
widget.close()
135+
136+
71137
if __name__ == "__main__":
72138
unittest.main()

0 commit comments

Comments
 (0)