Skip to content

Commit d055c48

Browse files
author
Lukas Geiger
committed
test: add PythonBox regression CI
1 parent 8690878 commit d055c48

5 files changed

Lines changed: 207 additions & 23 deletions

File tree

.github/workflows/tests.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: PythonBox tests
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
regression-tests:
15+
runs-on: windows-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
python-version: ['3.10', '3.11', '3.12']
20+
21+
steps:
22+
- name: Check out repository
23+
uses: actions/checkout@v6
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v6
27+
with:
28+
python-version: ${{ matrix.python-version }}
29+
30+
- name: Install dependencies
31+
run: |
32+
python -m pip install --upgrade pip
33+
python -m pip install -r requirements.txt
34+
35+
- name: Compile sources
36+
env:
37+
PYTHONIOENCODING: utf-8
38+
run: |
39+
python -m py_compile PythonBox_v8.py manage_translations.py translator.py
40+
41+
- name: Run regression tests
42+
env:
43+
PYTHONIOENCODING: utf-8
44+
QT_QPA_PLATFORM: offscreen
45+
run: |
46+
python -m unittest discover -s tests -v

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
88
### Hinzugefügt / Added
99
- App- und Fenstericon über `PythonBox.ico`.
1010
- `build_exe.bat` für lokale PyInstaller-Builds.
11+
- Regressionstests für Qt6-Editor-APIs, F5-Ausführung, externe Python-Kommandos und Offscreen-Fensteraufbau.
12+
- GitHub Actions Workflow für Windows-Regressionstests auf Python 3.10 bis 3.12.
1113

1214
### Geändert / Changed
1315
- README, Security Policy, Contributing Guide und Code of Conduct auf das aktuelle Repository `dev-bricks/pythonbox` und die MIT-Lizenz ausgerichtet.
1416
- `.gitignore` um interne Steuerungsdateien, Secrets, Datenbanken, Logs, Test-Locks und Windows-/Build-Artefakte erweitert.
17+
- Dokumentierte Mindestversion auf Python 3.10+ vereinheitlicht, passend zur Startdatei und Testmatrix.
1518

1619
### Behoben / Fixed
1720
- Veraltete Clone-Pfade und `main.py`-Startbefehle in der Repository-Dokumentation entfernt.
1821
- Öffentliche E-Mail-Adresse aus dem Code of Conduct entfernt.
22+
- Doppelte `run_script`-Definition in `PythonArchitect` beseitigt, damit F5 wieder konsistent über das Debug-Output-Panel läuft.
23+
- Entfernte Qt6-APIs `fontMetrics().width()` und `setTabStopWidth()` durch aktuelle Alternativen ersetzt.
24+
- Externe Python-Skripte starten jetzt mit `sys.executable` statt einem hardcodierten `python`/`python3`.
1925

2026
## [1.0.0] - YYYY-MM-DD
2127

PythonBox_v8.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,38 @@
5050
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
5151
QPushButton, QLabel, QSplitter, QTreeWidget, QTreeWidgetItem,
5252
QMessageBox, QInputDialog, QPlainTextEdit, QFrame, QComboBox,
53-
QDialog, QFormLayout, QDialogButtonBox, QTextEdit, QMenu, QAction,
53+
QDialog, QFormLayout, QDialogButtonBox, QTextEdit, QMenu,
5454
QFileDialog, QProgressBar, QCheckBox, QStyle, QSystemTrayIcon,
55-
QTabWidget, QTabBar, QLineEdit, QShortcut, QToolBar, QStatusBar,
55+
QTabWidget, QTabBar, QLineEdit, QToolBar, QStatusBar,
5656
QDockWidget, QGroupBox, QRadioButton, QCompleter, QSpinBox,
5757
QScrollBar, QSlider, QSizePolicy, QListWidget, QListWidgetItem,
5858
QToolTip
5959
)
6060
from PySide6.QtCore import (
6161
Qt, QSize, QRect, QRegularExpression, QUrl, QMimeData, QProcess,
62-
QTimer, Signal, QSettings, QStringListModel, QPoint
62+
QTimer, Signal, QSettings, QStringListModel, QPoint, QEvent
6363
)
6464
from PySide6.QtGui import (
6565
QFont, QColor, QPainter, QTextFormat, QSyntaxHighlighter,
6666
QTextCharFormat, QPalette, QIcon, QKeySequence, QTextCursor,
67-
QTextDocument, QFontMetrics, QPen, QBrush
67+
QTextDocument, QFontMetrics, QPen, QBrush, QAction, QShortcut
6868
)
6969

