diff --git a/CHANGELOG.md b/CHANGELOG.md index b96c3ef..f617a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,17 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Das Schließen fordert den Abbruch an und hält den Dialog bis zum bestätigten Thread-Ende offen. Die Oberfläche bleibt dabei bedienbar; die Freigabe eines noch laufenden nativen Workers wird verhindert. - Automatische zyklische Python-Bereinigung wird während des GUI-Betriebs im Anwendungsthread ausgeführt, damit Hintergrundthreads keine Qt-Objekte zerstören. +### Laufwerksbelegung / Drive capacity (2026-09-30) + +- Die Ordner-Seitenleiste zeigt für jedes Laufwerk einen Belegungsbalken, den belegten Anteil in Prozent und freien, belegten sowie gesamten Speicher in binären Größen (GiB/TiB). +- Kapazitäten werden unabhängig im Hintergrund abgefragt, beim Öffnen und über „Laufwerksbelegung aktualisieren“ erneuert. Nicht verfügbare Laufwerke erhalten einen erklärenden Text statt falscher Nullwerte. +- Text und Balken sind auch in schmalen Seitenleisten lesbar; Pfadnavigation bleibt erhalten. Alle neuen Texte sind in DE/EN/ES/ZH/JA/RU vorhanden. +- Hintergrundabfragen liefern reine Python-Daten; ein Timer des Panels übernimmt fertige Ergebnisse im GUI-Thread. Dateisystem- und Sortiermodell gehören jetzt ihrem Dateibrowser und werden mit ihm gelöscht. +- Die Löschprüfungen bereinigen ausschließlich ihre eigenen Qt-Objekte; die Navigation wartet mit einem begrenzten Qt-Ereignislauf auf tatsächlich geladene Dateizeilen. +- Zyklische Python-Objekte werden während des GUI-Betriebs im Anwendungsthread bereinigt. Hintergrundabfragen werden vor dem Beenden abgewartet; Prüfsummen-Threads lösen keine automatische Löschung von Qt-GUI-Objekten mehr aus. +- Laufwerksabfragen laufen in eigenen Hilfsprozessen mit zehn Sekunden Zeitlimit. Hängende Datenträger werden als nicht verfügbar angezeigt; beim Beenden werden laufende Hilfsprozesse beendet und abgeholt. +- Source feature only: no EXE/MSIX release or Store submission performed. + ### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A) - **CI/CD Lifecycle Workflows & Label-Governance**: - `.github/workflows/auto-assign.yml`: Automatisches Zuweisen von Pull Requests an Maintainer via `actions/github-script@v7`, `timeout-minutes: 5`, least-privilege permissions (`issues: write`, `pull-requests: write`) und Concurrency `cancel-in-progress: true`. diff --git a/README.md b/README.md index 339f5bb..5885ab7 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ ## 1. Features & Core Capabilities +The source version includes per-drive capacity bars in the folder sidebar, with used percentage and free/used/total space in GiB/TiB. Capacity requests run in the background; use **Refresh drive usage** to update them. Unavailable devices show an explanatory message. This addition is not yet part of a newly packaged release. + Standard operating system file managers are built for casual browsing and lack the heavy-lifting tools developers, researchers, and power users require daily. ExplorerPro addresses this gap by packaging pro-grade productivity utilities into a cohesive, responsive desktop interface with zero telemetry and 100% Local-First data isolation: - **Unified Multi-Tab Experience:** Browse multiple directories concurrently with tab pinning, breadcrumb navigation, drag-and-drop, and intelligent context menus. diff --git a/README_de.md b/README_de.md index dc9d3c6..ce9d3e0 100644 --- a/README_de.md +++ b/README_de.md @@ -2,6 +2,8 @@ # ExplorerPro Suite +Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je Laufwerk: Balken, Prozentanteil und freier/belegter/gesamter Speicher in GiB/TiB. Die Abfrage läuft im Hintergrund; **Laufwerksbelegung aktualisieren** erneuert die Werte. Nicht verfügbare Geräte werden verständlich gekennzeichnet. Diese Ergänzung ist noch nicht Bestandteil eines neu gebauten Releases. + [English](README.md) | **[Deutsch](README_de.md)** | [Maschinenlesbarer Kontext (llms.txt)](llms.txt) [![CI](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml/badge.svg)](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml) diff --git a/locales/translations.json b/locales/translations.json index ff39cfe..8cb06fb 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -2846,5 +2846,53 @@ "zh": "打开选定的文件", "ja": "選択したファイルを開く", "ru": "Открывает выбранный файл" + }, + "Speicherbelegung": { + "de": "Speicherbelegung", + "en": "Storage usage", + "es": "Uso del almacenamiento", + "zh": "存储使用情况", + "ja": "ストレージ使用量", + "ru": "Использование хранилища" + }, + "Speicherbelegung wird ermittelt …": { + "de": "Speicherbelegung wird ermittelt …", + "en": "Checking storage usage …", + "es": "Calculando el uso del almacenamiento …", + "zh": "正在查询存储使用情况 …", + "ja": "ストレージ使用量を確認中 …", + "ru": "Проверка использования хранилища …" + }, + "Speicherbelegung nicht verfügbar": { + "de": "Speicherbelegung nicht verfügbar", + "en": "Storage usage unavailable", + "es": "Uso del almacenamiento no disponible", + "zh": "存储使用情况不可用", + "ja": "ストレージ使用量を取得できません", + "ru": "Данные об использовании хранилища недоступны" + }, + "{free} frei · {used} belegt · {total} gesamt": { + "de": "{free} frei · {used} belegt · {total} gesamt", + "en": "{free} free · {used} used · {total} total", + "es": "{free} libre · {used} usado · {total} total", + "zh": "{free} 可用 · {used} 已用 · {total} 总计", + "ja": "{free} 空き · {used} 使用中 · {total} 合計", + "ru": "{free} свободно · {used} занято · {total} всего" + }, + "{percent}% belegt": { + "de": "{percent}% belegt", + "en": "{percent}% used", + "es": "{percent}% usado", + "zh": "已用 {percent}%", + "ja": "{percent}% 使用中", + "ru": "{percent}% занято" + }, + "Laufwerksbelegung aktualisieren": { + "de": "Laufwerksbelegung aktualisieren", + "en": "Refresh drive usage", + "es": "Actualizar el uso de las unidades", + "zh": "刷新驱动器使用情况", + "ja": "ドライブ使用量を更新", + "ru": "Обновить использование дисков" } -} \ No newline at end of file +} diff --git a/src/core/drive_usage.py b/src/core/drive_usage.py new file mode 100644 index 0000000..69d8dd0 --- /dev/null +++ b/src/core/drive_usage.py @@ -0,0 +1,138 @@ +"""Read capacity without scanning files or changing the filesystem.""" + +from dataclasses import dataclass +import os +import shutil +import json +from pathlib import Path +import subprocess +import sys +import threading +import tempfile + +QUERY_TIMEOUT_SECONDS = 10 +_query_lock = threading.Lock() +_query_processes = set() +_queries_stopped = False + + +def start_drive_queries(): + global _queries_stopped + with _query_lock: + _queries_stopped = False + + +def stop_drive_queries(): + """Stop only our capacity helpers, including reads stuck inside the OS.""" + global _queries_stopped + with _query_lock: + _queries_stopped = True + for process in tuple(_query_processes): + if process.poll() is None: + process.kill() + + +def _query_command(path, result_path): + if getattr(sys, 'frozen', False): + return [sys.executable, '--drive-capacity-query', path, result_path] + return [sys.executable, str(Path(__file__).resolve()), path, result_path] + + +def read_drive_usage_bounded(path): + """Isolate disk_usage so stalled devices cannot retain pool slots forever.""" + with tempfile.TemporaryDirectory(prefix='explorerpro-capacity-') as directory: + return _read_drive_usage_process(path, Path(directory) / 'result.json') + + +def _read_drive_usage_process(path, result_path): + with _query_lock: + if _queries_stopped: + raise OSError('Capacity queries stopped') + process = subprocess.Popen( + _query_command(path, str(result_path)), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0, + ) + _query_processes.add(process) + try: + try: + process.wait(timeout=QUERY_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise OSError('Capacity query timed out') from None + if process.returncode: + raise OSError('Capacity query unavailable') + try: + values = json.loads(result_path.read_text(encoding='utf-8')) + return DriveUsage(**values) + except (OSError, ValueError, TypeError): + raise OSError('Invalid capacity query response') from None + finally: + with _query_lock: + _query_processes.discard(process) + + +def capacity_query_main(path, result_path): + """Read-only measurement; write its result to the parent's private temp file.""" + try: + usage = read_drive_usage(path) + except (OSError, ValueError): + return 1 + # Windowed frozen executables may have no stdout, even with redirected pipes. + with open(result_path, 'x', encoding='utf-8') as result: + json.dump({'total': usage.total, 'used': usage.used, 'free': usage.free}, result) + return 0 + + +@dataclass(frozen=True) +class DriveUsage: + total: int + used: int + free: int + + def __post_init__(self): + if self.total <= 0 or not 0 <= self.used <= self.total or not 0 <= self.free <= self.total: + raise ValueError("Invalid drive capacity") + + @property + def used_percent(self) -> float: + return self.used / self.total * 100 + + +def read_drive_usage(path: str) -> DriveUsage: + if os.name == "nt": + # Empty removable drives must return an error rather than opening an + # operating-system critical-error dialog in this background thread. + import ctypes + from ctypes import wintypes + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + set_mode = kernel.SetThreadErrorMode + set_mode.argtypes = (wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)) + set_mode.restype = wintypes.BOOL + get_mode = kernel.GetThreadErrorMode + get_mode.argtypes = () + get_mode.restype = wintypes.DWORD + previous = wintypes.DWORD() + if not set_mode(get_mode() | 0x0001, ctypes.byref(previous)): # SEM_FAILCRITICALERRORS + raise ctypes.WinError(ctypes.get_last_error()) + try: + usage = shutil.disk_usage(path) + finally: + set_mode(previous.value, None) + else: + usage = shutil.disk_usage(path) + return DriveUsage(usage.total, usage.used, usage.free) + + +def format_capacity(size: int) -> str: + """Use explicit binary units, retaining useful precision for small drives.""" + value = float(size) + for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"): + if value < 1024 or unit == "PiB": + return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}" + value /= 1024 + + +if __name__ == '__main__': + sys.exit(capacity_query_main(*sys.argv[1:]) if len(sys.argv) == 3 else 2) diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index 6a79c87..453690d 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -122,7 +122,7 @@ def _setup_ui(self): layout.setContentsMargins(0, 0, 0, 0) # Datei-System-Model - self.model = QFileSystemModel() + self.model = QFileSystemModel(self) self.model.setFilter( QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot @@ -130,7 +130,7 @@ def _setup_ui(self): self.model.directoryLoaded.connect(self._on_directory_loaded) # Sortier-Proxy - self.proxy = QSortFilterProxyModel() + self.proxy = QSortFilterProxyModel(self) self.proxy.setSourceModel(self.model) self.proxy.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) diff --git a/src/gui/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py new file mode 100644 index 0000000..4fb8242 --- /dev/null +++ b/src/gui/sidebar/drive_capacity.py @@ -0,0 +1,79 @@ +"""Capacity widgets and independent background requests for drive rows.""" + +from concurrent.futures import ThreadPoolExecutor + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget + +from core.drive_usage import format_capacity, start_drive_queries, stop_drive_queries +from translator import t + +_executor = None + + +def capacity_executor(): + """Workers only return Python data; no Qt objects cross thread boundaries. + + OS reads run in bounded helpers, so failed drives release worker slots. + """ + global _executor + if _executor is None: + start_drive_queries() + _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="drive-capacity") + return _executor + + +def shutdown_capacity_executor(): + """Stop capacity helpers, then drain Python workers before Qt teardown.""" + global _executor + if _executor is not None: + stop_drive_queries() + _executor.shutdown(wait=True, cancel_futures=True) + _executor = None + + +class DriveCapacityWidget(QWidget): + def __init__(self, path, parent=None): + super().__init__(parent) + self.path = path + # Tree selection and double-click navigation still receive mouse events. + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 3, 2, 3) + layout.setSpacing(2) + self.title = QLabel(path) + self.details = QLabel() + self.details.setWordWrap(True) + self.bar = QProgressBar() + self.bar.setRange(0, 1000) + self.bar.setFixedHeight(18) + self.bar.setAccessibleName(t("Speicherbelegung")) + layout.addWidget(self.title) + layout.addWidget(self.details) + layout.addWidget(self.bar) + self.set_loading() + + def set_loading(self): + self.details.setText(t("Speicherbelegung wird ermittelt …")) + self.bar.hide() + self.setAccessibleName(self.path) + self.setAccessibleDescription(self.details.text()) + + def set_usage(self, usage): + if usage is None: + self.details.setText(t("Speicherbelegung nicht verfügbar")) + self.bar.hide() + self.bar.setValue(0) + else: + self.details.setText(t("{free} frei · {used} belegt · {total} gesamt").format( + free=format_capacity(usage.free), used=format_capacity(usage.used), + total=format_capacity(usage.total))) + self.bar.setValue(round(usage.used_percent * 10)) + self.bar.setFormat(t("{percent}% belegt").format(percent=f"{usage.used_percent:.1f}")) + self.bar.show() + description = self.details.text() + if usage is not None: + description += " · " + self.bar.format() + self.setToolTip(self.path + "\n" + description) + self.setAccessibleDescription(description) + self.bar.setAccessibleDescription(description) diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index daf285d..a914721 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -5,12 +5,14 @@ Phase 5: Vollständige Integration """ +from concurrent.futures import CancelledError + from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget, QTreeWidget, QTreeWidgetItem, QListWidget, QListWidgetItem, QPushButton, QLabel, QFrame, QToolButton, QButtonGroup ) -from PySide6.QtCore import Qt, Signal, QDir, QStandardPaths +from PySide6.QtCore import Qt, Signal, Slot, QDir, QStandardPaths, QSize, QTimer import os # Module importieren - absolute Imports @@ -19,6 +21,9 @@ from modules.launcher import AppsPanel from modules.prompts import PromptsPanel from modules.sync import SyncPanel +from core.drive_usage import read_drive_usage_bounded as read_drive_usage +from gui.sidebar.drive_capacity import DriveCapacityWidget, capacity_executor +from translator import t class TreePanel(QWidget): @@ -28,8 +33,17 @@ class TreePanel(QWidget): def __init__(self, parent=None): super().__init__(parent) + self._drive_rows = {} + self._usage_requests = {} + self._resize_timer = QTimer(self) + self._resize_timer.setSingleShot(True) + self._resize_timer.timeout.connect(self._resize_drive_rows) + self._usage_timer = QTimer(self) + self._usage_timer.setInterval(50) + self._usage_timer.timeout.connect(self._collect_drive_usage) self._setup_ui() self._populate() + self.refresh_drive_usage() def _setup_ui(self): layout = QVBoxLayout(self) @@ -48,6 +62,9 @@ def _setup_ui(self): self.tree.itemExpanded.connect(self._on_item_expanded) layout.addWidget(self.tree) + self.refresh_drives_button = QPushButton(t("Laufwerksbelegung aktualisieren")) + self.refresh_drives_button.clicked.connect(self.refresh_drive_usage) + layout.addWidget(self.refresh_drives_button) def _populate(self): """Füllt den Baum mit Laufwerken und Schnellzugriff""" @@ -77,18 +94,77 @@ def _populate(self): # Laufwerke drives_item = QTreeWidgetItem(["💾 Laufwerke"]) drives_item.setFlags(drives_item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self.tree.addTopLevelItem(drives_item) for drive in QDir.drives(): path = drive.absolutePath() - item = QTreeWidgetItem([path]) + # The embedded widget paints the title; duplicate tree text would + # otherwise show through between its labels and progress bar. + item = QTreeWidgetItem([""]) item.setData(0, Qt.ItemDataRole.UserRole, path) + item.setData(0, Qt.ItemDataRole.AccessibleTextRole, path) item.setIcon(0, get_file_icon(path)) item.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) drives_item.addChild(item) + capacity = DriveCapacityWidget(path) + self.tree.setItemWidget(item, 0, capacity) + item.setSizeHint(0, capacity.sizeHint()) + self._drive_rows[path] = (item, capacity) - self.tree.addTopLevelItem(drives_item) drives_item.setExpanded(True) + def refresh_drive_usage(self): + """One request per drive; repeat clicks cannot queue duplicate queries.""" + for path, (item, capacity) in self._drive_rows.items(): + if path in self._usage_requests: + continue + capacity.set_loading() + item.setSizeHint(0, capacity.sizeHint()) + self._usage_requests[path] = capacity_executor().submit(read_drive_usage, path) + if self._usage_requests: + self._usage_timer.start() + + @Slot() + def _collect_drive_usage(self): + for path, future in list(self._usage_requests.items()): + if not future.done(): + continue + try: + usage = future.result() + except (CancelledError, OSError, ValueError): + usage = None + self._on_drive_usage(path, usage) + if not self._usage_requests: + self._usage_timer.stop() + + @Slot(str, object) + def _on_drive_usage(self, path, usage): + self._usage_requests.pop(path, None) + row = self._drive_rows.get(path) + if row is not None: + item, capacity = row + capacity.set_usage(usage) + item.setToolTip(0, capacity.toolTip()) + item.setData(0, Qt.ItemDataRole.AccessibleDescriptionRole, capacity.accessibleDescription()) + item.setSizeHint(0, capacity.sizeHint()) + self._resize_drive_rows() + + def showEvent(self, event): + super().showEvent(event) + self.refresh_drive_usage() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._resize_timer.start(0) + + @Slot() + def _resize_drive_rows(self): + """Wrapped labels need taller rows when the sidebar becomes narrow.""" + for item, capacity in self._drive_rows.values(): + height = capacity.layout().totalHeightForWidth(capacity.width()) + item.setSizeHint(0, QSize(0, max(height, capacity.sizeHint().height()))) + self.tree.doItemsLayout() + def _on_item_clicked(self, item: QTreeWidgetItem, column: int): path = item.data(0, Qt.ItemDataRole.UserRole) if path: diff --git a/src/main.py b/src/main.py index 931193e..660f258 100644 --- a/src/main.py +++ b/src/main.py @@ -21,6 +21,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# Frozen helpers must finish before importing Qt or constructing the application. +if __name__ == '__main__' and len(sys.argv) == 4 and sys.argv[1] == '--drive-capacity-query': + from core.drive_usage import capacity_query_main + sys.exit(capacity_query_main(sys.argv[2], sys.argv[3])) + from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt, QTranslator, QLibraryInfo from PySide6.QtGui import QIcon @@ -28,6 +33,7 @@ from app import ExplorerProApp from version import __version__ from core.gui_gc import install_gui_gc +from gui.sidebar.drive_capacity import shutdown_capacity_executor def load_app_icon() -> QIcon: @@ -99,7 +105,10 @@ def main(): explorer.show() exit_code = app.exec() finally: - collector.close() + try: + shutdown_capacity_executor() + finally: + collector.close() sys.exit(exit_code) diff --git a/tests/conftest.py b/tests/conftest.py index 293ae29..9f2243b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import QApplication from core.gui_gc import install_gui_gc +from gui.sidebar.drive_capacity import shutdown_capacity_executor _application = QApplication.instance() or QApplication([]) _collector = install_gui_gc(_application) @@ -22,4 +23,7 @@ def collect_gui_cycles(): @pytest.fixture(scope='session', autouse=True) def gui_runtime(): yield - _collector.close() + try: + shutdown_capacity_executor() + finally: + _collector.close() diff --git a/tests/test_drive_capacity.py b/tests/test_drive_capacity.py new file mode 100644 index 0000000..ac9dbc7 --- /dev/null +++ b/tests/test_drive_capacity.py @@ -0,0 +1,249 @@ +import os +import threading +import time +from concurrent.futures import Future +from types import SimpleNamespace + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +import pytest +from PySide6.QtCore import QDir, Qt +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest + +from core.drive_usage import DriveUsage, format_capacity, read_drive_usage +from gui.sidebar.drive_capacity import DriveCapacityWidget +from gui.sidebar.sidebar_main import TreePanel +from translator import get_translator + +app = QApplication.instance() or QApplication([]) + + +def wait_until(predicate): + deadline = time.monotonic() + 4 + while not predicate(): + assert time.monotonic() < deadline, 'Background result did not arrive' + app.processEvents() + QTest.qWait(5) + + +@pytest.fixture(autouse=True) +def german(): + tr = get_translator() + old = tr.get_language() + tr.set_language('de') + yield + tr.set_language(old) + + +@pytest.mark.parametrize('used,free,percent', [(0, 1000, 0), (1, 999, .1), (500, 500, 50), (1000, 0, 100)]) +def test_usage_values_and_bar(used, free, percent): + widget = DriveCapacityWidget('X:/') + widget.set_usage(DriveUsage(1000, used, free)) + assert widget.bar.value() == round(percent * 10) + assert widget.bar.format() == f'{percent:.1f}% belegt' + assert f'{free} B frei' in widget.details.text() + assert f'{used} B belegt' in widget.details.text() + assert '1000 B gesamt' in widget.details.text() + assert not widget.bar.isHidden() + assert widget.bar.format() in widget.accessibleDescription() + + +@pytest.mark.parametrize('values', [(0, 0, 0), (100, -1, 20), (100, 101, 0), (100, 30, 101)]) +def test_invalid_capacity_not_shown_as_real_data(values): + with pytest.raises(ValueError): + DriveUsage(*values) + + +def test_unavailable_discards_previous_measurement(): + widget = DriveCapacityWidget('X:/') + widget.set_usage(DriveUsage(100, 80, 20)) + widget.set_usage(None) + assert widget.details.text() == 'Speicherbelegung nicht verfügbar' + assert widget.bar.isHidden() + assert '80' not in widget.accessibleDescription() + + +def test_real_operating_system_capacity(tmp_path): + import shutil + expected = shutil.disk_usage(tmp_path) + actual = read_drive_usage(str(tmp_path)) + assert actual.total == expected.total + # Free space can change between OS calls; compare accounting and valid range. + assert actual.used + actual.free == actual.total if os.name == 'nt' else actual.used + actual.free <= actual.total + assert 0 <= actual.used_percent <= 100 + + +def test_binary_units(): + assert format_capacity(1024) == '1.0 KiB' + assert format_capacity(1024 ** 3) == '1.0 GiB' + + +def make_panel(monkeypatch, read): + monkeypatch.setattr(QDir, 'drives', lambda: [SimpleNamespace(absolutePath=lambda: 'X:/')]) + monkeypatch.setattr('gui.sidebar.sidebar_main.read_drive_usage', read) + return TreePanel() + + +def test_cancelled_drive_query_is_reported_as_unavailable(monkeypatch): + panel = make_panel(monkeypatch, lambda _: DriveUsage(100, 20, 80)) + try: + wait_until(lambda: not panel._usage_requests) + cancelled = Future() + cancelled.cancel() + panel._usage_requests['X:/'] = cancelled + + panel._collect_drive_usage() + + assert not panel._usage_requests + assert panel._drive_rows['X:/'][1].details.text() == 'Speicherbelegung nicht verfügbar' + finally: + panel.close() + + +def test_slow_query_keeps_gui_responsive_and_deduplicates(monkeypatch): + entered, release = threading.Event(), threading.Event() + threads = [] + main_thread = threading.get_ident() + + def read(path): + threads.append(threading.get_ident()) + entered.set() + assert release.wait(4) + return DriveUsage(1000, 250, 750) + + panel = make_panel(monkeypatch, read) + try: + wait_until(entered.is_set) + for _ in range(5): + panel.refresh_drive_usage() + app.processEvents() + assert len(threads) == 1 + assert threads[0] != main_thread + assert panel._drive_rows['X:/'][1].bar.isHidden() + release.set() + wait_until(lambda: not panel._usage_requests) + assert panel._drive_rows['X:/'][1].bar.value() == 250 + panel.refresh_drives_button.click() + wait_until(lambda: not panel._usage_requests) + assert len(threads) == 2 + finally: + release.set() + for future in panel._usage_requests.values(): + future.result(timeout=4) + panel.close() + + +def test_failed_query_and_recovery(monkeypatch): + def unavailable(path): + raise OSError('Device not ready') + + panel = make_panel(monkeypatch, unavailable) + wait_until(lambda: not panel._usage_requests) + item, widget = panel._drive_rows['X:/'] + assert item.text(0) == '' # embedded title must not overlap tree text + assert item.data(0, Qt.ItemDataRole.AccessibleTextRole) == 'X:/' + assert 'nicht verfügbar' in widget.details.text() + monkeypatch.setattr('gui.sidebar.sidebar_main.read_drive_usage', lambda path: DriveUsage(100, 100, 0)) + panel.refresh_drive_usage() + wait_until(lambda: not panel._usage_requests) + assert widget.bar.value() == 1000 + assert item.data(0, Qt.ItemDataRole.UserRole) == 'X:/' + selected = [] + panel.folder_selected.connect(selected.append) + panel._on_item_clicked(item, 0) + assert selected == ['X:/'] + panel.close() + + +def test_panel_destroyed_while_query_is_running(monkeypatch): + from PySide6.QtCore import QCoreApplication, QEvent, QObject + from shiboken6 import isValid + entered, release = threading.Event(), threading.Event() + + def read(path): + entered.set() + assert release.wait(4) + return DriveUsage(100, 20, 80) + + panel = make_panel(monkeypatch, read) + futures = list(panel._usage_requests.values()) + unrelated = None + try: + wait_until(entered.is_set) + unrelated = QObject() + unrelated.deleteLater() + panel.deleteLater() + QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete) + assert isValid(unrelated), 'Panel cleanup must not delete unrelated Qt objects' + finally: + release.set() + for future in futures: + future.result(timeout=4) + if unrelated is not None and isValid(unrelated): + QCoreApplication.sendPostedEvents(unrelated, QEvent.Type.DeferredDelete) + app.processEvents() + + +def test_capacity_text_placeholders_in_every_language(): + tr = get_translator() + for lang in tr.get_supported_languages(): + tr.set_language(lang) + widget = DriveCapacityWidget('X:/') + widget.set_usage(DriveUsage(1000, 500, 500)) + assert '500 B' in widget.details.text() and '1000 B' in widget.details.text() + assert '{' not in widget.details.text() + assert '50.0' in widget.bar.format() + + +def test_narrow_sidebar_preserves_wrapped_capacity_text(monkeypatch): + panel = make_panel(monkeypatch, lambda path: DriveUsage(1024 ** 4, 1024 ** 3, 1024 ** 4 - 1024 ** 3)) + panel.resize(200, 600) + panel.show() + wait_until(lambda: not panel._usage_requests) + app.processEvents() + item, widget = panel._drive_rows['X:/'] + required = widget.layout().totalHeightForWidth(widget.width()) + assert item.sizeHint(0).height() >= required + assert widget.height() >= required + panel.close() + + +def test_pending_capacity_job_finishes_before_interpreter_exit(): + import subprocess + import sys + from pathlib import Path + root = Path(__file__).resolve().parents[1] + script = ''' +import sys, time +sys.path.insert(0, 'src') +from PySide6.QtWidgets import QApplication +from core.drive_usage import DriveUsage +from gui.sidebar import drive_capacity +app = QApplication([]) +def slow_read(path): + time.sleep(.2) + print('capacity-job-finished', flush=True) + return DriveUsage(100, 20, 80) +drive_capacity.capacity_executor().submit(slow_read, 'X:/') +''' + result = subprocess.run([sys.executable, '-c', script], cwd=root, capture_output=True, text=True, timeout=20) + assert result.returncode == 0, result.stderr + assert 'capacity-job-finished' in result.stdout + + +def test_destroyed_panel_cancels_queued_layout_refresh(monkeypatch, capsys): + from PySide6.QtCore import QCoreApplication, QEvent + from shiboken6 import isValid + panel = make_panel(monkeypatch, lambda path: DriveUsage(100, 20, 80)) + wait_until(lambda: not panel._usage_requests) + panel.resize(200, 600) + panel.show() + timer = panel._resize_timer + timer.start(0) + assert timer.isActive() + panel.deleteLater() + QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete) + assert not isValid(timer) + app.processEvents() + assert 'RuntimeError' not in capsys.readouterr().err diff --git a/tests/test_drive_query_process.py b/tests/test_drive_query_process.py new file mode 100644 index 0000000..86b8d2a --- /dev/null +++ b/tests/test_drive_query_process.py @@ -0,0 +1,100 @@ +"""Exercise real helper termination rather than substituting completed futures.""" + +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from core import drive_usage +from gui.sidebar import drive_capacity + + +@pytest.fixture(autouse=True) +def query_lifecycle(): + drive_usage.start_drive_queries() + yield + drive_capacity.shutdown_capacity_executor() + drive_usage.stop_drive_queries() + + +def sleeper_command(path, result_path): + return [sys.executable, '-c', 'import time; time.sleep(60)'] + + +def test_real_helper_matches_os_capacity(tmp_path): + expected = drive_usage.read_drive_usage(str(tmp_path)) + actual = drive_usage.read_drive_usage_bounded(str(tmp_path)) + assert actual.total == expected.total + assert 0 <= actual.used_percent <= 100 + assert not drive_usage._query_processes + + +def test_timed_out_helper_is_reaped(monkeypatch): + monkeypatch.setattr(drive_usage, '_query_command', sleeper_command) + monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', .2) + processes = [] + real_popen = subprocess.Popen + + def record(*args, **kwargs): + process = real_popen(*args, **kwargs) + processes.append(process) + return process + + monkeypatch.setattr(drive_usage.subprocess, 'Popen', record) + with pytest.raises(OSError, match='timed out'): + drive_usage.read_drive_usage_bounded('blocked') + assert processes[0].poll() is not None + assert not drive_usage._query_processes + + +def test_four_stalled_reads_release_slot_for_healthy_fifth(monkeypatch, tmp_path): + real_command = drive_usage._query_command + monkeypatch.setattr(drive_usage, '_query_command', + lambda path, result: sleeper_command(path, result) if path == 'blocked' else real_command(path, result)) + monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', 1) + with ThreadPoolExecutor(max_workers=4) as executor: + stalled = [executor.submit(drive_usage.read_drive_usage_bounded, 'blocked') for _ in range(4)] + healthy = executor.submit(drive_usage.read_drive_usage_bounded, str(tmp_path)) + assert healthy.result(timeout=6).total > 0 + for future in stalled: + with pytest.raises(OSError, match='timed out'): + future.result() + assert not drive_usage._query_processes + + +def test_shutdown_kills_running_helpers_and_cancels_queued(monkeypatch): + monkeypatch.setattr(drive_usage, '_query_command', sleeper_command) + monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', 60) + executor = drive_capacity.capacity_executor() + futures = [executor.submit(drive_usage.read_drive_usage_bounded, 'blocked') for _ in range(8)] + deadline = time.monotonic() + 5 + while len(drive_usage._query_processes) != 4: + assert time.monotonic() < deadline + time.sleep(.01) + processes = tuple(drive_usage._query_processes) + started = time.monotonic() + drive_capacity.shutdown_capacity_executor() + assert time.monotonic() - started < 3 + assert all(process.poll() is not None for process in processes) + assert all(future.done() for future in futures) + assert not drive_usage._query_processes + + +def test_packaged_entrypoint_dispatches_before_qt(tmp_path): + root = Path(__file__).resolve().parents[1] + output = tmp_path / 'result.json' + result = subprocess.run([sys.executable, str(root / 'src/main.py'), + '--drive-capacity-query', str(tmp_path), str(output)], + capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + assert '"total"' in output.read_text(encoding='utf-8') + + +def test_helper_works_without_stdout(monkeypatch, tmp_path): + monkeypatch.setattr(sys, 'stdout', None) + output = tmp_path / 'result.json' + assert drive_usage.capacity_query_main(str(tmp_path), str(output)) == 0 + assert '"total"' in output.read_text(encoding='utf-8') diff --git a/tests/test_file_browser.py b/tests/test_file_browser.py index 2be2b0c..3da0658 100644 --- a/tests/test_file_browser.py +++ b/tests/test_file_browser.py @@ -32,6 +32,19 @@ def test_dnd_flags_enabled(): assert browser.table.dragEnabled(), "table.dragEnabled() muss True sein" +def test_browser_destruction_disposes_its_models(): + from PySide6.QtCore import QCoreApplication, QEvent + from shiboken6 import isValid + _ensure_app() + browser = FileBrowser() + model, proxy = browser.model, browser.proxy + browser.deleteLater() + QCoreApplication.sendPostedEvents(browser, QEvent.Type.DeferredDelete) + assert not isValid(browser) + assert not isValid(model), 'Filesystem gatherer must not outlive its browser' + assert not isValid(proxy), 'Sort model must not outlive its browser' + + def test_do_file_drop_copies_file(tmp_path): """_do_file_drop kopiert eine Quelldatei ohne das Original zu löschen.""" _ensure_app() diff --git a/tests/test_gui_gc.py b/tests/test_gui_gc.py index e121632..eb0e946 100644 --- a/tests/test_gui_gc.py +++ b/tests/test_gui_gc.py @@ -208,3 +208,34 @@ def fail_setup(app, language): ) assert result.returncode == 0, result.stderr assert 'main-setup-close-restored' in result.stdout + + +def test_capacity_shutdown_drains_workers_before_restoring_gc(): + script = ''' +import gc, sys, threading, time +sys.path.insert(0, 'src') +from PySide6.QtWidgets import QApplication +from core.gui_gc import install_gui_gc +from gui.sidebar.drive_capacity import capacity_executor, shutdown_capacity_executor +app = QApplication([]) +collector = install_gui_gc(app) +entered = threading.Event() +def worker(): + entered.set() + time.sleep(.1) + assert not gc.isenabled() + return 'finished' +future = capacity_executor().submit(worker) +assert entered.wait(5) +shutdown_capacity_executor() +assert future.done() and future.result() == 'finished' +collector.close() +assert gc.isenabled() +print('shutdown-ok') +''' + result = subprocess.run( + [sys.executable, "-c", script], cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, timeout=25, + ) + assert result.returncode == 0, result.stderr + assert "shutdown-ok" in result.stdout diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py index a7fe322..39b834e 100644 --- a/tests/test_store_regressions_20260928.py +++ b/tests/test_store_regressions_20260928.py @@ -11,6 +11,7 @@ from PySide6.QtCore import QItemSelectionModel from PySide6.QtGui import QContextMenuEvent from PySide6.QtWidgets import QApplication, QInputDialog, QMenu +from PySide6.QtTest import QTest import gui.browser.file_browser as browser_module from gui.browser.file_browser import FileBrowser @@ -102,11 +103,11 @@ def test_navigation_loads_files_and_selection_reaches_preview(tmp_path): browser.resize(800, 500) browser.show() browser.navigate_to(str(tmp_path)) - for _ in range(100): - _app().processEvents() - if browser.proxy.rowCount(browser.table.rootIndex()) > 0: - break - time.sleep(.01) + # QFileSystemModel gathers rows asynchronously. Pump the actual Qt event + # loop with a bounded deadline instead of assuming a one-second OS scan. + deadline = time.monotonic() + 5 + while browser.proxy.rowCount(browser.table.rootIndex()) == 0 and time.monotonic() < deadline: + QTest.qWait(10) root = browser.proxy.mapToSource(browser.table.rootIndex()) assert Path(browser.model.filePath(root)) == tmp_path assert browser.proxy.rowCount(browser.table.rootIndex()) > 0