70+
71+
def build_external_python_command(script_path: Path) -> List[str]:
72+
"""Build a command that runs a script with the current Python interpreter."""
73+
interpreter = sys.executable or ("python.exe" if sys.platform == "win32" else "python3")
74+
script = str(script_path)
75+
76+
if sys.platform == "win32":
77+
return ['cmd', '/c', 'start', '', 'cmd', '/k', interpreter, script]
78+
79+
terminal = shutil.which("x-terminal-emulator")
80+
if terminal:
81+
return [terminal, '-e', interpreter, script]
82+
83+
return [interpreter, script]
84+
7085
# ============================================================================
7186
# MODERN UI THEME
7287
# ============================================================================
@@ -219,7 +234,7 @@ def __init__(self, parent=None):
219234
font.setStyleHint(QFont.Monospace)
220235
self.setFont(font)
221236
self.setLineWrapMode(QPlainTextEdit.NoWrap)
222-
self.setTabStopWidth(self.fontMetrics().width(' ') * 4)
237+
self.setTabStopDistance(self.fontMetrics().horizontalAdvance(' ') * 4)
223238

224239
# Auto-Completion Setup
225240
self.completer = None
@@ -481,7 +496,7 @@ def lineNumberAreaWidth(self):
481496
while max_val >= 10:
482497
max_val //= 10
483498
digits += 1
484-
width = 20 + self.fontMetrics().width('9') * digits
499+
width = 20 + self.fontMetrics().horizontalAdvance('9') * digits
485500
# Platz für Git-Markierung
486501
width += 4
487502
return width
@@ -1528,7 +1543,7 @@ def setup_ui(self):
15281543

15291544
def eventFilter(self, obj, event):
15301545
"""Behandelt Pfeiltasten für Command-History"""
1531-
if obj == self.input_line and event.type() == event.KeyPress:
1546+
if obj == self.input_line and event.type() == QEvent.Type.KeyPress:
15321547
if event.key() == Qt.Key_Up:
15331548
self.navigate_history(-1)
15341549
return True
@@ -2944,7 +2959,7 @@ def _apply_settings_to_editors(self):
29442959
editor.setFont(font)
29452960

29462961
# Tab-Größe
2947-
editor.setTabStopWidth(editor.fontMetrics().width(' ') * tab_size)
2962+
editor.setTabStopDistance(editor.fontMetrics().horizontalAdvance(' ') * tab_size)
29482963

29492964
# Zeilenumbruch
29502965
if word_wrap:
@@ -3477,18 +3492,22 @@ def fix_encoding(self):
34773492

34783493
# --- BUILD & RUN ---
34793494

3480-
def run_script(self):
3481-
"""Führt das aktuelle Skript im integrierten Output-Panel aus"""
3495+
def run_current_code(self):
3496+
"""Führt den aktuellen Editor-Inhalt über eine temporäre Datei aus."""
34823497
editor = self.tab_editor.current_editor()
34833498
if not editor:
34843499
return
34853500

34863501
code = editor.toPlainText()
34873502
if not code.strip():
34883503
return
3489-
3504+
3505+
tmp_path = Path.home() / ".python_baukasten" / "temp_run.py"
3506+
tmp_path.parent.mkdir(parents=True, exist_ok=True)
3507+
tmp_path.write_text(code, encoding='utf-8')
3508+
34903509
self.output_dock.show()
3491-
self.output_panel.run_code(code)
3510+
self.debug_output.run_normal(str(tmp_path))
34923511

34933512
def run_script_external(self):
34943513
"""Führt das aktuelle Skript in externem Terminal aus"""
@@ -3503,11 +3522,8 @@ def run_script_external(self):
35033522
tmp_path = Path.home() / ".python_baukasten" / "temp_run.py"
35043523
tmp_path.parent.mkdir(parents=True, exist_ok=True)
35053524
tmp_path.write_text(code, encoding='utf-8')
3506-
3507-
if sys.platform == "win32":
3508-
subprocess.Popen(['cmd', '/c', 'start', '', 'cmd', '/k', 'python', str(tmp_path)])
3509-
else:
3510-
subprocess.Popen(['x-terminal-emulator', '-e', 'python3', str(tmp_path)])
3525+
3526+
subprocess.Popen(build_external_python_command(tmp_path))
35113527

35123528
def build_exe(self):
35133529
if not shutil.which("pyinstaller"):
@@ -3561,10 +3577,7 @@ def scan_external_tools(self):
35613577
self.ext_tools_menu.addAction(f"🛠️ {name}", lambda t=tool: self.run_external_tool(t))
35623578

35633579
def run_external_tool(self, path):
3564-
if sys.platform == "win32":
3565-
subprocess.Popen(['cmd', '/c', 'start', '', 'cmd', '/k', 'python', str(path)])
3566-
else:
3567-
subprocess.Popen(['python3', str(path)])
3580+
subprocess.Popen(build_external_python_command(Path(path)))
35683581

35693582

35703583
# ============================================================================

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ PythonBox ist eine leichtgewichtige Python-IDE mit Dark Theme, integriertem Debu
44

55
PythonBox is a lightweight Python IDE with a dark theme, integrated debugging, code folding, and optional VS Code/PyCharm integration.
66

7-
![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)
7+
![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)
88
![PySide6](https://img.shields.io/badge/PySide6-6.5+-green.svg)
99
![License](https://img.shields.io/badge/License-MIT-yellow.svg)
1010

@@ -24,6 +24,7 @@ PythonBox is a lightweight Python IDE with a dark theme, integrated debugging, c
2424
- Debug-Toolbar mit Step In, Step Over und Step Out
2525
- Linter-Integration für Pylint und Flake8
2626
- Git-Status, Diff und Modified-Markierung
27+
- Qt6-kompatible Editor-Metriken und F5-Ausführung über das Debug-Output-Panel
2728

2829
### Windows-Paketierung
2930
- `PythonBox.ico` wird als App- und Fenstericon verwendet, wenn die Datei vorhanden ist.
@@ -37,7 +38,7 @@ PythonBox is a lightweight Python IDE with a dark theme, integrated debugging, c
3738
## Installation
3839

3940
### Voraussetzungen / Requirements
40-
- Python 3.8+
41+
- Python 3.10+
4142
- PySide6 6.5+
4243
- Optional: Git, Pylint, Flake8, VS Code, PyCharm
4344

@@ -61,6 +62,16 @@ build_exe.bat
6162

6263
Das Build-Ergebnis liegt anschließend in `dist/`. Build-Artefakte und lokale Releases sind bewusst nicht Teil des Git-Repositories.
6364

65+
## Tests
66+
67+
Die Regressionstests prüfen die Qt6-API-Kompatibilität, die F5-Ausführung über `debug_output.run_normal`, die externe Terminal-Ausführung mit dem aktuellen Python-Interpreter und einen Offscreen-Smoke-Test für das Hauptfenster.
68+
69+
```bash
70+
python -m unittest discover -s tests -v
71+
```
72+
73+
GitHub Actions führt diese Prüfungen unter Windows für Python 3.10 bis 3.12 aus.
74+
6475
## Tastenkürzel / Keyboard Shortcuts
6576

6677
| Shortcut | Funktion / Action |
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import ast
2+
import importlib.util
3+
import os
4+
import unittest
5+
from pathlib import Path
6+
from unittest import mock
7+
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
SOURCE = ROOT / "PythonBox_v8.py"
11+
12+
13+
def load_pythonbox_module():
14+
spec = importlib.util.spec_from_file_location("pythonbox_v8", SOURCE)
15+
module = importlib.util.module_from_spec(spec)
16+
spec.loader.exec_module(module)
17+
return module
18+
19+
20+
class PythonArchitectRegressionTests(unittest.TestCase):
21+
def _pythonarchitect_class(self):
22+
tree = ast.parse(SOURCE.read_text(encoding="utf-8"))
23+
for node in tree.body:
24+
if isinstance(node, ast.ClassDef) and node.name == "PythonArchitect":
25+
return node
26+
self.fail("PythonArchitect class not found")
27+
28+
def test_pythonarchitect_has_no_shadowed_methods(self):
29+
methods = [
30+
node.name
31+
for node in self._pythonarchitect_class().body
32+
if isinstance(node, ast.FunctionDef)
33+
]
34+
duplicates = sorted({name for name in methods if methods.count(name) > 1})
35+
36+
self.assertEqual([], duplicates)
37+
38+
def test_f5_run_script_uses_debug_output_panel(self):
39+
run_script = None
40+
for node in self._pythonarchitect_class().body:
41+
if isinstance(node, ast.FunctionDef) and node.name == "run_script":
42+
run_script = node
43+
break
44+
self.assertIsNotNone(run_script)
45+
46+
call_targets = []
47+
for node in ast.walk(run_script):
48+
if (
49+
isinstance(node, ast.Call)
50+
and isinstance(node.func, ast.Attribute)
51+
and isinstance(node.func.value, ast.Attribute)
52+
and isinstance(node.func.value.value, ast.Name)
53+
and node.func.value.value.id == "self"
54+
):
55+
call_targets.append((node.func.value.attr, node.func.attr))
56+
57+
self.assertIn(("debug_output", "run_normal"), call_targets)
58+
self.assertNotIn(("output_panel", "run_code"), call_targets)
59+
60+
def test_qt6_removed_editor_apis_are_not_used(self):
61+
source = SOURCE.read_text(encoding="utf-8")
62+
63+
self.assertNotIn(".fontMetrics().width(", source)
64+
self.assertNotIn(".setTabStopWidth(", source)
65+
66+
def test_main_window_can_be_constructed_offscreen(self):
67+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
68+
module = load_pythonbox_module()
69+
app = module.QApplication.instance() or module.QApplication([])
70+
window = module.PythonArchitect()
71+
72+
try:
73+
self.assertIsNotNone(window.tab_editor.current_editor())
74+
finally:
75+
window.close()
76+
window.deleteLater()
77+
app.processEvents()
78+
79+
80+
class ExternalPythonCommandTests(unittest.TestCase):
81+
def test_windows_external_commands_use_current_interpreter(self):
82+
module = load_pythonbox_module()
83+
84+
with (
85+
mock.patch.object(module.sys, "platform", "win32"),
86+
mock.patch.object(module.sys, "executable", r"C:\Current Python\python.exe"),
87+
):
88+
cmd = module.build_external_python_command(Path("tool.py"))
89+
90+
self.assertEqual(['cmd', '/c', 'start', '', 'cmd', '/k'], cmd[:6])
91+
self.assertEqual(r"C:\Current Python\python.exe", cmd[6])
92+
self.assertEqual("tool.py", cmd[7])
93+
94+
def test_non_windows_external_commands_use_current_interpreter(self):
95+
module = load_pythonbox_module()
96+
97+
with (
98+
mock.patch.object(module.sys, "platform", "linux"),
99+
mock.patch.object(module.sys, "executable", "/opt/current-python/bin/python"),
100+
mock.patch.object(module.shutil, "which", return_value=None),
101+
):
102+
cmd = module.build_external_python_command(Path("tool.py"))
103+
104+
self.assertEqual(["/opt/current-python/bin/python", "tool.py"], cmd)
105+
106+
107+
if __name__ == "__main__":
108+
unittest.main()

0 commit comments

Comments
 (0)