diff --git a/assets/images/GynTree_logo 64X64.ico b/assets/images/GynTree_logo 64X64.ico deleted file mode 100644 index c61933c..0000000 Binary files a/assets/images/GynTree_logo 64X64.ico and /dev/null differ diff --git a/assets/images/GynTree_logo.ico b/assets/images/GynTree_logo.ico new file mode 100644 index 0000000..6f32844 Binary files /dev/null and b/assets/images/GynTree_logo.ico differ diff --git a/assets/images/GynTree_logo.png b/assets/images/GynTree_logo.png index 048d1ef..59d46ff 100644 Binary files a/assets/images/GynTree_logo.png and b/assets/images/GynTree_logo.png differ diff --git a/assets/images/moon_icon.png b/assets/images/moon_icon.png new file mode 100644 index 0000000..073825b Binary files /dev/null and b/assets/images/moon_icon.png differ diff --git a/assets/images/sun_icon.png b/assets/images/sun_icon.png new file mode 100644 index 0000000..537476a Binary files /dev/null and b/assets/images/sun_icon.png differ diff --git a/requirements.txt b/requirements.txt index eda7491..1811a68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ altgraph==0.17.4 attrs==24.2.0 colorama==0.4.6 +coverage==7.6.1 execnet==2.1.1 iniconfig==2.0.0 Jinja2==3.1.4 @@ -18,6 +19,7 @@ PyQt5==5.15.11 PyQt5-Qt5==5.15.2 PyQt5_sip==12.15.0 pytest==8.3.3 +pytest-cov==5.0.0 pytest-html==4.1.1 pytest-metadata==3.1.1 pytest-mock==3.14.0 @@ -27,3 +29,5 @@ referencing==0.35.1 rpds-py==0.20.0 rsa==4.9 setuptools==75.1.0 +tdqm==0.0.1 +tqdm==4.66.5 diff --git a/scripts/build_executable.py b/scripts/build_executable.py index d33ca92..502d4be 100644 --- a/scripts/build_executable.py +++ b/scripts/build_executable.py @@ -3,29 +3,30 @@ import sys from PyQt5.QtCore import QLibraryInfo -# Get the absolute path to the project root project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -# Get PyQt5 directory pyqt_dir = QLibraryInfo.location(QLibraryInfo.BinariesPath) -# List of QT DLLs we need qt_dlls = ['Qt5Core.dll', 'Qt5Gui.dll', 'Qt5Widgets.dll'] -# Prepare the command line arguments for PyInstaller pyinstaller_args = [ '--name=GynTree', '--windowed', '--onefile', - f'--icon={os.path.join(project_root, "assets", "images", "GynTree_logo 64X64.ico")}', - f'--add-data={os.path.join(project_root, "assets")};assets', + '--clean', + f'--icon={os.path.join(project_root, "assets", "images", "GynTree_logo.ico")}', # For EXE icon + f'--add-data={os.path.join(project_root, "assets", "images", "GynTree_logo.ico")};assets/images', # For use in app + f'--add-data={os.path.join(project_root, "assets", "images", "file_icon.png")};assets/images', + f'--add-data={os.path.join(project_root, "assets", "images", "folder_icon.png")};assets/images', + f'--add-data={os.path.join(project_root, "assets", "images", "GynTree_logo.png")};assets/images', + f'--add-data={os.path.join(project_root, "src", "styles", "light_theme.qss")};styles', + f'--add-data={os.path.join(project_root, "src", "styles", "dark_theme.qss")};styles', '--hidden-import=PyQt5.sip', '--hidden-import=PyQt5.QtCore', '--hidden-import=PyQt5.QtGui', '--hidden-import=PyQt5.QtWidgets', ] -# Add Qt DLLs for dll in qt_dlls: dll_path = os.path.join(pyqt_dir, dll) if os.path.exists(dll_path): @@ -33,8 +34,6 @@ else: print(f"Warning: {dll} not found in {pyqt_dir}") -# Add the main script pyinstaller_args.append(os.path.join(project_root, 'src', 'App.py')) -# Run PyInstaller -PyInstaller.__main__.run(pyinstaller_args) \ No newline at end of file +PyInstaller.__main__.run(pyinstaller_args) diff --git a/src/App.py b/src/App.py index c8be246..088db1b 100644 --- a/src/App.py +++ b/src/App.py @@ -1,32 +1,32 @@ -""" - GynTree: Main entry point for the GynTree application. Initializes core components and starts the user interface. - The app module orchestrates the overall flow of the application, connecting various components and services. -""" - import sys import logging from PyQt5.QtWidgets import QApplication from controllers.AppController import AppController from utilities.error_handler import ErrorHandler +from utilities.theme_manager import ThemeManager -logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def main(): - # Set global exception handling sys.excepthook = ErrorHandler.global_exception_handler app = QApplication(sys.argv) - controller = AppController() - # Connect cleanup method to be called on application quit + theme_manager = ThemeManager.getInstance() + + try: + controller = AppController() + except Exception as e: + logger.critical(f"Failed to initialize AppController: {str(e)}") + sys.exit(1) + app.aboutToQuit.connect(controller.cleanup) - # Start the application controller.run() - # Start the event loop + theme_manager.apply_theme_to_all_windows(app) + sys.exit(app.exec_()) if __name__ == "__main__": @@ -34,4 +34,4 @@ def main(): main() except Exception as e: logger.critical(f"Fatal error in main: {str(e)}", exc_info=True) - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/src/components/TreeExporter.py b/src/components/TreeExporter.py index df6add6..bbf9422 100644 --- a/src/components/TreeExporter.py +++ b/src/components/TreeExporter.py @@ -31,7 +31,6 @@ def export_as_image(self): temp_tree.setColumnWidth(0, name_column_width + 20) temp_tree.setColumnWidth(1, type_column_width) - # Calculate the full size of the tree total_width = name_column_width + type_column_width + 40 total_height = 0 iterator = QTreeWidgetItemIterator(temp_tree, QTreeWidgetItemIterator.All) diff --git a/src/components/UI/AutoExcludeUI.py b/src/components/UI/AutoExcludeUI.py index ada88d9..bbb0ec6 100644 --- a/src/components/UI/AutoExcludeUI.py +++ b/src/components/UI/AutoExcludeUI.py @@ -1,8 +1,13 @@ -from PyQt5.QtWidgets import QMainWindow, QVBoxLayout, QLabel, QPushButton, QScrollArea, QWidget, QTreeWidget, QTreeWidgetItem, QMessageBox, QHeaderView, QHBoxLayout +from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QLabel, QPushButton, QScrollArea, QWidget, + QTreeWidget, QTreeWidgetItem, QMessageBox, QHeaderView, QHBoxLayout) from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QIcon, QFont import os from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager +import logging + +logger = logging.getLogger(__name__) class AutoExcludeUI(QMainWindow): def __init__(self, auto_exclude_manager, settings_manager, formatted_recommendations, project_context): @@ -11,18 +16,17 @@ def __init__(self, auto_exclude_manager, settings_manager, formatted_recommendat self.settings_manager = settings_manager self.formatted_recommendations = formatted_recommendations self.project_context = project_context + self.theme_manager = ThemeManager.getInstance() + self.folder_icon = QIcon(get_resource_path("assets/images/folder_icon.png")) self.file_icon = QIcon(get_resource_path("assets/images/file_icon.png")) + self.setWindowTitle('Auto-Exclude Recommendations') - self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) - self.setStyleSheet(""" - QMainWindow { background-color: #f0f0f0; } - QLabel { font-size: 20px; color: #333; margin-bottom: 10px; } - QPushButton { background-color: #4caf50; color: white; padding: 8px 16px; font-size: 14px; margin: 4px 2px; border-radius: 6px; } - QPushButton:hover { background-color: #45a049; } - QTreeWidget { font-size: 14px; color: #333; background-color: #fff; border: 1px solid #ddd; } - """) + self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo.ico'))) + self.init_ui() + + self.theme_manager.themeChanged.connect(self.apply_theme) def init_ui(self): central_widget = QWidget() @@ -33,6 +37,7 @@ def init_ui(self): header_layout = QHBoxLayout() title_label = QLabel('Auto-Exclude Recommendations', font=QFont('Arial', 16, QFont.Bold)) header_layout.addWidget(title_label) + collapse_btn = QPushButton('Collapse All') expand_btn = QPushButton('Expand All') header_layout.addWidget(collapse_btn) @@ -60,6 +65,8 @@ def init_ui(self): self.setCentralWidget(central_widget) self.setGeometry(300, 150, 800, 600) + self.apply_theme() + def populate_tree(self): """Populates the tree with merged exclusions from both AutoExcludeManager and project folder.""" self.tree_widget.clear() @@ -106,5 +113,8 @@ def update_recommendations(self, formatted_recommendations): self.formatted_recommendations = formatted_recommendations self.populate_tree() + def apply_theme(self): + self.theme_manager.apply_theme(self) + def closeEvent(self, event): - super().closeEvent(event) + super().closeEvent(event) \ No newline at end of file diff --git a/src/components/UI/DashboardUI.py b/src/components/UI/DashboardUI.py index 32d0a73..528b52e 100644 --- a/src/components/UI/DashboardUI.py +++ b/src/components/UI/DashboardUI.py @@ -1,5 +1,6 @@ import os -from PyQt5.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel, QStatusBar, QHBoxLayout +from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QWidget, QLabel, + QStatusBar, QHBoxLayout, QPushButton, QMessageBox) from PyQt5.QtGui import QIcon, QFont, QPixmap from PyQt5.QtCore import Qt from components.UI.ProjectUI import ProjectUI @@ -7,7 +8,9 @@ from components.UI.ResultUI import ResultUI from components.UI.DirectoryTreeUI import DirectoryTreeUI from components.UI.ExclusionsManagerUI import ExclusionsManagerUI +from components.UI.animated_toggle import AnimatedToggle from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager import logging logger = logging.getLogger(__name__) @@ -16,29 +19,22 @@ class DashboardUI(QMainWindow): def __init__(self, controller): super().__init__() self.controller = controller + self.theme_manager = ThemeManager.getInstance() self.project_ui = None + self.result_ui = None + self.auto_exclude_ui = None + self.exclusions_ui = None + self.directory_tree_ui = None + self.theme_toggle = None self.initUI() + # Connect controller signals + self.controller.project_created.connect(self.on_project_created) + self.controller.project_loaded.connect(self.on_project_loaded) + def initUI(self): self.setWindowTitle('GynTree Dashboard') - self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) - self.setStyleSheet(""" - QMainWindow { background-color: #f0f0f0; } - QLabel { color: #333; } - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 15px 32px; - text-align: center; - text-decoration: none; - font-size: 16px; - margin: 4px 2px; - border-radius: 8px; - } - QPushButton:hover { background-color: #45a049; } - QStatusBar { background-color: #333; color: white; } - """) + self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo.ico'))) central_widget = QWidget(self) self.setCentralWidget(central_widget) @@ -50,7 +46,7 @@ def initUI(self): logo_path = get_resource_path('assets/images/gyntree_logo.png') if os.path.exists(logo_path): logo_pixmap = QPixmap(logo_path) - logo_label.setPixmap(logo_pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + logo_label.setPixmap(logo_pixmap.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation)) else: logger.warning(f"Logo file not found at {logo_path}") @@ -63,13 +59,26 @@ def initUI(self): header_layout.setAlignment(Qt.AlignCenter) main_layout.addLayout(header_layout) + # Animated light/dark theme toggle + theme_toggle_layout = QHBoxLayout() + self.theme_toggle = AnimatedToggle( + checked_color="#FFB000", + pulse_checked_color="#44FFB000" + ) + self.theme_toggle.setFixedSize(self.theme_toggle.sizeHint()) + self.theme_toggle.setChecked(self.theme_manager.get_current_theme() == 'dark') + self.theme_toggle.stateChanged.connect(self.toggle_theme) + theme_toggle_layout.addWidget(self.theme_toggle) + theme_toggle_layout.setAlignment(Qt.AlignRight) + main_layout.addLayout(theme_toggle_layout) + self.create_project_btn = self.create_styled_button('Create Project') self.load_project_btn = self.create_styled_button('Load Project') self.manage_exclusions_btn = self.create_styled_button('Manage Exclusions') self.analyze_directory_btn = self.create_styled_button('Analyze Directory') self.view_directory_tree_btn = self.create_styled_button('View Directory Tree') - for btn in [self.create_project_btn, self.load_project_btn, self.manage_exclusions_btn, + for btn in [self.create_project_btn, self.load_project_btn, self.manage_exclusions_btn, self.analyze_directory_btn, self.view_directory_tree_btn]: main_layout.addWidget(btn) @@ -85,11 +94,16 @@ def initUI(self): self.setGeometry(300, 300, 800, 600) + self.theme_manager.apply_theme(self) + def create_styled_button(self, text): btn = QPushButton(text) btn.setFont(QFont('Arial', 14)) return btn + def toggle_theme(self): + self.controller.toggle_theme() + def show_dashboard(self): self.show() @@ -100,29 +114,54 @@ def show_project_ui(self): self.project_ui.show() return self.project_ui - def update_project_info(self, project): - self.setWindowTitle(f"GynTree - {project.name}") - self.status_bar.showMessage(f"Current project: {project.name}, Start directory: {project.start_directory}") + def on_project_created(self, project): + logger.info(f"Project created: {project.name}") + self.update_project_info(project) + self.enable_project_actions() + + def on_project_loaded(self, project): + logger.info(f"Project loaded: {project.name}") + self.update_project_info(project) + self.enable_project_actions() + + def enable_project_actions(self): + self.manage_exclusions_btn.setEnabled(True) + self.analyze_directory_btn.setEnabled(True) + self.view_directory_tree_btn.setEnabled(True) def show_auto_exclude_ui(self, auto_exclude_manager, settings_manager, formatted_recommendations, project_context): - auto_exclude_ui = AutoExcludeUI(auto_exclude_manager, settings_manager, formatted_recommendations, project_context) - auto_exclude_ui.show() - return auto_exclude_ui + if not self.auto_exclude_ui: + self.auto_exclude_ui = AutoExcludeUI(auto_exclude_manager, settings_manager, formatted_recommendations, project_context) + self.auto_exclude_ui.show() def show_result(self, directory_analyzer): - result_ui = ResultUI(directory_analyzer) - result_ui.show() - return result_ui + if self.controller.project_controller.project_context: + self.result_ui = ResultUI(self.controller, self.theme_manager, directory_analyzer) + self.result_ui.show() + return self.result_ui + else: + return None def manage_exclusions(self, settings_manager): - exclusions_ui = ExclusionsManagerUI(settings_manager) - exclusions_ui.show() - return exclusions_ui + if self.controller.project_controller.project_context: + self.exclusions_ui = ExclusionsManagerUI(self.controller, self.theme_manager, settings_manager) + self.exclusions_ui.show() + return self.exclusions_ui + else: + QMessageBox.warning(self, "No Project", "Please load or create a project before managing exclusions.") + return None + + def view_directory_tree_ui(self, result): + if not self.directory_tree_ui: + self.directory_tree_ui = DirectoryTreeUI(self.controller, self.theme_manager) + self.directory_tree_ui.update_tree(result) + self.directory_tree_ui.show() + - def view_directory_tree(self, result): - tree_ui = DirectoryTreeUI(result) - tree_ui.show() - return tree_ui + + def update_project_info(self, project): + self.setWindowTitle(f"GynTree - {project.name}") + self.status_bar.showMessage(f"Current project: {project.name}, Start directory: {project.start_directory}") def clear_directory_tree(self): if hasattr(self, 'directory_tree_view'): @@ -140,5 +179,4 @@ def clear_exclusions(self): logger.debug("Exclusions list cleared") def show_error_message(self, title, message): - from PyQt5.QtWidgets import QMessageBox QMessageBox.critical(self, title, message) \ No newline at end of file diff --git a/src/components/UI/DirectoryTreeUI.py b/src/components/UI/DirectoryTreeUI.py index 7dc4b60..22cd8ce 100644 --- a/src/components/UI/DirectoryTreeUI.py +++ b/src/components/UI/DirectoryTreeUI.py @@ -1,41 +1,48 @@ -""" -GynTree: This module implements the DirectoryTreeUI class for visualizing directory structures. -It creates an interactive tree view of the analyzed directory, allowing users to -explore the structure visually. The class also provides options for collapsing/expanding -nodes and exporting the tree view in different formats. -""" from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTreeWidget, QPushButton, QHBoxLayout, QTreeWidgetItem, QHeaderView) from PyQt5.QtGui import QFont, QIcon from PyQt5.QtCore import Qt, QSize from components.TreeExporter import TreeExporter from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager +import logging + +logger = logging.getLogger(__name__) class DirectoryTreeUI(QWidget): - def __init__(self, directory_structure): + def __init__(self, controller, theme_manager: ThemeManager): super().__init__() - self.directory_structure = directory_structure + self.controller = controller + self.theme_manager = theme_manager + self.directory_structure = None self.folder_icon = QIcon(get_resource_path("assets/images/folder_icon.png")) self.file_icon = QIcon(get_resource_path("assets/images/file_icon.png")) self.tree_widget = None self.tree_exporter = None self.init_ui() + self.theme_manager.themeChanged.connect(self.apply_theme) + def init_ui(self): - layout = QVBoxLayout() + main_layout = QVBoxLayout() + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(20) + header_layout = QHBoxLayout() - title_label = QLabel('Directory Tree', font=QFont('Arial', 14, QFont.Bold)) + title_label = QLabel('Directory Tree', font=QFont('Arial', 24, QFont.Bold)) header_layout.addWidget(title_label) - collapse_btn = QPushButton('Collapse All') - header_layout.addWidget(collapse_btn) + collapse_btn = self.create_styled_button('Collapse All') + expand_btn = self.create_styled_button('Expand All') + export_png_btn = self.create_styled_button('Export PNG') + export_ascii_btn = self.create_styled_button('Export ASCII') - export_png_btn = QPushButton('Export as PNG') - export_ascii_btn = QPushButton('Export as ASCII') + header_layout.addWidget(collapse_btn) + header_layout.addWidget(expand_btn) header_layout.addWidget(export_png_btn) header_layout.addWidget(export_ascii_btn) - - layout.addLayout(header_layout) + header_layout.setAlignment(Qt.AlignCenter) + main_layout.addLayout(header_layout) self.tree_widget = QTreeWidget() self.tree_widget.setHeaderLabels(['Name']) @@ -43,25 +50,39 @@ def init_ui(self): self.tree_widget.setAlternatingRowColors(True) self.tree_widget.setIconSize(QSize(20, 20)) self.tree_widget.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) - layout.addWidget(self.tree_widget) + main_layout.addWidget(self.tree_widget) - self._populate_tree(self.tree_widget.invisibleRootItem(), self.directory_structure) - self.tree_widget.expandAll() self.tree_exporter = TreeExporter(self.tree_widget) collapse_btn.clicked.connect(self.tree_widget.collapseAll) + expand_btn.clicked.connect(self.tree_widget.expandAll) export_png_btn.clicked.connect(self.tree_exporter.export_as_image) export_ascii_btn.clicked.connect(self.tree_exporter.export_as_ascii) - self.setLayout(layout) + self.setLayout(main_layout) self.setWindowTitle('Directory Tree') - self.resize(800, 600) + self.setGeometry(300, 150, 800, 600) + + self.apply_theme() + + def create_styled_button(self, text): + btn = QPushButton(text) + btn.setFont(QFont('Arial', 14)) + return btn + + def update_tree(self, directory_structure): + self.directory_structure = directory_structure + self.tree_widget.clear() + self._populate_tree(self.tree_widget.invisibleRootItem(), self.directory_structure) + self.tree_widget.expandAll() def _populate_tree(self, parent, data): item = QTreeWidgetItem(parent) item.setText(0, data['name']) - item.setIcon(0, self.folder_icon if data['type'] == 'Directory' else self.file_icon) - + item.setIcon(0, self.folder_icon if data['type'] == 'directory' else self.file_icon) if 'children' in data: for child in data['children']: - self._populate_tree(item, child) \ No newline at end of file + self._populate_tree(item, child) + + def apply_theme(self): + self.theme_manager.apply_theme(self) \ No newline at end of file diff --git a/src/components/UI/ExclusionsManagerUI.py b/src/components/UI/ExclusionsManagerUI.py index ecf8a7e..3df712d 100644 --- a/src/components/UI/ExclusionsManagerUI.py +++ b/src/components/UI/ExclusionsManagerUI.py @@ -1,49 +1,29 @@ -from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem, QPushButton, QHBoxLayout, - QFileDialog, QMessageBox, QGroupBox, QHeaderView, QSplitter -) +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem, + QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QGroupBox, + QHeaderView, QSplitter) from PyQt5.QtGui import QIcon, QFont from PyQt5.QtCore import Qt -from services.SettingsManager import SettingsManager from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager import os +import logging + +logger = logging.getLogger(__name__) class ExclusionsManagerUI(QWidget): - def __init__(self, settings_manager: SettingsManager): + def __init__(self, controller, theme_manager: ThemeManager, settings_manager): super().__init__() + self.controller = controller + self.theme_manager = theme_manager self.settings_manager = settings_manager + self.exclusion_tree = None + self.root_tree = None + self.setWindowTitle('Exclusions Manager') - self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) - self.setStyleSheet(""" - QWidget { - background-color: #f0f0f0; - color: #333; - } - QLabel { - font-size: 18px; - color: #333; - } - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 10px 20px; - text-align: center; - text-decoration: none; - font-size: 14px; - margin: 4px 2px; - border-radius: 8px; - } - QPushButton:hover { - background-color: #45a049; - } - QTreeWidget { - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - } - """) + self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo.ico'))) + self.init_ui() + self.theme_manager.themeChanged.connect(self.apply_theme) def init_ui(self): layout = QVBoxLayout() @@ -55,22 +35,20 @@ def init_ui(self): title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - # Splitter for Root Exclusions and Detailed Exclusions + # Splitter for root exclusions and detailed exclusions splitter = QSplitter(Qt.Vertical) - # Root Exclusions (Read-Only) - root_group = QGroupBox("Root Exclusions (Non-Editable)") + # Root exclusions (read-only) + root_group = QGroupBox("Root Exclusions (Non-editable)") root_layout = QVBoxLayout() - root_tree = QTreeWidget() - root_tree.setHeaderLabels(["Excluded Paths"]) - root_tree.header().setSectionResizeMode(0, QHeaderView.Stretch) - root_exclusions = self.settings_manager.get_root_exclusions() - self.populate_root_exclusions(root_tree, root_exclusions) - root_layout.addWidget(root_tree) + self.root_tree = QTreeWidget() + self.root_tree.setHeaderLabels(["Excluded Paths"]) + self.root_tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + root_layout.addWidget(self.root_tree) root_group.setLayout(root_layout) splitter.addWidget(root_group) - # Detailed Exclusions (Editable) + # Detailed exclusions (editable) detailed_group = QGroupBox("Detailed Exclusions") detailed_layout = QVBoxLayout() self.exclusion_tree = QTreeWidget() @@ -103,70 +81,99 @@ def init_ui(self): add_file_button.clicked.connect(self.add_file) remove_button.clicked.connect(self.remove_selected) - self.populate_exclusion_tree() + self.apply_theme() - def populate_root_exclusions(self, tree, exclusions): - for path in sorted(exclusions): - item = QTreeWidgetItem(tree, [path]) - item.setFlags(item.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEditable) - tree.expandAll() + def showEvent(self, event): + super().showEvent(event) + self.load_project_data() + + def load_project_data(self): + if self.controller.project_controller.project_context and self.controller.project_controller.project_context.is_initialized: + self.settings_manager = self.controller.project_controller.project_context.settings_manager + self.populate_exclusion_tree() + self.populate_root_exclusions() + else: + QMessageBox.warning(self, "No Project", "No project is currently loaded or initialized. Please load or create a project first.") + + def populate_root_exclusions(self): + self.root_tree.clear() + if self.settings_manager: + root_exclusions = self.settings_manager.get_root_exclusions() + for path in sorted(root_exclusions): + item = QTreeWidgetItem(self.root_tree, [path]) + item.setFlags(item.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEditable) + self.root_tree.expandAll() def populate_exclusion_tree(self): self.exclusion_tree.clear() - exclusions = self.settings_manager.get_all_exclusions() - - dirs_item = QTreeWidgetItem(self.exclusion_tree, ['excluded_dirs']) - dirs_item.setFlags(dirs_item.flags() & ~Qt.ItemIsSelectable) - for directory in sorted(exclusions.get('excluded_dirs', [])): - item = QTreeWidgetItem(dirs_item, ['Directory', directory]) - item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) + if self.settings_manager: + exclusions = self.settings_manager.get_all_exclusions() + + dirs_item = QTreeWidgetItem(self.exclusion_tree, ['Excluded Dirs']) + dirs_item.setFlags(dirs_item.flags() & ~Qt.ItemIsSelectable) + for directory in sorted(exclusions.get('excluded_dirs', [])): + item = QTreeWidgetItem(dirs_item, ['Directory', directory]) + item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) - files_item = QTreeWidgetItem(self.exclusion_tree, ['excluded_files']) - files_item.setFlags(files_item.flags() & ~Qt.ItemIsSelectable) - for file in sorted(exclusions.get('excluded_files', [])): - item = QTreeWidgetItem(files_item, ['File', file]) - item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) + files_item = QTreeWidgetItem(self.exclusion_tree, ['Excluded Files']) + files_item.setFlags(files_item.flags() & ~Qt.ItemIsSelectable) + for file in sorted(exclusions.get('excluded_files', [])): + item = QTreeWidgetItem(files_item, ['File', file]) + item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) - self.exclusion_tree.expandAll() + self.exclusion_tree.expandAll() def add_directory(self): + if not self.settings_manager: + QMessageBox.warning(self, "No Project", "No project is currently loaded.") + return + directory = QFileDialog.getExistingDirectory(self, 'Select Directory to Exclude') if directory: - relative_directory = os.path.relpath(directory, self.settings_manager.project.start_directory) + relative_directory = os.path.relpath(directory, self.controller.project_controller.project_context.project.start_directory) exclusions = self.settings_manager.get_all_exclusions() - if relative_directory in exclusions['excluded_dirs'] or relative_directory in exclusions['root_exclusions']: + if relative_directory not in exclusions['excluded_dirs'] and relative_directory not in exclusions['root_exclusions']: + exclusions['excluded_dirs'].add(relative_directory) + self.settings_manager.update_settings({'excluded_dirs': list(exclusions['excluded_dirs'])}) + self.populate_exclusion_tree() + else: QMessageBox.warning(self, "Duplicate Entry", f"The directory '{relative_directory}' is already excluded.") - return - exclusions['excluded_dirs'].add(relative_directory) - self.settings_manager.update_settings({'excluded_dirs': list(exclusions['excluded_dirs'])}) - self.populate_exclusion_tree() def add_file(self): + if not self.settings_manager: + QMessageBox.warning(self, "No Project", "No project is currently loaded.") + return + file, _ = QFileDialog.getOpenFileName(self, 'Select File to Exclude') if file: - relative_file = os.path.relpath(file, self.settings_manager.project.start_directory) + relative_file = os.path.relpath(file, self.controller.project_controller.project_context.project.start_directory) exclusions = self.settings_manager.get_all_exclusions() - if relative_file in exclusions['excluded_files'] or any(relative_file.startswith(root_dir) for root_dir in exclusions['root_exclusions']): + if relative_file not in exclusions['excluded_files'] and not any(relative_file.startswith(root_dir) for root_dir in exclusions['root_exclusions']): + exclusions['excluded_files'].add(relative_file) + self.settings_manager.update_settings({'excluded_files': list(exclusions['excluded_files'])}) + self.populate_exclusion_tree() + else: QMessageBox.warning(self, "Duplicate Entry", f"The file '{relative_file}' is already excluded or within a root exclusion.") - return - exclusions['excluded_files'].add(relative_file) - self.settings_manager.update_settings({'excluded_files': list(exclusions['excluded_files'])}) - self.populate_exclusion_tree() def remove_selected(self): + if not self.settings_manager: + QMessageBox.warning(self, "No Project", "No project is currently loaded.") + return + selected_items = self.exclusion_tree.selectedItems() if not selected_items: QMessageBox.information(self, "No Selection", "Please select an exclusion to remove.") return + for item in selected_items: parent = item.parent() if parent: path = item.text(1) category = parent.text(0) exclusions = self.settings_manager.get_all_exclusions() - if category == 'excluded_dirs': + if category == 'Excluded Dirs': exclusions['excluded_dirs'].discard(path) - elif category == 'excluded_files': + elif category == 'Excluded Files': exclusions['excluded_files'].discard(path) self.settings_manager.update_settings({ 'excluded_dirs': list(exclusions['excluded_dirs']), @@ -175,9 +182,24 @@ def remove_selected(self): self.populate_exclusion_tree() def save_and_exit(self): - self.settings_manager.save_settings() - QMessageBox.information(self, "Exclusions Saved", "Exclusions have been successfully saved.") - self.close() + if self.settings_manager: + root_exclusions = [self.root_tree.topLevelItem(i).text(0) for i in range(self.root_tree.topLevelItemCount())] + excluded_dirs = [self.exclusion_tree.topLevelItem(0).child(i).text(1) for i in range(self.exclusion_tree.topLevelItem(0).childCount())] + excluded_files = [self.exclusion_tree.topLevelItem(1).child(i).text(1) for i in range(self.exclusion_tree.topLevelItem(1).childCount())] + + self.settings_manager.update_settings({ + 'root_exclusions': root_exclusions, + 'excluded_dirs': excluded_dirs, + 'excluded_files': excluded_files + }) + self.settings_manager.save_settings() + QMessageBox.information(self, "Exclusions Saved", "Exclusions have been successfully saved.") + self.close() + else: + QMessageBox.warning(self, "Error", "No project loaded. Cannot save exclusions.") + + def apply_theme(self): + self.theme_manager.apply_theme(self) def closeEvent(self, event): super().closeEvent(event) \ No newline at end of file diff --git a/src/components/UI/ProjectUI.py b/src/components/UI/ProjectUI.py index d7d6b01..0f47762 100644 --- a/src/components/UI/ProjectUI.py +++ b/src/components/UI/ProjectUI.py @@ -4,6 +4,10 @@ from PyQt5.QtCore import Qt, pyqtSignal from models.Project import Project from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager +import logging + +logger = logging.getLogger(__name__) class ProjectUI(QWidget): project_created = pyqtSignal(object) @@ -12,41 +16,25 @@ class ProjectUI(QWidget): def __init__(self, controller): super().__init__() self.controller = controller + self.theme_manager = ThemeManager.getInstance() self.init_ui() + self.theme_manager.themeChanged.connect(self.apply_theme) + def init_ui(self): self.setWindowTitle('Project Manager') - self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) - self.setStyleSheet(""" - QWidget { background-color: #f0f0f0; color: #333; } - QLabel { font-size: 16px; color: #333; } - QLineEdit { padding: 8px; font-size: 14px; border: 1px solid #ddd; border-radius: 4px; } - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 10px 20px; - text-align: center; - text-decoration: none; - font-size: 14px; - margin: 4px 2px; - border-radius: 8px; - } - QPushButton:hover { background-color: #45a049; } - QListWidget { border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } - """) + self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo.ico'))) layout = QVBoxLayout() - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) + layout.setContentsMargins(30, 30, 30, 30) + layout.setSpacing(20) - # Create Project Section create_section = QFrame() create_section.setFrameShape(QFrame.StyledPanel) create_layout = QVBoxLayout(create_section) create_title = QLabel('Create New Project') - create_title.setFont(QFont('Arial', 18, QFont.Bold)) + create_title.setFont(QFont('Arial', 24, QFont.Bold)) create_layout.addWidget(create_title) self.project_name_input = QLineEdit() @@ -54,40 +42,46 @@ def init_ui(self): create_layout.addWidget(self.project_name_input) dir_layout = QHBoxLayout() - self.start_dir_button = QPushButton('Select Start Directory') + self.start_dir_button = self.create_styled_button('Select Start Directory') self.start_dir_button.clicked.connect(self.select_directory) self.start_dir_label = QLabel('No directory selected') dir_layout.addWidget(self.start_dir_button) dir_layout.addWidget(self.start_dir_label) create_layout.addLayout(dir_layout) - self.create_project_btn = QPushButton('Create Project') + self.create_project_btn = self.create_styled_button('Create Project') self.create_project_btn.clicked.connect(self.create_project) create_layout.addWidget(self.create_project_btn) layout.addWidget(create_section) - # Load Project Section load_section = QFrame() load_section.setFrameShape(QFrame.StyledPanel) load_layout = QVBoxLayout(load_section) load_title = QLabel('Load Existing Project') - load_title.setFont(QFont('Arial', 18, QFont.Bold)) + load_title.setFont(QFont('Arial', 24, QFont.Bold)) load_layout.addWidget(load_title) self.project_list = QListWidget() self.project_list.addItems(self.controller.project_controller.project_manager.list_projects()) load_layout.addWidget(self.project_list) - self.load_project_btn = QPushButton('Load Project') + self.load_project_btn = self.create_styled_button('Load Project') self.load_project_btn.clicked.connect(self.load_project) load_layout.addWidget(self.load_project_btn) layout.addWidget(load_section) self.setLayout(layout) - self.setGeometry(300, 300, 500, 600) + self.setGeometry(300, 300, 600, 600) + + self.apply_theme() + + def create_styled_button(self, text): + btn = QPushButton(text) + btn.setFont(QFont('Arial', 14)) + return btn def select_directory(self): directory = QFileDialog.getExistingDirectory(self, "Select Start Directory") @@ -99,6 +93,7 @@ def create_project(self): start_directory = self.start_dir_label.text() if project_name and start_directory != 'No directory selected': new_project = Project(name=project_name, start_directory=start_directory) + logger.info(f"Creating new project: {project_name}") self.project_created.emit(new_project) self.project_name_input.clear() self.start_dir_label.setText('No directory selected') @@ -110,15 +105,14 @@ def load_project(self): selected_items = self.project_list.selectedItems() if selected_items: project_name = selected_items[0].text() + logger.info(f"Loading project: {project_name}") self.project_loaded.emit(Project(name=project_name, start_directory="")) self.close() else: QMessageBox.warning(self, "No Selection", "Please select a project to load.") - def get_selected_project_name(self): - selected_items = self.project_list.selectedItems() - if selected_items: - return selected_items[0].text() - else: - QMessageBox.warning(self, "No Selection", "Please select a project to load.") - return None \ No newline at end of file + def apply_theme(self): + self.theme_manager.apply_theme(self) + + def closeEvent(self, event): + super().closeEvent(event) \ No newline at end of file diff --git a/src/components/UI/ResultUI.py b/src/components/UI/ResultUI.py index 319a9c5..b1b56c2 100644 --- a/src/components/UI/ResultUI.py +++ b/src/components/UI/ResultUI.py @@ -1,37 +1,38 @@ from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QLabel, QPushButton, QFileDialog, QWidget, QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QApplication, QSplitter, QDesktopWidget) -from PyQt5.QtGui import QIcon, QFont, QPalette, QColor +from PyQt5.QtGui import QIcon, QFont from PyQt5.QtCore import Qt, QTimer import csv from utilities.resource_path import get_resource_path +from utilities.theme_manager import ThemeManager +import logging + +logger = logging.getLogger(__name__) class ResultUI(QMainWindow): - def __init__(self, directory_analyzer): + def __init__(self, controller, theme_manager: ThemeManager, directory_analyzer): super().__init__() + self.controller = controller + self.theme_manager = theme_manager self.directory_analyzer = directory_analyzer self.result_data = None self.init_ui() + self.theme_manager.themeChanged.connect(self.apply_theme) + def init_ui(self): self.setWindowTitle('Analysis Results') - self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo 64X64.ico'))) - - palette = QPalette() - palette.setColor(QPalette.Window, QColor(240, 240, 240)) - palette.setColor(QPalette.WindowText, QColor(50, 50, 50)) - palette.setColor(QPalette.Button, QColor(220, 220, 220)) - palette.setColor(QPalette.ButtonText, QColor(50, 50, 50)) - self.setPalette(palette) - + self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo.ico'))) + central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) - layout.setContentsMargins(20, 20, 20, 20) + layout.setContentsMargins(30, 30, 30, 30) layout.setSpacing(20) title = QLabel('Directory Analysis Results') - title.setFont(QFont('Arial', 18, QFont.Bold)) + title.setFont(QFont('Arial', 24, QFont.Bold)) title.setAlignment(Qt.AlignCenter) title.setMaximumHeight(40) layout.addWidget(title) @@ -44,37 +45,20 @@ def init_ui(self): self.result_table.setWordWrap(True) self.result_table.setTextElideMode(Qt.ElideNone) self.result_table.setShowGrid(True) - self.result_table.setStyleSheet(""" - QTableWidget { - border: 1px solid #d6d9dc; - gridline-color: #f0f0f0; - } - QTableWidget::item { - padding: 5px; - border-bottom: 1px solid #f0f0f0; - } - QHeaderView::section { - background-color: #f8f9fa; - padding: 5px; - border: 1px solid #d6d9dc; - font-weight: bold; - } - """) - layout.addWidget(self.result_table) button_layout = QHBoxLayout() - button_layout.setSpacing(10) + button_layout.setSpacing(15) - copy_button = QPushButton('Copy to Clipboard') + copy_button = self.create_styled_button('Copy to Clipboard') copy_button.clicked.connect(self.copy_to_clipboard) button_layout.addWidget(copy_button) - save_txt_button = QPushButton('Save as TXT') + save_txt_button = self.create_styled_button('Save as TXT') save_txt_button.clicked.connect(lambda: self.save_file('txt')) button_layout.addWidget(save_txt_button) - save_csv_button = QPushButton('Save as CSV') + save_csv_button = self.create_styled_button('Save as CSV') save_csv_button.clicked.connect(lambda: self.save_file('csv')) button_layout.addWidget(save_csv_button) @@ -85,8 +69,17 @@ def init_ui(self): height = int(screen.height() * 0.8) self.setGeometry(int(screen.width() * 0.1), int(screen.height() * 0.1), width, height) + self.apply_theme() + + def create_styled_button(self, text): + """Helper method to create styled buttons.""" + btn = QPushButton(text) + btn.setFont(QFont('Arial', 14)) + return btn + def update_result(self): - self.result_data = self.directory_analyzer.get_flat_structure() + """Updates the result table with data from the directory analyzer.""" + self.result_data = self.controller.project_controller.project_context.directory_analyzer.get_flat_structure() self.result_table.setRowCount(len(self.result_data)) max_path_width = 0 for row, item in enumerate(self.result_data): @@ -98,18 +91,18 @@ def update_result(self): padding = 50 self.result_table.setColumnWidth(0, max_path_width + padding) self.result_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - self.result_table.resizeRowsToContents() - QTimer.singleShot(0, self.adjust_column_widths) def adjust_column_widths(self): + """Adjust the widths of the result table columns to fit the available space.""" total_width = self.result_table.viewport().width() path_column_width = self.result_table.columnWidth(0) description_column_width = total_width - path_column_width self.result_table.setColumnWidth(1, description_column_width) def copy_to_clipboard(self): + """Copy the result data to the system clipboard.""" clipboard_text = "Path,Description\n" for row in range(self.result_table.rowCount()): row_data = [ @@ -120,6 +113,7 @@ def copy_to_clipboard(self): QApplication.clipboard().setText(clipboard_text) def save_file(self, file_type): + """Save the result data to a file (TXT or CSV).""" options = QFileDialog.Options() if file_type == 'txt': file_name, _ = QFileDialog.getSaveFileName(self, "Save TXT", "", "Text Files (*.txt)", options=options) @@ -144,4 +138,10 @@ def resizeEvent(self, event): self.adjust_column_widths() def refresh_display(self): - self.update_result() \ No newline at end of file + self.update_result() + + def apply_theme(self): + self.theme_manager.apply_theme(self) + + def closeEvent(self, event): + super().closeEvent(event) \ No newline at end of file diff --git a/src/components/UI/animated_toggle.py b/src/components/UI/animated_toggle.py new file mode 100644 index 0000000..c2ac108 --- /dev/null +++ b/src/components/UI/animated_toggle.py @@ -0,0 +1,135 @@ +from PyQt5.QtCore import ( + Qt, QSize, QPoint, QPointF, QRectF, + QEasingCurve, QPropertyAnimation, QSequentialAnimationGroup, + pyqtSlot, pyqtProperty) + +from PyQt5.QtWidgets import QCheckBox +from PyQt5.QtGui import QColor, QBrush, QPaintEvent, QPen, QPainter + + +class AnimatedToggle(QCheckBox): + + _transparent_pen = QPen(Qt.transparent) + _light_grey_pen = QPen(Qt.lightGray) + + def __init__(self, + parent=None, + bar_color=Qt.gray, + checked_color="#00B0FF", + handle_color=Qt.white, + pulse_unchecked_color="#44999999", + pulse_checked_color="#4400B0EE" + ): + super().__init__(parent) + + self._bar_brush = QBrush(bar_color) + self._bar_checked_brush = QBrush(QColor(checked_color).lighter()) + + self._handle_brush = QBrush(handle_color) + self._handle_checked_brush = QBrush(QColor(checked_color)) + + self._pulse_unchecked_animation = QBrush(QColor(pulse_unchecked_color)) + self._pulse_checked_animation = QBrush(QColor(pulse_checked_color)) + + self.setContentsMargins(8, 0, 8, 0) + self._handle_position = 0 + + self._pulse_radius = 0 + + self.animation = QPropertyAnimation(self, b"handle_position", self) + self.animation.setEasingCurve(QEasingCurve.InOutCubic) + self.animation.setDuration(200) # time in ms + + self.pulse_anim = QPropertyAnimation(self, b"pulse_radius", self) + self.pulse_anim.setDuration(350) # time in ms + self.pulse_anim.setStartValue(10) + self.pulse_anim.setEndValue(20) + + self.animations_group = QSequentialAnimationGroup() + self.animations_group.addAnimation(self.animation) + self.animations_group.addAnimation(self.pulse_anim) + + self.stateChanged.connect(self.setup_animation) + + def sizeHint(self): + return QSize(58, 45) + + def hitButton(self, pos: QPoint): + return self.contentsRect().contains(pos) + + @pyqtSlot(int) + def setup_animation(self, value): + self.animations_group.stop() + if value: + self.animation.setEndValue(1) + else: + self.animation.setEndValue(0) + self.animations_group.start() + + def paintEvent(self, e: QPaintEvent): + + contRect = self.contentsRect() + handleRadius = round(0.24 * contRect.height()) + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + p.setPen(self._transparent_pen) + barRect = QRectF( + 0, 0, + contRect.width() - handleRadius, 0.40 * contRect.height() + ) + barRect.moveCenter(contRect.center()) + rounding = barRect.height() / 2 + + # the handle will move along this line + trailLength = contRect.width() - 2 * handleRadius + + xPos = contRect.x() + handleRadius + trailLength * self._handle_position + + if self.pulse_anim.state() == QPropertyAnimation.Running: + p.setBrush( + self._pulse_checked_animation if + self.isChecked() else self._pulse_unchecked_animation) + p.drawEllipse(QPointF(xPos, barRect.center().y()), + self._pulse_radius, self._pulse_radius) + + if self.isChecked(): + p.setBrush(self._bar_checked_brush) + p.drawRoundedRect(barRect, rounding, rounding) + p.setBrush(self._handle_checked_brush) + + else: + p.setBrush(self._bar_brush) + p.drawRoundedRect(barRect, rounding, rounding) + p.setPen(self._light_grey_pen) + p.setBrush(self._handle_brush) + + p.drawEllipse( + QPointF(xPos, barRect.center().y()), + handleRadius, handleRadius) + + p.end() + + @pyqtProperty(float) + def handle_position(self): + return self._handle_position + + @handle_position.setter + def handle_position(self, pos): + """change the property + we need to trigger QWidget.update() method, either by: + 1- calling it here [ what we doing ]. + 2- connecting the QPropertyAnimation.valueChanged() signal to it. + """ + self._handle_position = pos + self.update() + + @pyqtProperty(float) + def pulse_radius(self): + return self._pulse_radius + + @pulse_radius.setter + def pulse_radius(self, pos): + self._pulse_radius = pos + self.update() \ No newline at end of file diff --git a/src/controllers/AppController.py b/src/controllers/AppController.py index 7964791..74a3eb7 100644 --- a/src/controllers/AppController.py +++ b/src/controllers/AppController.py @@ -1,24 +1,13 @@ -""" -GynTree: AppController orchestrates the overall application workflow. -This controller ties together the other controllers (ProjectController, ThreadController, -UIController) to ensure smooth interaction between project management, thread handling, -and user interface updates. It acts as the main coordinator for the application. - -Responsibilities: -- Delegate tasks to specialized controllers (project, thread, UI). -- Handle project creation and loading. -- Manage the overall application lifecycle, including cleanup. -""" - import logging from PyQt5.QtCore import QObject, pyqtSignal, QTimer -from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtWidgets import QMessageBox, QApplication from components.UI.DashboardUI import DashboardUI from controllers.ProjectController import ProjectController from controllers.ThreadController import ThreadController from controllers.UIController import UIController from utilities.error_handler import handle_exception from utilities.logging_decorator import log_method +from utilities.theme_manager import ThemeManager logger = logging.getLogger(__name__) @@ -29,15 +18,21 @@ class AppController(QObject): def __init__(self): super().__init__() self.main_ui = DashboardUI(self) + self.theme_manager = ThemeManager.getInstance() self.project_controller = ProjectController(self) self.thread_controller = ThreadController() self.ui_controller = UIController(self.main_ui) self.ui_components = [] + self.project_context = None - # Connect thread controller signals self.thread_controller.worker_finished.connect(self._on_auto_exclude_finished) self.thread_controller.worker_error.connect(self._on_auto_exclude_error) + self.theme_manager.themeChanged.connect(self.apply_theme_to_all_windows) + + initial_theme = self.get_theme_preference() + self.theme_manager.set_theme(initial_theme) + def __enter__(self): return self @@ -54,14 +49,11 @@ def run(self): def cleanup(self): logger.debug("Starting cleanup process in AppController") - # Clean up thread controller self.thread_controller.cleanup_thread() - # Close project context if self.project_controller and self.project_controller.project_context: self.project_controller.project_context.close() - # Clean up UI components for ui in self.ui_components: if ui and not ui.isHidden(): logger.debug(f"Closing UI: {type(ui).__name__}") @@ -69,11 +61,30 @@ def cleanup(self): ui.deleteLater() self.ui_components.clear() + + QApplication.closeAllWindows() + logger.debug("Cleanup process in AppController completed") + def toggle_theme(self): + new_theme = self.theme_manager.toggle_theme() + self.set_theme_preference(new_theme) + + def apply_theme_to_all_windows(self, theme): + app = QApplication.instance() + self.theme_manager.apply_theme_to_all_windows(app) + + def get_theme_preference(self): + return self.project_controller.get_theme_preference() if self.project_controller else 'light' + + def set_theme_preference(self, theme): + if self.project_controller: + self.project_controller.set_theme_preference(theme) + @handle_exception @log_method def create_project_action(self, *args): + logger.debug("Creating project UI") project_ui = self.main_ui.show_project_ui() project_ui.project_created.connect(self.on_project_created) self.ui_components.append(project_ui) @@ -81,17 +92,26 @@ def create_project_action(self, *args): @handle_exception @log_method def on_project_created(self, project): - success = self.project_controller.create_project(project) - if success: - self.project_created.emit(project) - self.main_ui.update_project_info(project) - self.after_project_loaded() - else: - QMessageBox.critical(self.main_ui, "Error", "Failed to create project. Please try again.") + logger.info(f"Project created signal received for project: {project.name}") + try: + success = self.project_controller.create_project(project) + if success: + logger.info(f"Project {project.name} created successfully") + self.project_context = self.project_controller.project_context + self.project_created.emit(project) + self.main_ui.update_project_info(project) # Call this only once + self.after_project_loaded() + else: + logger.error(f"Failed to create project: {project.name}") + QMessageBox.critical(self.main_ui, "Error", "Failed to create project. Please try again.") + except Exception as e: + logger.exception(f"Exception occurred while creating project: {str(e)}") + QMessageBox.critical(self.main_ui, "Error", f"An unexpected error occurred: {str(e)}") @handle_exception @log_method def load_project_action(self, *args): + logger.debug("Loading project UI") project_ui = self.main_ui.show_project_ui() project_ui.project_loaded.connect(self.on_project_loaded) self.ui_components.append(project_ui) @@ -102,7 +122,7 @@ def on_project_loaded(self, project): loaded_project = self.project_controller.load_project(project.name) if loaded_project: self.project_loaded.emit(loaded_project) - self.main_ui.update_project_info(loaded_project) + self.main_ui.update_project_info(loaded_project) # Call this only once self.after_project_loaded() else: QMessageBox.critical(self.main_ui, "Error", "Failed to load project. Please try again.") @@ -111,8 +131,8 @@ def on_project_loaded(self, project): @log_method def after_project_loaded(self): self.ui_controller.reset_ui() - if self.project_controller.project_context: - QTimer.singleShot(0, self._start_auto_exclude) + if self.project_controller and self.project_controller.project_context: + self._start_auto_exclude() else: logger.error("Project context not initialized. Cannot start auto-exclude thread.") QMessageBox.warning(self.main_ui, "Warning", "Failed to initialize project context. Some features may not work correctly.") @@ -147,31 +167,36 @@ def _on_auto_exclude_error(self, error_msg): @handle_exception @log_method def manage_exclusions(self, *args): - if self.project_controller.project_context: + if self.project_controller and self.project_controller.project_context: exclusions_manager_ui = self.ui_controller.manage_exclusions(self.project_controller.project_context.settings_manager) self.ui_components.append(exclusions_manager_ui) else: logger.error("No project context available.") + QMessageBox.warning(self.main_ui, "Error", "No project is currently loaded. Please load a project first.") @handle_exception @log_method def view_directory_tree(self, *args): - if self.project_controller.project_context: + if self.project_controller and self.project_controller.project_context: result = self.project_controller.project_context.get_directory_tree() directory_tree_ui = self.ui_controller.view_directory_tree(result) self.ui_components.append(directory_tree_ui) else: logger.error("Cannot view directory tree: project_context is None.") + QMessageBox.warning(self.main_ui, "Error", "No project is currently loaded. Please load a project first.") @handle_exception @log_method def analyze_directory(self, *args): - if self.project_controller.project_context: + if self.project_controller and self.project_controller.project_context: result_ui = self.ui_controller.show_result(self.project_controller.project_context.directory_analyzer) - result_ui.update_result() - self.ui_components.append(result_ui) + if result_ui is not None: + result_ui.update_result() + else: + logger.error("ResultUI could not be initialized.") else: logger.error("Cannot analyze directory: project_context is None.") + QMessageBox.warning(self.main_ui, "Error", "No project is currently loaded. Please load a project first.") def __del__(self): logger.debug("AppController destructor called") diff --git a/src/controllers/ProjectController.py b/src/controllers/ProjectController.py index 9974290..b64f850 100644 --- a/src/controllers/ProjectController.py +++ b/src/controllers/ProjectController.py @@ -51,6 +51,13 @@ def _set_current_project(self, project: Project): self.current_project = project logger.debug(f"Project '{project.name}' set as active.") + def get_theme_preference(self): + return self.project_context.get_theme_preference() if self.project_context else 'light' + + def set_theme_preference(self, theme: str): + if self.project_context: + self.project_context.set_theme_preference(theme) + def analyze_directory(self): """Trigger directory analysis""" if self.project_context: @@ -60,7 +67,7 @@ def analyze_directory(self): logger.error("Cannot analyze directory: project_context is None.") def view_directory_tree(self): - """View the directory structure""" + """Trigger view of the directory structure""" if self.project_context: result = self.project_context.get_directory_tree() self.app_controller.ui_controller.view_directory_tree(result) diff --git a/src/controllers/UIController.py b/src/controllers/UIController.py index c84f7fe..38ab109 100644 --- a/src/controllers/UIController.py +++ b/src/controllers/UIController.py @@ -10,6 +10,9 @@ - Provide a clean interface for displaying project information in the main UI. """ +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, QMetaObject, Qt +from PyQt5.QtWidgets import QMessageBox +from components.UI.ResultUI import ResultUI import logging logger = logging.getLogger(__name__) @@ -34,16 +37,19 @@ def manage_exclusions(self, settings_manager): return self.main_ui.manage_exclusions(settings_manager) def update_project_info(self, project): - """Update the project information displayed in the UI.""" + """Update project information displayed in the UI.""" self.main_ui.update_project_info(project) def view_directory_tree(self, result): - """Show directory tree UI given result.""" - return self.main_ui.view_directory_tree(result) + """Show directory tree UI given the result.""" + return self.main_ui.view_directory_tree_ui(result) def show_result(self, directory_analyzer): """Show result UI given directory analyzer.""" return self.main_ui.show_result(directory_analyzer) + + def update_ui(self, component, data): + QMetaObject.invokeMethod(component, "update_data", Qt.QueuedConnection, data) def show_error_message(self, title, message): """Display an error message to the user.""" diff --git a/src/services/CommentParser.py b/src/services/CommentParser.py index 131fa5c..8075466 100644 --- a/src/services/CommentParser.py +++ b/src/services/CommentParser.py @@ -1,8 +1,8 @@ -from abc import ABC, abstractmethod -import os import re -from typing import Dict, Tuple, Optional +import os import logging +from abc import ABC, abstractmethod +from typing import Dict, Tuple, Optional, List logger = logging.getLogger(__name__) @@ -26,11 +26,11 @@ def read_file(self, filepath: str, max_chars: int) -> str: class CommentSyntax(ABC): @abstractmethod - def get_syntax(self, file_extension: str) -> Dict[str, Optional[str]]: + def get_syntax(self, file_extension: str) -> Dict[str, Optional[Tuple[str, str]]]: pass class DefaultCommentSyntax(CommentSyntax): - SYNTAX = { + syntax = { '.py': {'single': '#', 'multi': ('"""', '"""')}, '.js': {'single': '//', 'multi': ('/*', '*/')}, '.ts': {'single': '//', 'multi': ('/*', '*/')}, @@ -42,14 +42,32 @@ class DefaultCommentSyntax(CommentSyntax): '.cpp': {'single': '//', 'multi': ('/*', '*/')} } - def get_syntax(self, file_extension: str) -> Dict[str, Optional[str]]: - return self.SYNTAX.get(file_extension, {}) + def get_syntax(self, file_extension: str) -> Dict[str, Optional[Tuple[str, str]]]: + return self.syntax.get(file_extension, {}) class CommentParser: + """ + A parser for extracting GynTree comments from various file types. + + This parser supports both single-line and multi-line comments across different + programming languages. It looks for comments that start with 'GynTree:' (case-insensitive) + and extracts the content following this marker. + + Key behaviors: + - Only the first GynTree comment in a file is extracted. + - For multi-line comments, all lines after the GynTree marker are included until the end of the comment. + - The parser preserves the original formatting of the comment, including newlines and indentation. + - Comments not starting with 'GynTree:' are ignored. + - If no GynTree comment is found, 'No description available' is returned. + - For unsupported file types, 'Unsupported file type' is returned. + - For empty files, 'File found empty' is returned. + + The parser supports various file types including Python, JavaScript, C++, HTML, and others. + """ def __init__(self, file_reader: FileReader, comment_syntax: CommentSyntax): self.file_reader = file_reader self.comment_syntax = comment_syntax - self.gyntree_pattern = re.compile(r'gyntree:(.*)', re.IGNORECASE | re.DOTALL) + self.gyntree_pattern = re.compile(r'(?i)gyntree:', re.IGNORECASE) def get_file_purpose(self, filepath: str) -> str: file_extension = os.path.splitext(filepath)[1].lower() @@ -60,32 +78,96 @@ def get_file_purpose(self, filepath: str) -> str: content = self.file_reader.read_file(filepath, 5000) if not content: - return "File not found or empty" + return "File found empty" - description = self._extract_comment(content, syntax) + description = self._extract_comment(content, syntax, file_extension) return description if description else "No description available" - def _extract_comment(self, content: str, syntax: dict) -> Optional[str]: - # Check for multi-line comments first + def _extract_comment(self, content: str, syntax: Dict[str, Optional[Tuple[str, str]]], file_extension: str) -> Optional[str]: + lines = content.splitlines() + if syntax['multi']: - start_delim, end_delim = map(re.escape, syntax['multi']) - pattern = rf'{start_delim}(.*?){end_delim}' - for match in re.finditer(pattern, content, re.DOTALL): - description = self._parse_comment_content(match.group(1)) - if description: - return description - - # Then check for single-line comments + multi_comment = self._extract_multi_line_comment(lines, syntax['multi'], file_extension) + if multi_comment: + return multi_comment + if syntax['single']: - for line in content.splitlines(): - stripped_line = line.strip() - if stripped_line.startswith(syntax['single']): - description = self._parse_comment_content(stripped_line[len(syntax['single']):].strip()) - if description: - return description + single_comment = self._extract_single_line_comment(lines, syntax['single']) + if single_comment: + return single_comment + + return None + + def _extract_multi_line_comment(self, lines: List[str], delimiters: Tuple[str, str], file_extension: str) -> Optional[str]: + start_delim, end_delim = delimiters + in_comment = False + comment_lines = [] + gyntree_found = False + for line in lines: + if not in_comment and start_delim in line: + in_comment = True + start_index = line.index(start_delim) + len(start_delim) + line = line[start_index:] + + if in_comment: + if not gyntree_found: + match = self.gyntree_pattern.search(line) + if match: + gyntree_found = True + line = line[match.end():] + comment_lines = [] + + if gyntree_found: + if end_delim in line: + end_index = line.index(end_delim) + comment_lines.append(line[:end_index]) + break + comment_lines.append(line) + + if not in_comment and self.gyntree_pattern.search(line): + return self._parse_comment_content(line) + + return self._clean_multi_line_comment(comment_lines, file_extension) if comment_lines else None + + def _extract_single_line_comment(self, lines: List[str], delimiter: str) -> Optional[str]: + for line in lines: + if line.strip().startswith(delimiter) and self.gyntree_pattern.search(line): + return self._parse_comment_content(line) return None - def _parse_comment_content(self, comment_content: str) -> Optional[str]: + def _parse_comment_content(self, comment_content: str) -> str: match = self.gyntree_pattern.search(comment_content) - return match.group(1).strip() if match else None \ No newline at end of file + if match: + return comment_content[match.end():].strip() + return comment_content.strip() + + def _clean_multi_line_comment(self, comment_lines: List[str], file_extension: str) -> str: + while comment_lines and not comment_lines[0].strip(): + comment_lines.pop(0) + while comment_lines and not comment_lines[-1].strip(): + comment_lines.pop() + + if not comment_lines: + return "" + + if file_extension == '.py': + return self._clean_python_docstring(comment_lines) + + min_indent = min(len(line) - len(line.lstrip()) for line in comment_lines if line.strip()) + cleaned_lines = [line[min_indent:] for line in comment_lines] + cleaned_lines = [line.lstrip('* ').rstrip() for line in cleaned_lines] + return '\n'.join(cleaned_lines).strip() + + def _clean_python_docstring(self, comment_lines: List[str]) -> str: + if len(comment_lines) == 1: + return comment_lines[0].strip() + + min_indent = min(len(line) - len(line.lstrip()) for line in comment_lines[1:] if line.strip()) + + cleaned_lines = [comment_lines[0].strip()] + [ + line[min_indent:] if line.strip() else '' + for line in comment_lines[1:] + ] + + return '\n'.join(cleaned_lines).rstrip() \ No newline at end of file diff --git a/src/services/DirectoryAnalyzer.py b/src/services/DirectoryAnalyzer.py index 13e6f68..55b62a5 100644 --- a/src/services/DirectoryAnalyzer.py +++ b/src/services/DirectoryAnalyzer.py @@ -14,20 +14,20 @@ def __init__(self, start_dir: str, settings_manager): def analyze_directory(self) -> Dict[str, Any]: """ - Analyze the directory and return a hierarchical structure. + Analyze directory and return hierarchical structure. """ logger.debug(f"Analyzing directory hierarchy for: {self.start_dir}") return self.directory_structure_service.get_hierarchical_structure(self.start_dir, self._stop_event) def get_flat_structure(self) -> Dict[str, Any]: """ - Get a flat structure of the directory. + Get flat structure of directory. """ logger.debug(f"Generating flat directory structure for: {self.start_dir}") return self.directory_structure_service.get_flat_structure(self.start_dir, self._stop_event) def stop(self): """ - Signal the analysis to stop. + Signal analysis to stop. """ - self._stop_event.set() + self._stop_event.set() \ No newline at end of file diff --git a/src/services/DirectoryStructureService.py b/src/services/DirectoryStructureService.py index 60fbad0..230a6f5 100644 --- a/src/services/DirectoryStructureService.py +++ b/src/services/DirectoryStructureService.py @@ -19,65 +19,67 @@ def get_hierarchical_structure(self, start_dir: str, stop_event: threading.Event def get_flat_structure(self, start_dir: str, stop_event: threading.Event) -> List[Dict[str, Any]]: logger.debug(f"Generating flat structure for: {start_dir}") flat_structure = [] - for root, dirs, files in os.walk(start_dir): + for root, dirs, files in self._walk_directory(start_dir, stop_event): if stop_event.is_set(): logger.debug("Directory analysis stopped.") return flat_structure - - dirs[:] = [d for d in dirs if not self.settings_manager.is_excluded(os.path.join(root, d))] - - if self.settings_manager.is_excluded(root): - continue - + for file in files: full_path = os.path.join(root, file) - if self.settings_manager.is_excluded(full_path): - logger.debug(f"Excluding {full_path}") - continue - flat_structure.append({ - 'path': full_path, - 'type': 'File', - 'description': self.comment_parser.get_file_purpose(full_path) - }) + if not self.settings_manager.is_excluded(full_path): + flat_structure.append({ + 'path': full_path, + 'type': 'file', + 'description': self.comment_parser.get_file_purpose(full_path) + }) return flat_structure def _analyze_recursive(self, current_dir: str, stop_event: threading.Event) -> Dict[str, Any]: if stop_event.is_set(): logger.debug("Directory analysis stopped.") return {} + if self.settings_manager.is_excluded(current_dir): logger.debug(f"Skipping excluded directory: {current_dir}") return {} + structure = { 'name': os.path.basename(current_dir), - 'type': 'Directory', + 'type': 'directory', 'path': current_dir, 'children': [] } + try: for item in os.listdir(current_dir): if stop_event.is_set(): logger.debug("Directory analysis stopped.") return structure + full_path = os.path.join(current_dir, item) - - if self.settings_manager.is_excluded(full_path): - logger.debug(f"Skipping excluded path: {full_path}") - continue - if os.path.isdir(full_path): - child_structure = self._analyze_recursive(full_path, stop_event) - if child_structure: - structure['children'].append(child_structure) - else: - file_info = { - 'name': item, - 'type': 'File', - 'path': full_path, - 'description': self.comment_parser.get_file_purpose(full_path) - } - structure['children'].append(file_info) + if not self.settings_manager.is_excluded(full_path): + if os.path.isdir(full_path): + child_structure = self._analyze_recursive(full_path, stop_event) + if child_structure: + structure['children'].append(child_structure) + else: + file_info = { + 'name': item, + 'type': 'file', + 'path': full_path, + 'description': self.comment_parser.get_file_purpose(full_path) + } + structure['children'].append(file_info) except PermissionError as e: logger.warning(f"Permission denied: {current_dir} - {e}") except Exception as e: logger.error(f"Error analyzing {current_dir}: {e}") - return structure \ No newline at end of file + + return structure + + def _walk_directory(self, start_dir: str, stop_event: threading.Event): + for root, dirs, files in os.walk(start_dir): + if stop_event.is_set(): + return + dirs[:] = [d for d in dirs if not self.settings_manager.is_excluded(os.path.join(root, d))] + yield root, dirs, files \ No newline at end of file diff --git a/src/services/ProjectContext.py b/src/services/ProjectContext.py index 1c70e6d..54f99d4 100644 --- a/src/services/ProjectContext.py +++ b/src/services/ProjectContext.py @@ -1,10 +1,12 @@ import logging +import traceback from models.Project import Project from services.SettingsManager import SettingsManager from services.DirectoryAnalyzer import DirectoryAnalyzer from services.auto_exclude.AutoExcludeManager import AutoExcludeManager from services.RootExclusionManager import RootExclusionManager from services.ProjectTypeDetector import ProjectTypeDetector +from utilities.error_handler import handle_exception logger = logging.getLogger(__name__) @@ -20,6 +22,7 @@ def __init__(self, project: Project): self.project_type_detector = None self.initialize() + @handle_exception def initialize(self): try: self.settings_manager = SettingsManager(self.project) @@ -28,6 +31,7 @@ def initialize(self): self.initialize_root_exclusions() self.initialize_auto_exclude_manager() self.initialize_directory_analyzer() + self.settings_manager.save_settings() except Exception as e: logger.error(f"Failed to initialize ProjectContext: {str(e)}") raise @@ -51,13 +55,14 @@ def initialize_root_exclusions(self): def initialize_auto_exclude_manager(self): try: - self.auto_exclude_manager = AutoExcludeManager( - self.project.start_directory, - self.settings_manager, - self.project_types, - self.project_type_detector - ) - logger.debug("Initialized AutoExcludeManager") + if not self.auto_exclude_manager: + self.auto_exclude_manager = AutoExcludeManager( + self.project.start_directory, + self.settings_manager, + self.project_types, + self.project_type_detector + ) + logger.debug("Initialized AutoExcludeManager") except Exception as e: logger.error(f"Failed to initialize AutoExcludeManager: {str(e)}") self.auto_exclude_manager = None @@ -69,6 +74,7 @@ def initialize_directory_analyzer(self): ) logger.debug("Initialized DirectoryAnalyzer") + @handle_exception def stop_analysis(self): if self.directory_analyzer: self.directory_analyzer.stop() @@ -76,45 +82,62 @@ def stop_analysis(self): def reinitialize_directory_analyzer(self): self.initialize_directory_analyzer() + @handle_exception def trigger_auto_exclude(self) -> str: if not self.auto_exclude_manager: logger.warning("AutoExcludeManager not initialized. Attempting to reinitialize.") self.initialize_auto_exclude_manager() if not self.auto_exclude_manager: - logger.error("Failed to reinitialize AutoExcludeManager. Cannot perform auto-exclude.") - return "" + logger.error("Failed to initialize AutoExcludeManager. Cannot perform auto-exclude.") + return "Auto-exclude manager initialization failed." if not self.settings_manager: logger.error("SettingsManager not initialized. Cannot perform auto-exclude.") - return "" + return "Settings manager missing." - new_recommendations = self.auto_exclude_manager.get_recommendations() - return self.auto_exclude_manager.get_formatted_recommendations() + try: + new_recommendations = self.auto_exclude_manager.get_recommendations() + return self.auto_exclude_manager.get_formatted_recommendations() + except Exception as e: + logger.error(f"Failed to trigger auto-exclude: {str(e)}") + return "Error during auto-exclude process." def get_directory_tree(self): return self.directory_analyzer.analyze_directory() def save_settings(self): - self.settings_manager.save_settings() + if self.settings_manager: + self.settings_manager.save_settings() + def get_theme_preference(self) -> str: + return self.settings_manager.get_theme_preference() if self.settings_manager else 'light' + + def set_theme_preference(self, theme: str): + if self.settings_manager: + self.settings_manager.set_theme_preference(theme) + self.save_settings() + + @property + def is_initialized(self) -> bool: + return self.settings_manager is not None and self.directory_analyzer is not None + + @handle_exception def close(self): logger.debug(f"Closing project context for project: {self.project.name}") self.stop_analysis() - if self.settings_manager: self.settings_manager.save_settings() self.settings_manager = None - if self.directory_analyzer: self.directory_analyzer.stop() self.directory_analyzer = None - if self.auto_exclude_manager: self.auto_exclude_manager = None - self.project_types.clear() self.detected_types.clear() self.project_type_detector = None - - logger.debug(f"Project context closed for project: {self.project.name}") \ No newline at end of file + logger.debug(f"Project context closed for project: {self.project.name}") + + def __del__(self): + self.close() \ No newline at end of file diff --git a/src/services/ProjectManager.py b/src/services/ProjectManager.py index 6eba9f8..1a9c880 100644 --- a/src/services/ProjectManager.py +++ b/src/services/ProjectManager.py @@ -34,7 +34,7 @@ def list_projects(self): projects = [] for filename in os.listdir(self.projects_dir): if filename.endswith('.json'): - project_name = filename[:-5] # Remove .json extension + project_name = filename[:-5] projects.append(project_name) return projects diff --git a/src/services/ProjectTypeDetector.py b/src/services/ProjectTypeDetector.py index 769b874..b4d39c7 100644 --- a/src/services/ProjectTypeDetector.py +++ b/src/services/ProjectTypeDetector.py @@ -19,7 +19,8 @@ def detect_javascript_project(self) -> bool: js_files = ['.js', '.ts', '.jsx', '.tsx'] js_config_files = ['package.json', 'tsconfig.json', '.eslintrc.js', '.eslintrc.json'] return any( - any(file.endswith(ext) for ext in js_files) or file in js_config_files + any(file.endswith(ext) for ext in js_files) or + file in js_config_files for file in os.listdir(self.start_directory) ) diff --git a/src/services/SettingsManager.py b/src/services/SettingsManager.py index 0034699..8cd4f9f 100644 --- a/src/services/SettingsManager.py +++ b/src/services/SettingsManager.py @@ -25,7 +25,8 @@ def load_settings(self) -> Dict[str, List[str]]: default_settings = { 'root_exclusions': self.project.root_exclusions or [], 'excluded_dirs': self.project.excluded_dirs or [], - 'excluded_files': self.project.excluded_files or [] + 'excluded_files': self.project.excluded_files or [], + 'theme_preference': 'light' } for key, value in default_settings.items(): @@ -33,6 +34,13 @@ def load_settings(self) -> Dict[str, List[str]]: settings[key] = value return settings + + def get_theme_preference(self) -> str: + return self.settings.get('theme_preference', 'light') + + def set_theme_preference(self, theme: str): + self.settings['theme_preference'] = theme + self.save_settings() def get_root_exclusions(self) -> List[str]: return [os.path.normpath(d) for d in self.settings.get('root_exclusions', [])] @@ -51,13 +59,16 @@ def get_all_exclusions(self) -> Dict[str, Set[str]]: } def update_settings(self, new_settings: Dict[str, List[str]]): - self.settings.update(new_settings) + for key, value in new_settings.items(): + if key in self.settings: + self.settings[key] = value self.save_settings() def save_settings(self): os.makedirs(os.path.dirname(self.config_path), exist_ok=True) with open(self.config_path, 'w') as file: json.dump(self.settings, file, indent=4) + logger.debug(f"Settings saved to {self.config_path}") def is_excluded(self, path: str) -> bool: return (self.is_root_excluded(path) or @@ -67,7 +78,6 @@ def is_excluded(self, path: str) -> bool: def is_root_excluded(self, path: str) -> bool: relative_path = self._get_relative_path(path) path_parts = relative_path.split(os.sep) - for excluded in self.get_root_exclusions(): if '**' in excluded: if fnmatch.fnmatch(relative_path, excluded): diff --git a/src/services/auto_exclude/AutoExcludeManager.py b/src/services/auto_exclude/AutoExcludeManager.py index 7ea9b7e..317636e 100644 --- a/src/services/auto_exclude/AutoExcludeManager.py +++ b/src/services/auto_exclude/AutoExcludeManager.py @@ -14,23 +14,18 @@ def __init__(self, start_directory: str, settings_manager: SettingsManager, proj self.settings_manager = settings_manager self.project_types = project_types self.exclusion_services: List[ExclusionService] = ExclusionServiceFactory.create_services( - project_types, - self.start_directory, - project_type_detector, - settings_manager + project_types, self.start_directory, project_type_detector, settings_manager ) logger.debug(f"Created exclusion services: {[type(service).__name__ for service in self.exclusion_services]}") self.raw_recommendations: Dict[str, Set[str]] = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} def get_recommendations(self) -> Dict[str, Set[str]]: self.raw_recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - for service in self.exclusion_services: service_exclusions = service.get_exclusions() for category in ['root_exclusions', 'excluded_dirs', 'excluded_files']: self.raw_recommendations[category].update(service_exclusions.get(category, set())) - # Filter out already excluded items for category in ['root_exclusions', 'excluded_dirs', 'excluded_files']: self.raw_recommendations[category] = { path for path in self.raw_recommendations[category] diff --git a/src/services/auto_exclude/IDEandGitAutoExclude.py b/src/services/auto_exclude/IDEandGitAutoExclude.py index 68f4f82..e300b2c 100644 --- a/src/services/auto_exclude/IDEandGitAutoExclude.py +++ b/src/services/auto_exclude/IDEandGitAutoExclude.py @@ -14,12 +14,10 @@ def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetec def get_exclusions(self) -> Dict[str, Set[str]]: recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - # Common root exclusions common_root_exclusions = {'.git', '.vs', '.idea', '.vscode'} recommendations['root_exclusions'].update(common_root_exclusions) logger.debug(f"IDEandGitAutoExclude: Adding common root exclusions: {common_root_exclusions}") - # File exclusions common_file_exclusions = { '.gitignore', '.vsignore', '.dockerignore', '.gitattributes', 'Thumbs.db', '.DS_Store', '*.swp', '*~', diff --git a/src/styles/dark_theme.qss b/src/styles/dark_theme.qss new file mode 100644 index 0000000..c8dec75 --- /dev/null +++ b/src/styles/dark_theme.qss @@ -0,0 +1,118 @@ +QMainWindow, QWidget { + background-color: #2b2b2b; + color: #f0f0f0; +} + +QPushButton { + background-color: #4CAF50; + color: white; + border: none; + padding: 8px 16px; + text-align: center; + text-decoration: none; + font-size: 14px; + margin: 4px 2px; + border-radius: 4px; +} + +QPushButton:hover { + background-color: #45a049; +} + +QTreeWidget, QTableWidget { + background-color: #363636; + alternate-background-color: #2f2f2f; + border: 1px solid #555; + color: #f0f0f0; +} + +QHeaderView::section { + background-color: #404040; + padding: 4px; + border: 1px solid #555; + color: #f0f0f0; +} + +QLabel { + color: #f0f0f0; +} + +QLineEdit { + background-color: #363636; + border: 1px solid #555; + padding: 4px; + border-radius: 4px; + color: #f0f0f0; +} + +QGroupBox { + border: 1px solid #555; + border-radius: 4px; + margin-top: 1ex; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 3px 0 3px; + color: #f0f0f0; +} + +QStatusBar { + background-color: #404040; + color: #f0f0f0; +} + +QScrollBar:vertical { + border: none; + background: #2b2b2b; + width: 10px; + margin: 0px 0px 0px 0px; +} + +QScrollBar::handle:vertical { + background: #606060; + min-height: 20px; + border-radius: 5px; +} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} + +QScrollBar:horizontal { + border: none; + background: #2b2b2b; + height: 10px; + margin: 0px 0px 0px 0px; +} + +QScrollBar::handle:horizontal { + background: #606060; + min-width: 20px; + border-radius: 5px; +} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; +} + +QMenu { + background-color: #363636; + border: 1px solid #555; +} + +QMenu::item { + padding: 5px 20px 5px 20px; + color: #f0f0f0; +} + +QMenu::item:selected { + background-color: #404040; +} + +QToolTip { + background-color: #363636; + color: #f0f0f0; + border: 1px solid #555; +} \ No newline at end of file diff --git a/src/styles/light_theme.qss b/src/styles/light_theme.qss new file mode 100644 index 0000000..2b3bd83 --- /dev/null +++ b/src/styles/light_theme.qss @@ -0,0 +1,113 @@ +QMainWindow, QWidget { + background-color: #f0f0f0; + color: #333333; +} + +QPushButton { + background-color: #4CAF50; + color: white; + border: none; + padding: 8px 16px; + text-align: center; + text-decoration: none; + font-size: 14px; + margin: 4px 2px; + border-radius: 4px; +} + +QPushButton:hover { + background-color: #45a049; +} + +QTreeWidget, QTableWidget { + background-color: white; + alternate-background-color: #f9f9f9; + border: 1px solid #ddd; +} + +QHeaderView::section { + background-color: #e0e0e0; + padding: 4px; + border: 1px solid #ddd; +} + +QLabel { + color: #333333; +} + +QLineEdit { + background-color: white; + border: 1px solid #ccc; + padding: 4px; + border-radius: 4px; +} + +QGroupBox { + border: 1px solid #ccc; + border-radius: 4px; + margin-top: 1ex; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 3px 0 3px; +} + +QStatusBar { + background-color: #e0e0e0; + color: #333333; +} + +QScrollBar:vertical { + border: none; + background: #f0f0f0; + width: 10px; + margin: 0px 0px 0px 0px; +} + +QScrollBar::handle:vertical { + background: #c1c1c1; + min-height: 20px; + border-radius: 5px; +} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} + +QScrollBar:horizontal { + border: none; + background: #f0f0f0; + height: 10px; + margin: 0px 0px 0px 0px; +} + +QScrollBar::handle:horizontal { + background: #c1c1c1; + min-width: 20px; + border-radius: 5px; +} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; +} + +QMenu { + background-color: white; + border: 1px solid #ddd; +} + +QMenu::item { + padding: 5px 20px 5px 20px; +} + +QMenu::item:selected { + background-color: #e0e0e0; +} + +QToolTip { + background-color: #f0f0f0; + color: #333333; + border: 1px solid #ccc; +} \ No newline at end of file diff --git a/src/utilities/error_handler.py b/src/utilities/error_handler.py index 3cb177d..cd84feb 100644 --- a/src/utilities/error_handler.py +++ b/src/utilities/error_handler.py @@ -14,19 +14,19 @@ def __init__(self): super().__init__() self.error_occurred.connect(self.show_error_dialog) - def global_exception_handler(self, exc_type, exc_value, exc_traceback): + @classmethod + def global_exception_handler(cls, exc_type, exc_value, exc_traceback): """Handle uncaught exceptions globally""" error_msg = f"An unexpected error occurred:\n{exc_type.__name__}: {exc_value}" detailed_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) - logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) - - self.error_occurred.emit("Critical Error", error_msg) - + handler = cls() + handler.error_occurred.emit("Critical Error", error_msg) logger.debug(f"Detailed error traceback:\n{detailed_msg}") - def show_error_dialog(self, title, message): - """Show an error dialog with the given title and message""" + @staticmethod + def show_error_dialog(title, message): + """Show error dialog with the given title and message""" QTimer.singleShot(0, lambda: QMessageBox.critical(None, title, message)) def handle_exception(func): @@ -42,5 +42,4 @@ def wrapper(*args, **kwargs): return wrapper error_handler = ErrorHandler() - -sys.excepthook = error_handler.global_exception_handler \ No newline at end of file +sys.excepthook = ErrorHandler.global_exception_handler \ No newline at end of file diff --git a/src/utilities/resource_path.py b/src/utilities/resource_path.py index b1c3400..08649ad 100644 --- a/src/utilities/resource_path.py +++ b/src/utilities/resource_path.py @@ -4,7 +4,6 @@ def get_resource_path(relative_path): """ Get absolute path to resource, works for dev and for PyInstaller """ try: - # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") diff --git a/src/utilities/theme_manager.py b/src/utilities/theme_manager.py new file mode 100644 index 0000000..91d1bbb --- /dev/null +++ b/src/utilities/theme_manager.py @@ -0,0 +1,51 @@ +import os +from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtWidgets import QWidget +from utilities.resource_path import get_resource_path + +class ThemeManager(QObject): + themeChanged = pyqtSignal(str) + _instance = None + + @staticmethod + def getInstance(): + if ThemeManager._instance is None: + ThemeManager._instance = ThemeManager() + return ThemeManager._instance + + def __init__(self): + super().__init__() + self.current_theme = 'light' + self.light_theme = self.load_stylesheet('light') + self.dark_theme = self.load_stylesheet('dark') + + def load_stylesheet(self, theme_name): + stylesheet_path = get_resource_path(f'styles/{theme_name}_theme.qss') + if not os.path.exists(stylesheet_path): + raise FileNotFoundError(f"Stylesheet {stylesheet_path} not found") + with open(stylesheet_path, 'r') as f: + return f.read() + + def get_current_theme(self): + return self.current_theme + + def set_theme(self, theme): + if theme not in ['light', 'dark']: + raise ValueError("Theme must be either 'light' or 'dark'") + self.current_theme = theme + self.themeChanged.emit(self.current_theme) + + def toggle_theme(self): + new_theme = 'dark' if self.current_theme == 'light' else 'light' + self.set_theme(new_theme) + return new_theme + + def apply_theme(self, window: QWidget): + stylesheet = self.light_theme if self.current_theme == 'light' else self.dark_theme + window.setStyleSheet(stylesheet) + window.update() + window.repaint() + + def apply_theme_to_all_windows(self, app): + for window in app.topLevelWidgets(): + self.apply_theme(window) \ No newline at end of file diff --git a/tests/test_auto_exclude_manager.py b/tests/Integration/test_auto_exclude_manager.py similarity index 96% rename from tests/test_auto_exclude_manager.py rename to tests/Integration/test_auto_exclude_manager.py index c3d6496..9360974 100644 --- a/tests/test_auto_exclude_manager.py +++ b/tests/Integration/test_auto_exclude_manager.py @@ -4,6 +4,8 @@ from services.ProjectTypeDetector import ProjectTypeDetector from models.Project import Project +pytestmark = pytest.mark.integration + @pytest.fixture def mock_project(tmpdir): return Project( @@ -27,7 +29,7 @@ def auto_exclude_manager(mock_project, settings_manager, project_type_detector): return AutoExcludeManager(mock_project.start_directory, settings_manager, set(), project_type_detector) def test_initialization(auto_exclude_manager): - assert auto_exclude_manager.start_directory + assert auto_exclude_manager.start_directory is not None assert isinstance(auto_exclude_manager.settings_manager, SettingsManager) assert isinstance(auto_exclude_manager.project_types, set) assert len(auto_exclude_manager.exclusion_services) > 0 @@ -66,7 +68,7 @@ def test_exclusion_services_creation(tmpdir, settings_manager, project_type_dete project_types = {ptype for ptype, detected in detected_types.items() if detected} auto_exclude_manager = AutoExcludeManager(str(tmpdir), settings_manager, project_types, project_type_detector) service_names = [service.__class__.__name__ for service in auto_exclude_manager.exclusion_services] - assert 'IDEandGitAutoExclude' in service_names + assert 'IDEAndGitAutoExclude' in service_names assert 'PythonAutoExclude' in service_names assert 'WebAutoExclude' in service_names diff --git a/tests/Integration/test_directory_analyzer.py b/tests/Integration/test_directory_analyzer.py new file mode 100644 index 0000000..377d657 --- /dev/null +++ b/tests/Integration/test_directory_analyzer.py @@ -0,0 +1,223 @@ +import os +import pytest +import threading +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.SettingsManager import SettingsManager +from models.Project import Project + +pytestmark = pytest.mark.integration + +@pytest.fixture +def mock_project(tmpdir): + return Project( + name="test_project", + start_directory=str(tmpdir), + root_exclusions=[], + excluded_dirs=[], + excluded_files=[] + ) + +@pytest.fixture +def settings_manager(mock_project): + return SettingsManager(mock_project) + +@pytest.fixture +def analyzer(mock_project, settings_manager): + return DirectoryAnalyzer(mock_project.start_directory, settings_manager) + +def test_directory_analysis(tmpdir, analyzer): + test_file = tmpdir.join("test_file.py") + test_file.write("# GynTree: Test purpose.") + result = analyzer.analyze_directory() + + def find_file(structure, target_path): + if structure['type'] == 'file' and structure['path'] == str(test_file): + return structure + elif 'children' in structure: + for child in structure['children']: + found = find_file(child, target_path) + if found: + return found + return None + + file_info = find_file(result, str(test_file)) + assert file_info is not None + assert file_info['description'] == "This is a Test purpose." + +def test_excluded_directory(tmpdir, mock_project, settings_manager): + excluded_dir = tmpdir.mkdir("excluded") + excluded_file = excluded_dir.join("excluded_file.py") + excluded_file.write("# Not analyzed") + mock_project.excluded_dirs = [str(excluded_dir)] + settings_manager.update_settings({'excluded_dirs': [str(excluded_dir)]}) + analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) + result = analyzer.analyze_directory() + assert str(excluded_file) not in str(result) + +def test_excluded_file(tmpdir, mock_project, settings_manager): + test_file = tmpdir.join("excluded_file.py") + test_file.write("# Not analyzed") + mock_project.excluded_files = [str(test_file)] + settings_manager.update_settings({'excluded_files': [str(test_file)]}) + analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) + result = analyzer.analyze_directory() + assert str(test_file) not in str(result) + +def test_nested_directory_analysis(tmpdir, analyzer): + nested_dir = tmpdir.mkdir("nested") + nested_file = nested_dir.join("nested_file.py") + nested_file.write("# GynTree: Nested file") + result = analyzer.analyze_directory() + assert str(nested_file) in str(result) + assert result[str(nested_file)]['description'] == "This is a Nested file" + +def test_get_flat_structure(tmpdir, analyzer): + tmpdir.join("file1.py").write("# GynTree: File 1") + tmpdir.join("file2.py").write("# GynTree: File 2") + flat_structure = analyzer.get_flat_structure() + assert len(flat_structure) == 2 + assert any(item['path'].endswith('file1.py') for item in flat_structure) + assert any(item['path'].endswith('file2.py') for item in flat_structure) + +def test_empty_directory(tmpdir, analyzer): + result = analyzer.analyze_directory() + assert result['children'] == [] + +def test_large_directory_structure(tmpdir, analyzer): + for i in range(1000): + tmpdir.join(f"file_{i}.py").write(f"# GynTree: File {i}") + result = analyzer.analyze_directory() + assert len(result['children']) == 1000 + +def test_stop_analysis(tmpdir, analyzer): + for i in range(1000): + tmpdir.join(f"file_{i}.py").write(f"# GynTree: File {i}") + + def stop_analysis(): + analyzer.stop() + + timer = threading.Timer(0.1, stop_analysis) + timer.start() + result = analyzer.analyze_directory() + assert len(result['children']) < 1000 + +def test_root_exclusions(tmpdir, mock_project, settings_manager): + root_dir = tmpdir.mkdir("root_excluded") + root_file = root_dir.join("root_file.py") + root_file.write("# Not analyzed") + mock_project.root_exclusions = [str(root_dir)] + settings_manager.update_settings({'root_exclusions': [str(root_dir)]}) + analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) + result = analyzer.analyze_directory() + assert str(root_file) not in str(result) + +def test_symlink_handling(tmpdir, analyzer): + real_dir = tmpdir.mkdir("real_dir") + real_dir.join("real_file.py").write("# GynTree: Real file") + symlink_dir = tmpdir.join("symlink_dir") + target = str(real_dir) + link_name = str(symlink_dir) + + if hasattr(os, 'symlink'): + try: + os.symlink(target, link_name) + except (OSError, NotImplementedError, AttributeError): + pytest.skip("Symlink not supported on this platform or insufficient permissions") + else: + pytest.skip("Symlink not supported on this platform") + + result = analyzer.analyze_directory() + assert any('real_file.py' in path for path in result.keys()) + assert len([path for path in result.keys() if 'real_file.py' in path]) == 1 + +@pytest.mark.slow +def test_large_nested_directory_structure(tmpdir, analyzer): + def create_nested_structure(directory, depth, files_per_dir): + if depth == 0: + return + for i in range(files_per_dir): + directory.join(f"file_{i}.py").write(f"# GynTree: File {i} at depth {depth}") + for i in range(3): + subdir = directory.mkdir(f"subdir_{i}") + create_nested_structure(subdir, depth - 1, files_per_dir) + + create_nested_structure(tmpdir, depth=5, files_per_dir=10) + result = analyzer.analyze_directory() + + def count_files(structure): + count = len([child for child in structure['children'] if child['type'] == 'file']) + for child in structure['children']: + if child['type'] == 'directory': + count += count_files(child) + return count + + total_files = count_files(result) + expected_files = 10 * (1 + 3 + 9 + 27 + 81) # Sum of 10 * (3^0 + 3^1 + 3^2 + 3^3 + 3^4) + assert total_files == expected_files + +def test_file_type_detection(tmpdir, analyzer): + tmpdir.join("python_file.py").write("# GynTree: Python file") + tmpdir.join("javascript_file.js").write("// GynTree: JavaScript file") + tmpdir.join("html_file.html").write("") + tmpdir.join("css_file.css").write("/* GynTree: CSS file */") + + result = analyzer.analyze_directory() + + file_types = {child['name']: child['type'] for child in result['children']} + assert file_types['python_file.py'] == 'file' + assert file_types['javascript_file.js'] == 'file' + assert file_types['html_file.html'] == 'file' + assert file_types['css_file.css'] == 'file' + +def test_directory_permissions(tmpdir, analyzer): + restricted_dir = tmpdir.mkdir("restricted") + restricted_dir.join("secret.txt").write("Top secret") + os.chmod(str(restricted_dir), 0o000) # Remove all permissions + + try: + result = analyzer.analyze_directory() + assert "restricted" in [child['name'] for child in result['children']] + assert len([child for child in result['children'] if child['name'] == "restricted"][0]['children']) == 0 + finally: + os.chmod(str(restricted_dir), 0o755) # Restore permissions for cleanup + +def test_unicode_filenames(tmpdir, analyzer): + unicode_filename = "üñÃçödé_file.py" + tmpdir.join(unicode_filename).write("# GynTree: Unicode filename test") + + result = analyzer.analyze_directory() + assert unicode_filename in [child['name'] for child in result['children']] + +def test_empty_files(tmpdir, analyzer): + tmpdir.join("empty_file.py").write("") + result = analyzer.analyze_directory() + empty_file = [child for child in result['children'] if child['name'] == "empty_file.py"][0] + assert empty_file['description'] == "No description available" + +def test_non_utf8_files(tmpdir, analyzer): + non_utf8_file = tmpdir.join("non_utf8.txt") + with open(str(non_utf8_file), 'wb') as f: + f.write(b'\xff\xfe' + "Some non-UTF8 content".encode('utf-16le')) + + result = analyzer.analyze_directory() + assert "non_utf8.txt" in [child['name'] for child in result['children']] + +def test_very_long_filenames(tmpdir, analyzer): + long_filename = "a" * 255 + ".py" + tmpdir.join(long_filename).write("# GynTree: Very long filename test") + + result = analyzer.analyze_directory() + assert long_filename in [child['name'] for child in result['children']] + +@pytest.mark.slow +def test_performance_large_codebase(tmpdir, analyzer): + for i in range(10000): # Create 10,000 files + tmpdir.join(f"file_{i}.py").write(f"# GynTree: File {i}\n" * 100) # Each file has 100 lines + + import time + start_time = time.time() + result = analyzer.analyze_directory() + end_time = time.time() + + assert len(result['children']) == 10000 + assert end_time - start_time < 60 # Ensure analysis completes in less than 60 seconds \ No newline at end of file diff --git a/tests/Integration/test_project_context.py b/tests/Integration/test_project_context.py new file mode 100644 index 0000000..8d6478a --- /dev/null +++ b/tests/Integration/test_project_context.py @@ -0,0 +1,201 @@ +import json +import pytest +from services.ProjectContext import ProjectContext +from models.Project import Project +from services.SettingsManager import SettingsManager +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.auto_exclude.AutoExcludeManager import AutoExcludeManager +from services.RootExclusionManager import RootExclusionManager +from services.ProjectTypeDetector import ProjectTypeDetector +from utilities.theme_manager import ThemeManager + +pytestmark = pytest.mark.integration + +@pytest.fixture +def mock_project(tmpdir): + return Project( + name="test_project", + start_directory=str(tmpdir), + root_exclusions=[], + excluded_dirs=[], + excluded_files=[] + ) + +@pytest.fixture +def project_context(mock_project): + return ProjectContext(mock_project) + +def test_initialization(project_context): + assert project_context.project is not None + assert isinstance(project_context.settings_manager, SettingsManager) + assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) + assert isinstance(project_context.auto_exclude_manager, AutoExcludeManager) + assert isinstance(project_context.root_exclusion_manager, RootExclusionManager) + assert isinstance(project_context.project_type_detector, ProjectTypeDetector) + +def test_detect_project_types(project_context, tmpdir): + tmpdir.join("main.py").write("print('Hello, World!')") + project_context.detect_project_types() + assert 'python' in project_context.project_types + +def test_initialize_root_exclusions(project_context): + initial_exclusions = set(project_context.settings_manager.get_root_exclusions()) + project_context.initialize_root_exclusions() + updated_exclusions = set(project_context.settings_manager.get_root_exclusions()) + assert updated_exclusions >= initial_exclusions + +def test_trigger_auto_exclude(project_context): + result = project_context.trigger_auto_exclude() + assert isinstance(result, str) + assert len(result) > 0 + +def test_get_directory_tree(project_context, tmpdir): + tmpdir.join("test_file.py").write("# Test content") + tree = project_context.get_directory_tree() + assert isinstance(tree, dict) + assert "test_file.py" in str(tree) + +def test_save_settings(project_context): + project_context.settings_manager.add_excluded_dir("test_dir") + project_context.save_settings() + reloaded_context = ProjectContext(project_context.project) + assert "test_dir" in reloaded_context.settings_manager.get_excluded_dirs() + +def test_close(project_context): + project_context.close() + assert project_context.settings_manager is None + assert project_context.directory_analyzer is None + assert project_context.auto_exclude_manager is None + assert len(project_context.project_types) == 0 + assert project_context.project_type_detector is None + +def test_reinitialize_directory_analyzer(project_context): + original_analyzer = project_context.directory_analyzer + project_context.reinitialize_directory_analyzer() + assert project_context.directory_analyzer is not original_analyzer + assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) + +def test_stop_analysis(project_context, mocker): + mock_stop = mocker.patch.object(project_context.directory_analyzer, 'stop') + project_context.stop_analysis() + mock_stop.assert_called_once() + +def test_project_context_with_existing_settings(mock_project, tmpdir): + settings_file = tmpdir.join("config", "projects", f"{mock_project.name}.json") + settings_file.write(json.dumps({ + "root_exclusions": ["existing_root"], + "excluded_dirs": ["existing_dir"], + "excluded_files": ["existing_file"], + "theme_preference": "dark" + }), ensure=True) + context = ProjectContext(mock_project) + assert "existing_root" in context.settings_manager.get_root_exclusions() + assert "existing_dir" in context.settings_manager.get_excluded_dirs() + assert "existing_file" in context.settings_manager.get_excluded_files() + assert context.get_theme_preference() == "dark" + +def test_project_context_error_handling(mock_project, mocker): + mocker.patch('services.settings_manager.SettingsManager.__init__', side_effect=Exception("Test error")) + with pytest.raises(Exception): + ProjectContext(mock_project) + +def test_get_theme_preference(project_context): + assert project_context.get_theme_preference() in ['light', 'dark'] + +def test_set_theme_preference(project_context): + initial_theme = project_context.get_theme_preference() + new_theme = 'dark' if initial_theme == 'light' else 'light' + project_context.set_theme_preference(new_theme) + assert project_context.get_theme_preference() == new_theme + +def test_theme_preference_persistence(project_context): + initial_theme = project_context.get_theme_preference() + new_theme = 'dark' if initial_theme == 'light' else 'light' + project_context.set_theme_preference(new_theme) + project_context.save_settings() + reloaded_context = ProjectContext(project_context.project) + assert reloaded_context.get_theme_preference() == new_theme + +def test_theme_preference_invalid_value(project_context): + with pytest.raises(ValueError): + project_context.set_theme_preference('invalid_theme') + +def test_theme_preference_change_signal(project_context, qtbot): + with qtbot.waitSignal(project_context.theme_changed, timeout=1000) as blocker: + project_context.set_theme_preference('dark' if project_context.get_theme_preference() == 'light' else 'light') + assert blocker.signal_triggered + +def test_is_initialized(project_context): + assert project_context.is_initialized + +def test_not_initialized(mock_project): + incomplete_context = ProjectContext(mock_project) + incomplete_context.settings_manager = None + assert not incomplete_context.is_initialized + +def test_project_context_with_theme_manager(project_context): + assert isinstance(project_context.theme_manager, ThemeManager) + +def test_theme_manager_singleton(project_context): + assert project_context.theme_manager is ThemeManager.get_instance() + +def test_initialize_auto_exclude_manager(project_context): + project_context.initialize_auto_exclude_manager() + assert isinstance(project_context.auto_exclude_manager, AutoExcludeManager) + +def test_initialize_directory_analyzer(project_context): + project_context.initialize_directory_analyzer() + assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) + +def test_detect_project_types_multiple(project_context, tmpdir): + tmpdir.join("main.py").write("print('Hello, World!')") + tmpdir.join("index.html").write("
Hello, World!") + tmpdir.join("package.json").write('{"name": "test-project", "version": "1.0.0"}') + project_context.detect_project_types() + assert 'python' in project_context.project_types + assert 'web' in project_context.project_types + assert 'javascript' in project_context.project_types + +def test_get_all_exclusions(project_context): + exclusions = project_context.settings_manager.get_all_exclusions() + assert 'root_exclusions' in exclusions + assert 'excluded_dirs' in exclusions + assert 'excluded_files' in exclusions + +def test_update_settings(project_context): + new_settings = { + 'root_exclusions': ['new_root'], + 'excluded_dirs': ['new_dir'], + 'excluded_files': ['new_file.txt'] + } + project_context.settings_manager.update_settings(new_settings) + updated_exclusions = project_context.settings_manager.get_all_exclusions() + assert 'new_root' in updated_exclusions['root_exclusions'] + assert 'new_dir' in updated_exclusions['excluded_dirs'] + assert 'new_file.txt' in updated_exclusions['excluded_files'] + +def test_project_context_serialization(project_context): + serialized = project_context.to_dict() + assert 'name' in serialized + assert 'start_directory' in serialized + assert 'root_exclusions' in serialized + assert 'excluded_dirs' in serialized + assert 'excluded_files' in serialized + assert 'theme_preference' in serialized + +def test_project_context_deserialization(mock_project): + data = { + 'name': 'test_project', + 'start_directory': '/test/path', + 'root_exclusions': ['root1', 'root2'], + 'excluded_dirs': ['dir1', 'dir2'], + 'excluded_files': ['file1.txt', 'file2.txt'], + 'theme_preference': 'dark' + } + context = ProjectContext.from_dict(data) + assert context.project.name == 'test_project' + assert context.project.start_directory == '/test/path' + assert set(context.settings_manager.get_root_exclusions()) == set(['root1', 'root2']) + assert set(context.settings_manager.get_excluded_dirs()) == set(['dir1', 'dir2']) + assert set(context.settings_manager.get_excluded_files()) == set(['file1.txt', 'file2.txt']) + assert context.get_theme_preference() == 'dark' \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2960f1d..ca03e98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -# GynTree: This file configures the test environment for pytest. It adds the src directory to the Python path so that the tests can access the main modules. import sys import os import pytest @@ -11,17 +10,18 @@ from services.SettingsManager import SettingsManager from services.ProjectTypeDetector import ProjectTypeDetector from services.ProjectContext import ProjectContext +from utilities.theme_manager import ThemeManager @pytest.fixture(scope="session") def qapp(): - """Create a QApplication instance for the entire test session.""" + """Create QApplication instance for the entire test session.""" app = QApplication([]) yield app app.quit() @pytest.fixture def mock_project(tmpdir): - """Create a mock Project instance for testing.""" + """Create a mock project instance for testing.""" return Project( name="test_project", start_directory=str(tmpdir), @@ -32,20 +32,25 @@ def mock_project(tmpdir): @pytest.fixture def settings_manager(mock_project, tmpdir): - """Create a SettingsManager instance for testing.""" + """Create SettingsManager instance for testing.""" SettingsManager.config_dir = str(tmpdir.mkdir("config")) return SettingsManager(mock_project) @pytest.fixture def project_type_detector(tmpdir): - """Create a ProjectTypeDetector instance for testing.""" + """Create ProjectTypeDetector instance for testing.""" return ProjectTypeDetector(str(tmpdir)) @pytest.fixture def project_context(mock_project): - """Create a ProjectContext instance for testing.""" + """Create ProjectContext instance for testing.""" return ProjectContext(mock_project) +@pytest.fixture +def theme_manager(): + """Create ThemeManager instance for testing.""" + return ThemeManager.get_instance() + @pytest.fixture def setup_python_project(tmpdir): """Set up a basic Python project structure for testing.""" @@ -77,13 +82,13 @@ def create_files(directory, num_files): for i in range(num_files): file_path = os.path.join(directory, f"file_{i}.txt") with open(file_path, 'w') as f: - f.write(f"# gyntree: Test file {i}") + f.write(f"# GynTree: Test file {i}") def create_dirs(root, current_depth): if current_depth > depth: return create_files(root, files_per_dir) - for i in range(5): # Create 5 subdirectories + for i in range(5): subdir = os.path.join(root, f"dir_{i}") os.mkdir(subdir) create_dirs(subdir, current_depth + 1) @@ -94,7 +99,9 @@ def create_dirs(root, current_depth): return _create_large_directory_structure def pytest_configure(config): - """Add custom markers to the pytest configuration.""" + config.addinivalue_line("markers", "unit: marks unit tests") + config.addinivalue_line("markers", "integration: marks integration tests") + config.addinivalue_line("markers", "performance: marks performance tests") + config.addinivalue_line("markers", "functional: marks functional tests") config.addinivalue_line("markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')") - config.addinivalue_line("markers", "gui: marks tests that require a GUI (deselect with '-m \"not gui\"')") - config.addinivalue_line("markers", "memory: marks tests that perform memory profiling") \ No newline at end of file + config.addinivalue_line("markers", "gui: marks tests that require GUI (deselect with '-m \"not gui\"')") \ No newline at end of file diff --git a/tests/functional/test_dashboard_ui.py b/tests/functional/test_dashboard_ui.py new file mode 100644 index 0000000..3611890 --- /dev/null +++ b/tests/functional/test_dashboard_ui.py @@ -0,0 +1,199 @@ +import pytest +from PyQt5.QtWidgets import QApplication, QLabel +from PyQt5.QtGui import QFont +from PyQt5.QtCore import Qt +from components.UI.DashboardUI import DashboardUI +from controllers.AppController import AppController +from utilities.theme_manager import ThemeManager + +pytestmark = pytest.mark.functional + +@pytest.fixture(scope="module") +def app(): + return QApplication([]) + +@pytest.fixture +def dashboard_ui(app): + controller = AppController() + return DashboardUI(controller) + +def test_initialization(dashboard_ui): + assert dashboard_ui.controller is not None + assert dashboard_ui.theme_manager is not None + assert dashboard_ui.theme_toggle is not None + +def test_create_styled_button(dashboard_ui): + button = dashboard_ui.create_styled_button("Test Button") + assert button.text() == "Test Button" + assert button.font().pointSize() == 14 + +def test_toggle_theme(dashboard_ui, mocker): + mock_toggle_theme = mocker.patch.object(dashboard_ui.controller, 'toggle_theme') + dashboard_ui.toggle_theme() + mock_toggle_theme.assert_called_once() + +def test_show_dashboard(dashboard_ui, mocker): + mock_show = mocker.patch.object(dashboard_ui, 'show') + dashboard_ui.show_dashboard() + mock_show.assert_called_once() + +def test_show_project_ui(dashboard_ui, mocker): + mock_project_ui = mocker.Mock() + mock_show = mocker.patch.object(mock_project_ui, 'show') + mocker.patch('components.UI.project_ui.ProjectUI', return_value=mock_project_ui) + result = dashboard_ui.show_project_ui() + assert result == mock_project_ui + mock_show.assert_called_once() + +def test_on_project_created(dashboard_ui, mocker): + mock_project = mocker.Mock() + mock_update_project_info = mocker.patch.object(dashboard_ui, 'update_project_info') + mock_enable_project_actions = mocker.patch.object(dashboard_ui, 'enable_project_actions') + dashboard_ui.on_project_created(mock_project) + mock_update_project_info.assert_called_once_with(mock_project) + mock_enable_project_actions.assert_called_once() + +def test_on_project_loaded(dashboard_ui, mocker): + mock_project = mocker.Mock() + mock_update_project_info = mocker.patch.object(dashboard_ui, 'update_project_info') + mock_enable_project_actions = mocker.patch.object(dashboard_ui, 'enable_project_actions') + dashboard_ui.on_project_loaded(mock_project) + mock_update_project_info.assert_called_once_with(mock_project) + mock_enable_project_actions.assert_called_once() + +def test_enable_project_actions(dashboard_ui): + dashboard_ui.enable_project_actions() + assert dashboard_ui.manage_exclusions_btn.isEnabled() + assert dashboard_ui.analyze_directory_btn.isEnabled() + assert dashboard_ui.view_directory_tree_btn.isEnabled() + +def test_show_auto_exclude_ui(dashboard_ui, mocker): + mock_auto_exclude_ui = mocker.Mock() + mocker.patch('components.UI.auto_exclude_ui.AutoExcludeUI', return_value=mock_auto_exclude_ui) + result = dashboard_ui.show_auto_exclude_ui(None, None, [], None) + assert result == mock_auto_exclude_ui + mock_auto_exclude_ui.show.assert_called_once() + +def test_show_result(dashboard_ui, mocker): + mock_result_ui = mocker.Mock() + mocker.patch('components.UI.result_ui.ResultUI', return_value=mock_result_ui) + result = dashboard_ui.show_result(None) + assert result == mock_result_ui + mock_result_ui.show.assert_called_once() + +def test_manage_exclusions(dashboard_ui, mocker): + mock_exclusions_ui = mocker.Mock() + mocker.patch('components.UI.exclusions_manager_ui.ExclusionsManagerUI', return_value=mock_exclusions_ui) + result = dashboard_ui.manage_exclusions(None) + assert result == mock_exclusions_ui + mock_exclusions_ui.show.assert_called_once() + +def test_view_directory_tree_ui(dashboard_ui, mocker): + mock_directory_tree_ui = mocker.Mock() + mocker.patch('components.UI.directory_tree_ui.DirectoryTreeUI', return_value=mock_directory_tree_ui) + dashboard_ui.view_directory_tree_ui({}) + mock_directory_tree_ui.update_tree.assert_called_once_with({}) + mock_directory_tree_ui.show.assert_called_once() + +def test_update_project_info(dashboard_ui, mocker): + mock_project = mocker.Mock(name="Test Project", start_directory="/test/path") + mock_set_window_title = mocker.patch.object(dashboard_ui, 'setWindowTitle') + mock_show_message = mocker.patch.object(dashboard_ui.status_bar, 'showMessage') + dashboard_ui.update_project_info(mock_project) + mock_set_window_title.assert_called_once_with("GynTree - Test Project") + mock_show_message.assert_called_once_with("Current project: Test Project, Start directory: /test/path") + +def test_clear_directory_tree(dashboard_ui, mocker): + mock_clear = mocker.Mock() + dashboard_ui.directory_tree_view = mocker.Mock(clear=mock_clear) + dashboard_ui.clear_directory_tree() + mock_clear.assert_called_once() + +def test_clear_analysis(dashboard_ui, mocker): + mock_clear = mocker.Mock() + dashboard_ui.analysis_result_view = mocker.Mock(clear=mock_clear) + dashboard_ui.clear_analysis() + mock_clear.assert_called_once() + +def test_clear_exclusions(dashboard_ui, mocker): + mock_clear = mocker.Mock() + dashboard_ui.exclusions_list_view = mocker.Mock(clear=mock_clear) + dashboard_ui.clear_exclusions() + mock_clear.assert_called_once() + +def test_show_error_message(dashboard_ui, mocker): + mock_critical = mocker.patch('PyQt5.QtWidgets.QMessageBox.critical') + dashboard_ui.show_error_message("Test Title", "Test Message") + mock_critical.assert_called_once_with(dashboard_ui, "Test Title", "Test Message") + +def test_theme_toggle_state(dashboard_ui): + assert dashboard_ui.theme_toggle.isChecked() == (dashboard_ui.theme_manager.get_current_theme() == 'dark') + +def test_theme_toggle_connection(dashboard_ui, qtbot): + with qtbot.waitSignal(dashboard_ui.theme_toggle.stateChanged, timeout=1000): + dashboard_ui.theme_toggle.setChecked(not dashboard_ui.theme_toggle.isChecked()) + +def test_apply_theme(dashboard_ui, mocker): + mock_apply_theme = mocker.patch.object(dashboard_ui.theme_manager, 'apply_theme') + dashboard_ui.apply_theme() + mock_apply_theme.assert_called_once_with(dashboard_ui) + +def test_button_connections(dashboard_ui): + assert dashboard_ui.create_project_btn.clicked.connect.called + assert dashboard_ui.load_project_btn.clicked.connect.called + assert dashboard_ui.manage_exclusions_btn.clicked.connect.called + assert dashboard_ui.analyze_directory_btn.clicked.connect.called + assert dashboard_ui.view_directory_tree_btn.clicked.connect.called + +def test_initial_button_states(dashboard_ui): + assert dashboard_ui.manage_exclusions_btn.isEnabled() + assert dashboard_ui.analyze_directory_btn.isEnabled() + assert dashboard_ui.view_directory_tree_btn.isEnabled() + +def test_theme_toggle_initial_state(dashboard_ui): + assert dashboard_ui.theme_toggle.isChecked() == (dashboard_ui.theme_manager.get_current_theme() == 'dark') + +def test_status_bar_initial_state(dashboard_ui): + assert dashboard_ui.status_bar.currentMessage() == "Ready" + +def test_window_title(dashboard_ui): + assert dashboard_ui.windowTitle() == "GynTree Dashboard" + +def test_main_layout_margins(dashboard_ui): + main_layout = dashboard_ui.centralWidget().layout() + assert main_layout.contentsMargins() == (30, 30, 30, 30) + +def test_main_layout_spacing(dashboard_ui): + main_layout = dashboard_ui.centralWidget().layout() + assert main_layout.spacing() == 20 + +def test_logo_label(dashboard_ui): + logo_label = dashboard_ui.findChild(QLabel, "logo_label") + assert logo_label is not None + assert not logo_label.pixmap().isNull() + +def test_welcome_label(dashboard_ui): + welcome_label = dashboard_ui.findChild(QLabel, "welcome_label") + assert welcome_label is not None + assert welcome_label.text() == "Welcome to GynTree!" + assert welcome_label.font().pointSize() == 24 + assert welcome_label.font().weight() == QFont.Bold + +def test_theme_toggle_size(dashboard_ui): + assert dashboard_ui.theme_toggle.size() == dashboard_ui.theme_toggle.sizeHint() + +def test_button_styles(dashboard_ui): + buttons = [ + dashboard_ui.create_project_btn, + dashboard_ui.load_project_btn, + dashboard_ui.manage_exclusions_btn, + dashboard_ui.analyze_directory_btn, + dashboard_ui.view_directory_tree_btn + ] + for button in buttons: + assert button.font().pointSize() == 14 + +def test_window_geometry(dashboard_ui): + geometry = dashboard_ui.geometry() + assert geometry.width() == 800 + assert geometry.height() == 600 \ No newline at end of file diff --git a/tests/performance/test_directory_analyzer_memory.py b/tests/performance/test_directory_analyzer_memory.py new file mode 100644 index 0000000..8a38d1f --- /dev/null +++ b/tests/performance/test_directory_analyzer_memory.py @@ -0,0 +1,230 @@ +import pytest +import psutil +import gc +import time +import math +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.SettingsManager import SettingsManager +from models.Project import Project + +pytestmark = [pytest.mark.performance, pytest.mark.slow] + +@pytest.fixture +def create_large_directory_structure(tmpdir): + def _create_large_directory_structure(depth=5, files_per_dir=100): + def create_files(directory, num_files): + for i in range(num_files): + file_path = directory.join(f"file_{i}.txt") + file_path.write(f"# GynTree: Test file {i}") + + def create_dirs(root, current_depth): + if current_depth > depth: + return + create_files(root, files_per_dir) + for i in range(5): + subdir = root.mkdir(f"dir_{i}") + create_dirs(subdir, current_depth + 1) + + create_dirs(tmpdir, 1) + return tmpdir + + return _create_large_directory_structure + +@pytest.fixture +def mock_project(tmpdir): + return Project( + name="test_project", + start_directory=str(tmpdir), + root_exclusions=[], + excluded_dirs=[], + excluded_files=[] + ) + +@pytest.fixture +def settings_manager(mock_project): + return SettingsManager(mock_project) + +def test_directory_analyzer_memory_usage(create_large_directory_structure, settings_manager): + large_dir = create_large_directory_structure(depth=5, files_per_dir=100) + + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + process = psutil.Process() + + gc.collect() + memory_before = process.memory_info().rss + + result = analyzer.analyze_directory() + + gc.collect() + memory_after = process.memory_info().rss + + memory_increase = memory_after - memory_before + + # Assert memory increase is within acceptable limits (e.g., less than 100 MB) + max_allowed_increase = 100 * 1024 * 1024 # 100 MB in bytes + assert memory_increase < max_allowed_increase, f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB, which exceeds the limit of {max_allowed_increase / (1024 * 1024)} MB" + + assert len(result['children']) > 0, "The analysis did not produce any results" + + print(f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB") + +def test_directory_analyzer_performance(create_large_directory_structure, settings_manager): + large_dir = create_large_directory_structure(depth=5, files_per_dir=100) + + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + start_time = time.time() + + result = analyzer.analyze_directory() + + end_time = time.time() + execution_time = end_time - start_time + + assert execution_time < 30, f"Analysis took {execution_time:.2f} seconds, which exceeds the 30 second limit" + + assert len(result['children']) > 0, "The analysis did not produce any results" + + print(f"Analysis completed in {execution_time:.2f} seconds") + +def test_directory_analyzer_scalability(create_large_directory_structure, settings_manager): + depths = [3, 4, 5] + execution_times = [] + + for depth in depths: + large_dir = create_large_directory_structure(depth=depth, files_per_dir=50) + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + start_time = time.time() + + result = analyzer.analyze_directory() + + end_time = time.time() + execution_time = end_time - start_time + execution_times.append(execution_time) + + print(f"Depth {depth}: Analysis completed in {execution_time:.2f} seconds") + + time_ratios = [execution_times[i+1] / execution_times[i] for i in range(len(execution_times)-1)] + average_ratio = sum(time_ratios) / len(time_ratios) + + assert 4 < average_ratio < 6, f"Average time ratio {average_ratio:.2f} is not close to the expected value of 5" + +def test_directory_analyzer_with_exclusions(create_large_directory_structure, settings_manager): + large_dir = create_large_directory_structure(depth=5, files_per_dir=100) + + settings_manager.add_excluded_dir("dir_0") + settings_manager.add_excluded_file("file_0.txt") + + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + start_time = time.time() + + result = analyzer.analyze_directory() + + end_time = time.time() + execution_time = end_time - start_time + + assert not any(child['name'] == "dir_0" for child in result['children']), "Excluded directory found in results" + assert not any(child['name'] == "file_0.txt" for child in result['children']), "Excluded file found in results" + + print(f"Analysis with exclusions completed in {execution_time:.2f} seconds") + +def test_directory_analyzer_memory_leak(create_large_directory_structure, settings_manager): + large_dir = create_large_directory_structure(depth=4, files_per_dir=50) + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + process = psutil.Process() + + for i in range(5): + gc.collect() + memory_before = process.memory_info().rss + + result = analyzer.analyze_directory() + + gc.collect() + memory_after = process.memory_info().rss + + memory_increase = memory_after - memory_before + print(f"Iteration {i+1}: Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB") + + if i > 0: + assert memory_increase < 10 * 1024 * 1024, f"Potential memory leak detected. Memory increased by {memory_increase / (1024 * 1024):.2f} MB in iteration {i+1}" + +def test_directory_analyzer_cpu_usage(create_large_directory_structure, settings_manager): + large_dir = create_large_directory_structure(depth=5, files_per_dir=100) + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + process = psutil.Process() + + start_time = time.time() + start_cpu_time = process.cpu_times().user + process.cpu_times().system + + result = analyzer.analyze_directory() + + end_time = time.time() + end_cpu_time = process.cpu_times().user + process.cpu_times().system + + wall_time = end_time - start_time + cpu_time = end_cpu_time - start_cpu_time + + cpu_usage = cpu_time / wall_time + + print(f"CPU usage: {cpu_usage:.2f} (ratio of CPU time to wall time)") + + # Check that CPU usage is reasonable (e.g., not using more than 2 cores on average) + assert cpu_usage < 2, f"CPU usage ({cpu_usage:.2f}) is higher than expected" + +def test_directory_analyzer_with_very_deep_structure(create_large_directory_structure, settings_manager): + very_deep_dir = create_large_directory_structure(depth=10, files_per_dir=10) + mock_project = Project(name="test_project", start_directory=str(very_deep_dir)) + analyzer = DirectoryAnalyzer(str(very_deep_dir), settings_manager) + + start_time = time.time() + + result = analyzer.analyze_directory() + + end_time = time.time() + execution_time = end_time - start_time + + print(f"Analysis of very deep structure completed in {execution_time:.2f} seconds") + + max_depth = 0 + def get_max_depth(node, current_depth): + nonlocal max_depth + max_depth = max(max_depth, current_depth) + for child in node.get('children', []): + if child['type'] == 'directory': + get_max_depth(child, current_depth + 1) + + get_max_depth(result, 0) + assert max_depth >= 10, f"Analysis did not capture the full depth of the directory structure. Max depth: {max_depth}" + +def test_directory_analyzer_with_large_files(create_large_directory_structure, settings_manager, tmpdir): + large_dir = create_large_directory_structure(depth=3, files_per_dir=10) + + for i in range(5): + large_file = large_dir.join(f"large_file_{i}.txt") + with large_file.open('w') as f: + f.write('0' * (10 * 1024 * 1024)) # 10 MB file + + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + start_time = time.time() + + result = analyzer.analyze_directory() + + end_time = time.time() + execution_time = end_time - start_time + + print(f"Analysis with large files completed in {execution_time:.2f} seconds") + + large_files = [child for child in result['children'] if child['name'].startswith('large_file_')] + assert len(large_files) == 5, f"Expected 5 large files, but found {len(large_files)}" \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py index a3293fc..4a6af5f 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,140 +1,109 @@ -import subprocess import os import sys -import time -from datetime import datetime +import argparse +import traceback from colorama import init, Fore, Style +from test_runner_utils import ( + clear_screen, print_colored, load_config, save_config, + discover_tests, get_user_choice +) +from test_runners import TestRunnerFactory, AllTestsRunner, SingleTestRunner init(autoreset=True) -def clear_screen(): - os.system('cls' if os.name == 'nt' else 'clear') +CONFIG_FILE = os.path.join('tests', 'test_config.json') +REPORTS_DIR = os.path.join('tests', 'reports') -def print_colored(text, color=Fore.WHITE, style=Style.NORMAL): - print(f"{style}{color}{text}") - -def run_command(command, output_file): - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, shell=True) - with open(output_file, 'w', encoding='utf-8') as f: - for stdout_line in iter(process.stdout.readline, ""): - f.write(stdout_line) - yield stdout_line - process.stdout.close() - return_code = process.wait() - if return_code: - raise subprocess.CalledProcessError(return_code, command) - -def run_tests(options): - base_command = f"pytest -v --tb=short --capture=no --log-cli-level=DEBUG {options.get('extra_args', '')}" - - if options.get('parallel', False): - base_command += " -n auto" - - if options.get('html_report', False): - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - report_name = f"test_report_{timestamp}.html" - base_command += f" --html={report_name} --self-contained-html" - - if options.get('memory_test', False): - base_command += " -m memory" - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_file = f"test_output_{timestamp}.txt" - - print_colored(f"Running command: {base_command}", Fore.CYAN) - print_colored(f"Test output will be saved to: {output_file}", Fore.YELLOW) - print_colored("Test output:", Fore.YELLOW) - - start_time = time.time() - try: - for line in run_command(base_command, output_file): - if "PASSED" in line: - print_colored(line.strip(), Fore.GREEN) - elif "FAILED" in line: - print_colored(line.strip(), Fore.RED) - elif "SKIPPED" in line: - print_colored(line.strip(), Fore.YELLOW) - else: - print(line.strip()) - except subprocess.CalledProcessError as e: - print_colored(f"An error occurred while running the tests: {e}", Fore.RED) - - end_time = time.time() - duration = end_time - start_time - - print_colored(f"\nTests completed in {duration:.2f} seconds", Fore.CYAN) - print_colored(f"Full test output saved to: {output_file}", Fore.GREEN) - - if options.get('html_report', False): - print_colored(f"HTML report generated: {report_name}", Fore.GREEN) - -def get_user_choice(prompt, options): - while True: - print_colored(prompt, Fore.CYAN) - for i, option in enumerate(options, 1): - print_colored(f"{i}. {option}", Fore.YELLOW) - choice = input("Enter your choice (number): ") - if choice.isdigit() and 1 <= int(choice) <= len(options): - return int(choice) - print_colored("Invalid choice. Please try again.", Fore.RED) - -def main_menu(): +def main_menu(debug_mode): + config = load_config(CONFIG_FILE) while True: clear_screen() print_colored("GynTree Interactive Test Runner", Fore.CYAN, Style.BRIGHT) print_colored("================================\n", Fore.CYAN, Style.BRIGHT) - - options = {} - - # Test Type + options = {'debug': debug_mode} + tests = discover_tests() + if not tests: + print_colored("No test files found!", Fore.RED) + sys.exit(1) test_type_choices = [ "Run all tests", - "Run only unit tests", - "Run only integration tests", - "Run memory tests", + "Run unit tests", + "Run integration tests", + "Run performance tests", + "Run functional tests", + "Run single test", "Exit" ] test_type = get_user_choice("Select test type:", test_type_choices) - if test_type == 5: + if test_type == 7: print_colored("Exiting. Goodbye!", Fore.YELLOW) sys.exit(0) - elif test_type == 2: - options['extra_args'] = "-m 'not integration and not memory'" - elif test_type == 3: - options['extra_args'] = "-m integration" - elif test_type == 4: - options['memory_test'] = True - - # Execution Mode - execution_mode_choices = [ - "Run tests sequentially", - "Run tests in parallel" - ] - execution_mode = get_user_choice("Select execution mode:", execution_mode_choices) - options['parallel'] = (execution_mode == 2) - - # Reporting - reporting_choices = [ - "Console output only", - "Generate HTML report" - ] + selected_tests = None + if test_type == 6: + test_file_choice = get_user_choice("Select test file to run:", tests) + selected_tests = [tests[test_file_choice - 1]] + runner = SingleTestRunner() + else: + test_type_name = test_type_choices[test_type - 1].lower().split()[1] + if test_type_name == "all": + runner = AllTestsRunner() + else: + runner = TestRunnerFactory.create_runner(test_type_name) + if test_type != 6: + execution_mode_choices = ["Run tests sequentially", "Run tests in parallel"] + execution_mode = get_user_choice("Select execution mode:", execution_mode_choices) + options['parallel'] = (execution_mode == 2) + reporting_choices = ["Console output only", "Generate HTML report"] reporting = get_user_choice("Select reporting option:", reporting_choices) options['html_report'] = (reporting == 2) - - # Confirmation + coverage_choices = ["No coverage", "Generate coverage report"] + coverage = get_user_choice("Select coverage option:", coverage_choices) + options['coverage'] = (coverage == 2) clear_screen() print_colored("Test Run Configuration:", Fore.CYAN, Style.BRIGHT) print_colored("-------------------------", Fore.CYAN) - print_colored(f"Test Type: {test_type_choices[test_type - 1]}", Fore.YELLOW) - print_colored(f"Execution Mode: {execution_mode_choices[execution_mode - 1]}", Fore.YELLOW) + print_colored(f"Test type: {test_type_choices[test_type - 1]}", Fore.YELLOW) + if selected_tests: + print_colored(f"Selected test: {selected_tests[0]}", Fore.YELLOW) + else: + print_colored(f"Execution mode: {execution_mode_choices[execution_mode - 1]}", Fore.YELLOW) print_colored(f"Reporting: {reporting_choices[reporting - 1]}", Fore.YELLOW) + print_colored(f"Coverage: {coverage_choices[coverage - 1]}", Fore.YELLOW) + print_colored(f"Debug mode: {'Enabled' if debug_mode else 'Disabled'}", Fore.YELLOW) print() - confirm = input("Do you want to proceed with this configuration? (y/n): ") if confirm.lower() == 'y': - run_tests(options) + try: + runner.run(options, selected_tests, REPORTS_DIR) + except Exception as e: + print_colored("An error occurred during test execution:", Fore.RED) + print_colored(str(e), Fore.RED) + print_colored("\nTraceback:", Fore.RED) + traceback.print_exc() + finally: + input("\nPress Enter to return to the main menu...") - input("\nPress Enter to return to the main menu...") + config['last_config'] = options + save_config(CONFIG_FILE, config) if __name__ == "__main__": - main_menu() \ No newline at end of file + parser = argparse.ArgumentParser(description="GynTree Test Runner") + parser.add_argument("--ci", action="store_true", help="Run in CI mode with last saved configuration") + parser.add_argument("--debug", action="store_true", help="Run in debug mode with extra logging") + args = parser.parse_args() + + if not os.path.exists(REPORTS_DIR): + os.makedirs(REPORTS_DIR) + + if args.ci: + config = load_config(CONFIG_FILE) + last_config = config.get('last_config', {}) + if last_config: + print_colored("Running tests with last saved configuration in CI mode", Fore.CYAN) + last_config['debug'] = args.debug + AllTestsRunner().run(last_config, None, REPORTS_DIR) + else: + print_colored("No saved configuration found. Please run in interactive mode first.", Fore.RED) + sys.exit(1) + else: + main_menu(args.debug) \ No newline at end of file diff --git a/tests/test_comment_parser.py b/tests/test_comment_parser.py deleted file mode 100644 index d5dad9a..0000000 --- a/tests/test_comment_parser.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest -from services.CommentParser import CommentParser, DefaultFileReader, DefaultCommentSyntax - -@pytest.fixture -def comment_parser(): - return CommentParser(DefaultFileReader(), DefaultCommentSyntax()) - -def test_single_line_comment(tmpdir, comment_parser): - file_path = tmpdir.join("test_file.py") - file_path.write("# GynTree: This is a test file.") - assert comment_parser.get_file_purpose(str(file_path)) == "This is a test file." - -def test_multiline_comment_js(tmpdir, comment_parser): - file_content = """ - /* - * GynTree: This is a multiline comment - * in a JavaScript file. - */ - """ - file_path = tmpdir.join("test_file.js") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "This is a multiline comment in a JavaScript file." - -def test_multiline_comment_cpp(tmpdir, comment_parser): - file_content = """ - /* GynTree: This C++ multiline comment - spans multiple lines. */ - """ - file_path = tmpdir.join("test_file.cpp") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "This C++ multiline comment spans multiple lines." - -def test_no_comment(tmpdir, comment_parser): - file_path = tmpdir.join("test_file.py") - file_path.write("print('Hello world')") - assert comment_parser.get_file_purpose(str(file_path)) == "No description available" - -def test_multiple_comments(tmpdir, comment_parser): - file_content = """ - # Non-GynTree comment - # GynTree: First GynTree comment - # GynTree: Second GynTree comment - """ - file_path = tmpdir.join("test_file.py") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "First GynTree comment" - -def test_html_comment(tmpdir, comment_parser): - file_path = tmpdir.join("test_file.html") - file_path.write("") - assert comment_parser.get_file_purpose(str(file_path)) == "HTML file comment" - -def test_unsupported_file_type(tmpdir, comment_parser): - file_path = tmpdir.join("test_file.xyz") - file_path.write("GynTree: This should not be parsed") - assert comment_parser.get_file_purpose(str(file_path)) == "Unsupported file type" - -def test_empty_file(tmpdir, comment_parser): - file_path = tmpdir.join("empty_file.py") - file_path.write("") - assert comment_parser.get_file_purpose(str(file_path)) == "File not found or empty" - -def test_comment_with_special_characters(tmpdir, comment_parser): - file_path = tmpdir.join("test_file.py") - file_path.write("# GynTree: Special chars: !@#$%^&*()") - assert comment_parser.get_file_purpose(str(file_path)) == "Special chars: !@#$%^&*()" - -def test_case_insensitive_gyntree(tmpdir, comment_parser): - file_content = "# gyntree: Case insensitive test" - file_path = tmpdir.join("test_file.py") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "Case insensitive test" - -def test_gyntree_not_at_start_of_line(tmpdir, comment_parser): - file_content = """ - /* - * Introduction - * GynTree: Description text - */ - """ - file_path = tmpdir.join("test_file.js") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "Description text" - -'''def test_long_file(tmpdir, comment_parser): - lines = ['# Line {}'.format(i) for i in range(1000)] - lines.insert(500, '# GynTree: Description in long file') - file_content = '\n'.join(lines) - file_path = tmpdir.join("test_file.py") - file_path.write(file_content) - assert comment_parser.get_file_purpose(str(file_path)) == "Description in long file"''' \ No newline at end of file diff --git a/tests/test_config.json b/tests/test_config.json new file mode 100644 index 0000000..64e1abe --- /dev/null +++ b/tests/test_config.json @@ -0,0 +1,7 @@ +{ + "last_config": { + "debug": false, + "html_report": false, + "coverage": false + } +} \ No newline at end of file diff --git a/tests/test_directory_analyzer.py b/tests/test_directory_analyzer.py deleted file mode 100644 index 3aff62c..0000000 --- a/tests/test_directory_analyzer.py +++ /dev/null @@ -1,130 +0,0 @@ -import os -import pytest -import threading -from services.DirectoryAnalyzer import DirectoryAnalyzer -from services.SettingsManager import SettingsManager -from models.Project import Project - -@pytest.fixture -def mock_project(tmpdir): - return Project( - name="test_project", - start_directory=str(tmpdir), - root_exclusions=[], - excluded_dirs=[], - excluded_files=[] - ) - -@pytest.fixture -def settings_manager(mock_project): - return SettingsManager(mock_project) - -@pytest.fixture -def analyzer(mock_project, settings_manager): - return DirectoryAnalyzer(mock_project.start_directory, settings_manager) - -def test_directory_analysis(tmpdir, analyzer): - test_file = tmpdir.join("test_file.py") - test_file.write("# gyntree: Test purpose.") - result = analyzer.analyze_directory() - # Navigate through the nested structure - def find_file(structure, target_path): - if structure['type'] == 'file' and structure['path'] == str(test_file): - return structure - elif 'children' in structure: - for child in structure['children']: - found = find_file(child, target_path) - if found: - return found - return None - file_info = find_file(result, str(test_file)) - assert file_info is not None - assert file_info['description'] == "This is a test purpose." - -def test_excluded_directory(tmpdir, mock_project, settings_manager): - excluded_dir = tmpdir.mkdir("excluded") - excluded_file = excluded_dir.join("excluded_file.py") - excluded_file.write("# This should not be analyzed") - mock_project.excluded_dirs = [str(excluded_dir)] - settings_manager.update_settings({'excluded_dirs': [str(excluded_dir)]}) - analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) - result = analyzer.analyze_directory() - assert str(excluded_file) not in result - -def test_excluded_file(tmpdir, mock_project, settings_manager): - test_file = tmpdir.join("excluded_file.py") - test_file.write("# This should not be analyzed") - mock_project.excluded_files = [str(test_file)] - settings_manager.update_settings({'excluded_files': [str(test_file)]}) - analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) - result = analyzer.analyze_directory() - assert str(test_file) not in result - -def test_nested_directory_analysis(tmpdir, analyzer): - nested_dir = tmpdir.mkdir("nested") - nested_file = nested_dir.join("nested_file.py") - nested_file.write("# gyntree: This is a nested file") - result = analyzer.analyze_directory() - assert str(nested_file) in result - assert result[str(nested_file)]['description'] == "This is a nested file" - -def test_get_flat_structure(tmpdir, analyzer): - tmpdir.join("file1.py").write("# gyntree: File 1") - tmpdir.join("file2.py").write("# gyntree: File 2") - flat_structure = analyzer.get_flat_structure() - assert len(flat_structure) == 2 - assert any(item['path'].endswith('file1.py') for item in flat_structure) - assert any(item['path'].endswith('file2.py') for item in flat_structure) - -def test_empty_directory(tmpdir, analyzer): - result = analyzer.analyze_directory() - # Check that the 'children' list is empty - assert result['children'] == [] - -def test_large_directory_structure(tmpdir, analyzer): - for i in range(1000): - tmpdir.join(f"file_{i}.py").write(f"# gyntree: File {i}") - result = analyzer.analyze_directory() - assert len(result) == 1000 - -def test_stop_analysis(tmpdir, analyzer): - for i in range(1000): - tmpdir.join(f"file_{i}.py").write(f"# gyntree: File {i}") - - def stop_analysis(): - analyzer.stop() - - timer = threading.Timer(0.1, stop_analysis) - timer.start() - - result = analyzer.analyze_directory() - assert len(result) < 1000 - -def test_root_exclusions(tmpdir, mock_project, settings_manager): - root_dir = tmpdir.mkdir("root_excluded") - root_file = root_dir.join("root_file.py") - root_file.write("# This should not be analyzed") - mock_project.root_exclusions = [str(root_dir)] - settings_manager.update_settings({'root_exclusions': [str(root_dir)]}) - analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) - result = analyzer.analyze_directory() - assert str(root_file) not in result - -def test_symlink_handling(tmpdir, analyzer): - real_dir = tmpdir.mkdir("real_dir") - real_dir.join("real_file.py").write("# gyntree: real file") - symlink_dir = tmpdir.join("symlink_dir") - target = str(real_dir) - link_name = str(symlink_dir) - - if hasattr(os, 'symlink'): - try: - os.symlink(target, link_name) - except (OSError, NotImplementedError, AttributeError): - pytest.skip("Symlink not supported on this platform or insufficient permissions") - else: - pytest.skip("Symlink not supported on this platform") - - result = analyzer.analyze_directory() - assert any('real_file.py' in path for path in result.keys()) - assert len([path for path in result.keys() if 'real_file.py' in path]) == 1 \ No newline at end of file diff --git a/tests/test_directory_analyzer_memory.py b/tests/test_directory_analyzer_memory.py deleted file mode 100644 index eb33bea..0000000 --- a/tests/test_directory_analyzer_memory.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -import psutil -import gc -from services.DirectoryAnalyzer import DirectoryAnalyzer -from services.SettingsManager import SettingsManager -from models.Project import Project - -@pytest.mark.memory -def test_directory_analyzer_memory_usage(create_large_directory_structure, settings_manager): - - large_dir = create_large_directory_structure(depth=5, files_per_dir=100) - - # Set up the DirectoryAnalyzer - mock_project = Project(name="test_project", start_directory=str(large_dir)) - analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) - - # Get the current process - process = psutil.Process() - - # Measure memory usage before analysis - gc.collect() - memory_before = process.memory_info().rss - - # Perform the analysis - result = analyzer.analyze_directory() - - # Measure memory usage after analysis - gc.collect() - memory_after = process.memory_info().rss - - # Calculate memory increase - memory_increase = memory_after - memory_before - - # Assert that memory increase is within acceptable limits (e.g., less than 100 MB) - max_allowed_increase = 100 * 1024 * 1024 # 100 MB in bytes - assert memory_increase < max_allowed_increase, f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB, which exceeds the limit of {max_allowed_increase / (1024 * 1024)} MB" - - # Check that the analysis completed successfully - assert len(result) > 0, "The analysis did not produce any results" - - # Optional: Print memory usage for informational purposes - print(f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB") \ No newline at end of file diff --git a/tests/test_project_context.py b/tests/test_project_context.py deleted file mode 100644 index 4b68939..0000000 --- a/tests/test_project_context.py +++ /dev/null @@ -1,97 +0,0 @@ -import json -import pytest -from services.ProjectContext import ProjectContext -from models.Project import Project -from services.SettingsManager import SettingsManager -from services.DirectoryAnalyzer import DirectoryAnalyzer -from services.auto_exclude.AutoExcludeManager import AutoExcludeManager -from services.RootExclusionManager import RootExclusionManager -from services.ProjectTypeDetector import ProjectTypeDetector - -@pytest.fixture -def mock_project(tmpdir): - return Project( - name="test_project", - start_directory=str(tmpdir), - root_exclusions=[], - excluded_dirs=[], - excluded_files=[] - ) - -@pytest.fixture -def project_context(mock_project): - return ProjectContext(mock_project) - -def test_initialization(project_context): - assert project_context.project is not None - assert isinstance(project_context.settings_manager, SettingsManager) - assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) - assert isinstance(project_context.auto_exclude_manager, AutoExcludeManager) - assert isinstance(project_context.root_exclusion_manager, RootExclusionManager) - assert isinstance(project_context.project_type_detector, ProjectTypeDetector) - -def test_detect_project_types(project_context, tmpdir): - tmpdir.join("main.py").write("print('Hello, world!')") - project_context.detect_project_types() - assert 'python' in project_context.project_types - -def test_initialize_root_exclusions(project_context): - initial_exclusions = set(project_context.settings_manager.get_root_exclusions()) - project_context.initialize_root_exclusions() - updated_exclusions = set(project_context.settings_manager.get_root_exclusions()) - assert updated_exclusions >= initial_exclusions - -def test_trigger_auto_exclude(project_context): - result = project_context.trigger_auto_exclude() - assert isinstance(result, str) - assert len(result) > 0 - -def test_get_directory_tree(project_context, tmpdir): - tmpdir.join("test_file.py").write("# Test content") - tree = project_context.get_directory_tree() - assert isinstance(tree, dict) - assert "test_file.py" in str(tree) - -def test_save_settings(project_context): - project_context.settings_manager.add_excluded_dir("test_dir") - project_context.save_settings() - reloaded_context = ProjectContext(project_context.project) - assert "test_dir" in reloaded_context.settings_manager.get_excluded_dirs() - -def test_close(project_context): - project_context.close() - assert project_context.settings_manager is None - assert project_context.directory_analyzer is None - assert project_context.auto_exclude_manager is None - assert len(project_context.project_types) == 0 - assert project_context.project_type_detector is None - -def test_reinitialize_directory_analyzer(project_context): - original_analyzer = project_context.directory_analyzer - project_context.reinitialize_directory_analyzer() - assert project_context.directory_analyzer is not original_analyzer - assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) - -def test_stop_analysis(project_context, mocker): - mock_stop = mocker.patch.object(project_context.directory_analyzer, 'stop') - project_context.stop_analysis() - mock_stop.assert_called_once() - -def test_project_context_with_existing_settings(mock_project, tmpdir): - # Set up existing settings - settings_file = tmpdir.join("config", "projects", f"{mock_project.name}.json") - settings_file.write(json.dumps({ - "root_exclusions": ["existing_root"], - "excluded_dirs": ["existing_dir"], - "excluded_files": ["existing_file"] - }), ensure=True) - - context = ProjectContext(mock_project) - assert "existing_root" in context.settings_manager.get_root_exclusions() - assert "existing_dir" in context.settings_manager.get_excluded_dirs() - assert "existing_file" in context.settings_manager.get_excluded_files() - -def test_project_context_error_handling(mock_project, mocker): - mocker.patch('services.SettingsManager.SettingsManager.__init__', side_effect=Exception("Test error")) - with pytest.raises(Exception): - ProjectContext(mock_project) \ No newline at end of file diff --git a/tests/test_runner_utils.py b/tests/test_runner_utils.py new file mode 100644 index 0000000..4ef9c2d --- /dev/null +++ b/tests/test_runner_utils.py @@ -0,0 +1,179 @@ +import os +import sys +import json +import subprocess +import threading +import queue +from datetime import datetime +import re +import time +import pytest +from pytest import Config +from colorama import Fore, Style, init + +# Initialize colorama +init(autoreset=True) + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def print_colored(text, color=Fore.WHITE, style=Style.NORMAL): + print(f"{style}{color}{text}{Style.RESET_ALL}") + +def load_config(config_file): + if os.path.exists(config_file): + with open(config_file, 'r') as f: + return json.load(f) + return {} + +def save_config(config_file, config): + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + +def run_command_with_timeout(command, output_file, timeout=1800): # 30 minutes timeout + def target(queue): + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, shell=True) + for line in iter(process.stdout.readline, ''): + queue.put(line) + process.stdout.close() + process.wait() + queue.put(None) + except Exception as e: + queue.put(f"Error: {str(e)}") + queue.put(None) + + q = queue.Queue() + thread = threading.Thread(target=target, args=(q,)) + thread.start() + + start_time = time.time() + with open(output_file, 'w', encoding='utf-8') as f: + while True: + try: + line = q.get(timeout=1) + if line is None: + break + if "PASSED" in line: + print_colored(line.strip(), Fore.GREEN) + elif "FAILED" in line or "ERROR" in line: + print_colored(line.strip(), Fore.RED) + else: + print(line.strip()) + f.write(line) + f.flush() + except queue.Empty: + if time.time() - start_time > timeout: + print_colored(f"Test execution timed out after {timeout} seconds.", Fore.RED) + break + + thread.join(timeout=5) + if thread.is_alive(): + print_colored("Failed to terminate process gracefully. Forcing termination.", Fore.RED) + + print_colored(f"Total execution time: {time.time() - start_time:.2f} seconds", Fore.CYAN) + +def run_tests(options, selected_tests, reports_dir): + if not os.path.exists('tests/conftest.py'): + print_colored("Error: conftest.py not found in tests directory. Please run the script from the project root.", Fore.RED) + return + + pytest_args = ["-v", "--tb=short", "--capture=no", "--log-cli-level=DEBUG"] + + if options.get('parallel', False): + pytest_args.append("-n auto") + + if options.get('html_report', False): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_name = os.path.join(reports_dir, f"test_report_{timestamp}.html") + pytest_args.extend([f"--html={report_name}", "--self-contained-html"]) + + if options.get('extra_args'): + pytest_args.extend(options['extra_args'].split()) + + if options.get('debug', False): + pytest_args.append("-vv") + + if selected_tests: + pytest_args.extend(selected_tests) + else: + pytest_args.append("tests") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = os.path.join(reports_dir, f"test_output_{timestamp}.txt") + + print_colored(f"Running pytest with arguments: {' '.join(pytest_args)}", Fore.CYAN) + print_colored(f"Test output will be saved to: {output_file}", Fore.YELLOW) + print_colored("Test output:", Fore.YELLOW) + + start_time = datetime.now() + + try: + if options.get('coverage', False): + coverage_command = f"coverage run -m pytest {' '.join(pytest_args)}" + run_command_with_timeout(coverage_command, output_file, timeout=3600) # 1 hour timeout + else: + run_command_with_timeout(f"pytest {' '.join(pytest_args)}", output_file, timeout=3600) # 1 hour timeout + except subprocess.CalledProcessError as e: + print_colored(f"An error occurred while running tests: {e}", Fore.RED) + except Exception as e: + print_colored(f"An unexpected error occurred: {str(e)}", Fore.RED) + finally: + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + print_colored(f"\nTests completed in {duration:.2f} seconds", Fore.CYAN) + print_colored(f"Full test output saved to: {output_file}", Fore.GREEN) + + if options.get('html_report', False): + print_colored(f"HTML report generated: {report_name}", Fore.GREEN) + + if options.get('coverage', False): + subprocess.run("coverage report", shell=True) + coverage_html = os.path.join(reports_dir, "coverage_html") + subprocess.run(f"coverage html -d {coverage_html}", shell=True) + print_colored(f"Coverage report generated: {coverage_html}/index.html", Fore.GREEN) + + print_colored("\nAnalyzing test results...", Fore.CYAN) + analyze_test_results(output_file) + +def analyze_test_results(output_file): + with open(output_file, 'r', encoding='utf-8') as f: + content = f.read() + + passed_tests = len(re.findall(r"PASSED", content)) + failed_tests = len(re.findall(r"FAILED", content)) + skipped_tests = len(re.findall(r"SKIPPED", content)) + error_tests = len(re.findall(r"ERROR", content)) + + total_tests = passed_tests + failed_tests + skipped_tests + error_tests + + print_colored(f"Total tests: {total_tests}", Fore.CYAN) + print_colored(f"Passed: {passed_tests}", Fore.GREEN) + print_colored(f"Failed: {failed_tests}", Fore.RED) + print_colored(f"Skipped: {skipped_tests}", Fore.YELLOW) + print_colored(f"Errors: {error_tests}", Fore.MAGENTA) + + if failed_tests > 0 or error_tests > 0: + print_colored("\nFailed and Error tests:", Fore.RED) + for line in content.split('\n'): + if "FAILED" in line or "ERROR" in line: + print_colored(line.strip(), Fore.RED) + +def discover_tests(): + """Discover test files in the tests directory and its subdirectories.""" + test_files = [] + for root, _, files in os.walk('tests'): + for file in files: + if file.startswith('test_') and file.endswith('.py'): + test_files.append(os.path.join(root, file)) + return test_files + +def get_user_choice(prompt, options): + while True: + print_colored(prompt, Fore.CYAN) + for i, option in enumerate(options, 1): + print_colored(f"{i}. {option}", Fore.YELLOW) + choice = input("Enter your choice (number): ") + if choice.isdigit() and 1 <= int(choice) <= len(options): + return int(choice) + print_colored("Invalid choice. Please try again.", Fore.RED) \ No newline at end of file diff --git a/tests/test_runners.py b/tests/test_runners.py new file mode 100644 index 0000000..349d0da --- /dev/null +++ b/tests/test_runners.py @@ -0,0 +1,49 @@ +from abc import ABC, abstractmethod +from test_runner_utils import run_tests + +class TestRunner(ABC): + @abstractmethod + def run(self, options, selected_tests, reports_dir): + pass + +class AllTestsRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + return run_tests(options, selected_tests, reports_dir) + +class SingleTestRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + return run_tests(options, selected_tests, reports_dir) + +class UnitTestRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + options['extra_args'] = "-m unit" + return run_tests(options, selected_tests, reports_dir) + +class IntegrationTestRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + options['extra_args'] = "-m 'integration'" + return run_tests(options, selected_tests, reports_dir) + +class PerformanceTestRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + options['extra_args'] = "-m 'performance'" + return run_tests(options, selected_tests, reports_dir) + +class FunctionalTestRunner(TestRunner): + def run(self, options, selected_tests, reports_dir): + options['extra_args'] = "-m 'functional'" + return run_tests(options, selected_tests, reports_dir) + +class TestRunnerFactory: + @staticmethod + def create_runner(test_type): + runners = { + "unit": UnitTestRunner(), + "integration": IntegrationTestRunner(), + "performance": PerformanceTestRunner(), + "functional": FunctionalTestRunner(), + } + runner = runners.get(test_type.lower()) + if runner is None: + raise ValueError(f"Unknown test type: {test_type}") + return runner \ No newline at end of file diff --git a/tests/unit/test_app_controller.py b/tests/unit/test_app_controller.py new file mode 100644 index 0000000..4825acc --- /dev/null +++ b/tests/unit/test_app_controller.py @@ -0,0 +1,189 @@ +import threading +import pytest +from PyQt5.QtWidgets import QApplication +from controllers.AppController import AppController +from utilities.theme_manager import ThemeManager +import logging + +pytestmark = pytest.mark.unit + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +def log_test(func): + def wrapper(*args, **kwargs): + logger.debug(f"Starting test: {func.__name__}") + result = func(*args, **kwargs) + logger.debug(f"Finished test: {func.__name__}") + return result + return wrapper + +def run_with_timeout(func, args=(), kwargs={}, timeout=10): + result = [None] + exception = [None] + def worker(): + try: + result[0] = func(*args, **kwargs) + except Exception as e: + exception[0] = e + + thread = threading.Thread(target=worker) + thread.start() + thread.join(timeout) + if thread.is_alive(): + raise TimeoutError(f"Test timed out after {timeout} seconds") + if exception[0]: + raise exception[0] + return result[0] + +@pytest.fixture(scope="module") +def app(): + return QApplication([]) + +@pytest.fixture +def app_controller(app, mocker): + controller = AppController() + mock_project_context = mocker.Mock() + mock_project_context.auto_exclude_manager = mocker.Mock() + controller.project_controller.project_context = mock_project_context + return controller + +@log_test +def test_initialization(app_controller): + assert app_controller.main_ui is not None + assert app_controller.theme_manager is not None + assert app_controller.project_controller is not None + assert app_controller.thread_controller is not None + assert app_controller.ui_controller is not None + +@log_test +def test_run(app_controller, mocker): + mock_show_dashboard = mocker.patch.object(app_controller.main_ui, 'show_dashboard') + app_controller.run() + mock_show_dashboard.assert_called_once() + +@log_test +def test_cleanup(app_controller, mocker): + mock_thread_cleanup = mocker.patch.object(app_controller.thread_controller, 'cleanup_thread') + mock_project_close = mocker.patch.object(app_controller.project_controller, 'close_project', create=True) + mock_ui_close = mocker.patch.object(QApplication, 'closeAllWindows') + + run_with_timeout(app_controller.cleanup) + + mock_thread_cleanup.assert_called_once() + if app_controller.project_controller and app_controller.project_controller.project_context: + mock_project_close.assert_called_once() + mock_ui_close.assert_called_once() + +@log_test +def test_toggle_theme(app_controller, mocker): + mock_toggle = mocker.patch.object(app_controller.theme_manager, 'toggle_theme') + mock_set_preference = mocker.patch.object(app_controller, 'set_theme_preference') + app_controller.toggle_theme() + mock_toggle.assert_called_once() + mock_set_preference.assert_called_once() + +@log_test +@pytest.mark.gui +def test_apply_theme_to_all_windows(app_controller, mocker, app): + mock_apply = mocker.patch.object(app_controller.theme_manager, 'apply_theme_to_all_windows') + app_controller.apply_theme_to_all_windows('light') + mock_apply.assert_called_once_with(app) + +@log_test +def test_get_theme_preference(app_controller, mocker): + mock_get_preference = mocker.patch.object(app_controller.project_controller, 'get_theme_preference', return_value='light') + theme = app_controller.get_theme_preference() + assert theme == 'light' + mock_get_preference.assert_called_once() + +@log_test +def test_set_theme_preference(app_controller, mocker): + mock_set_preference = mocker.patch.object(app_controller.project_controller, 'set_theme_preference') + app_controller.set_theme_preference('dark') + mock_set_preference.assert_called_once_with('dark') + +@log_test +def test_create_project_action(app_controller, mocker): + mock_show_project_ui = mocker.patch.object(app_controller.main_ui, 'show_project_ui') + app_controller.create_project_action() + mock_show_project_ui.assert_called_once() + +@log_test +def test_on_project_created(app_controller, mocker): + mock_project = mocker.Mock() + mock_create_project = mocker.patch.object(app_controller.project_controller, 'create_project', return_value=True) + mock_update_project_info = mocker.patch.object(app_controller.main_ui, 'update_project_info') + mock_after_project_loaded = mocker.patch.object(app_controller, 'after_project_loaded') + app_controller.on_project_created(mock_project) + assert mock_update_project_info.call_count == 1, "update_project_info should be called once." + +@log_test +def test_load_project_action(app_controller, mocker): + mock_show_project_ui = mocker.patch.object(app_controller.main_ui, 'show_project_ui') + app_controller.load_project_action() + mock_show_project_ui.assert_called_once() + +@log_test +def test_on_project_loaded(app_controller, mocker): + mock_project = mocker.Mock() + mock_load_project = mocker.patch.object(app_controller.project_controller, 'load_project', return_value=mock_project) + mock_update_project_info = mocker.patch.object(app_controller.main_ui, 'update_project_info') + mock_after_project_loaded = mocker.patch.object(app_controller, 'after_project_loaded') + app_controller.on_project_loaded(mock_project) + mock_load_project.assert_called_once_with(mock_project.name) + mock_update_project_info.assert_called_once_with(mock_project) + mock_after_project_loaded.assert_called_once() + +@log_test +def test_after_project_loaded(app_controller, mocker): + mock_reset_ui = mocker.patch.object(app_controller.ui_controller, 'reset_ui') + mock_start_auto_exclude = mocker.patch.object(app_controller, '_start_auto_exclude') + app_controller.after_project_loaded() + mock_reset_ui.assert_called_once() + mock_start_auto_exclude.assert_called_once() + +@log_test +def test_manage_exclusions(app_controller, mocker): + mock_manage_exclusions = mocker.patch.object(app_controller.ui_controller, 'manage_exclusions') + app_controller.manage_exclusions() + mock_manage_exclusions.assert_called_once() + +@log_test +def test_view_directory_tree(app_controller, mocker): + mock_view_directory_tree = mocker.patch.object(app_controller.ui_controller, 'view_directory_tree') + mock_project_context = mocker.Mock() + mock_project_context.get_directory_tree = mocker.Mock(return_value={}) + app_controller.project_controller.project_context = mock_project_context + app_controller.view_directory_tree() + mock_project_context.get_directory_tree.assert_called_once() + mock_view_directory_tree.assert_called_once_with({}) + +@log_test +def test_analyze_directory(app_controller, mocker): + mock_show_result = mocker.patch.object(app_controller.ui_controller, 'show_result') + mock_update_result = mocker.patch.object(mock_show_result.return_value, 'update_result') + app_controller.analyze_directory() + mock_show_result.assert_called_once() + mock_update_result.assert_called_once() + +@log_test +def test_start_auto_exclude(app_controller, mocker): + mock_start_thread = mocker.patch.object(app_controller.thread_controller, 'start_auto_exclude_thread') + app_controller._start_auto_exclude() + mock_start_thread.assert_called_once_with(app_controller.project_controller.project_context) + +@log_test +def test_on_auto_exclude_finished(app_controller, mocker): + mock_show_auto_exclude_ui = mocker.patch.object(app_controller.main_ui, 'show_auto_exclude_ui') + app_controller.project_controller.project_context.auto_exclude_manager = mocker.Mock() + app_controller._on_auto_exclude_finished(['recommendation1', 'recommendation2']) + mock_show_auto_exclude_ui.assert_called_once() + +@log_test +def test_on_auto_exclude_error(app_controller, mocker): + mock_show_dashboard = mocker.patch.object(app_controller.main_ui, 'show_dashboard') + mock_critical = mocker.patch('PyQt5.QtWidgets.QMessageBox.critical') + app_controller._on_auto_exclude_error("Test error") + mock_critical.assert_called_once() + mock_show_dashboard.assert_called_once() \ No newline at end of file diff --git a/tests/unit/test_comment_parser.py b/tests/unit/test_comment_parser.py new file mode 100644 index 0000000..59b4645 --- /dev/null +++ b/tests/unit/test_comment_parser.py @@ -0,0 +1,272 @@ +import pytest +import logging +from services.CommentParser import CommentParser, DefaultFileReader, DefaultCommentSyntax + +pytestmark = pytest.mark.unit + +@pytest.fixture +def comment_parser(): + return CommentParser(DefaultFileReader(), DefaultCommentSyntax()) + +def test_single_line_comment(tmpdir, comment_parser): + file_path = tmpdir.join("test_file.py") + file_path.write("# GynTree: This is a test file.") + assert comment_parser.get_file_purpose(str(file_path)) == "This is a test file." + +def test_multiline_comment_js(tmpdir, comment_parser): + file_content = """ + /* + * GynTree: This is a multiline comment + * in a JavaScript file. + */ + """ + file_path = tmpdir.join("test_file.js") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This is a multiline comment\nin a JavaScript file." + +def test_multiline_comment_cpp(tmpdir, comment_parser): + file_content = """ + /* GynTree: This C++ multiline comment + spans multiple lines. + */ + """ + file_path = tmpdir.join("test_file.cpp") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This C++ multiline comment\nspans multiple lines." + +def test_multiline_comment_python(tmpdir, comment_parser): + file_content = ''' + """ + GynTree: This file contains the ProjectManager class, which handles project-related operations. + It manages creating, loading, and saving projects, as well as maintaining project metadata. + """ + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This file contains the ProjectManager class, which handles project-related operations.\nIt manages creating, loading, and saving projects, as well as maintaining project metadata." + +def test_multiline_comment_python_complex(tmpdir, comment_parser): + file_content = ''' + """ + GynTree: ProjectController manages the loading, saving, and setting of projects. + This controller handles the main project-related operations, ensuring that the + current project is properly set up and context is established. It interacts with + the ProjectManager and ProjectContext services to manage the lifecycle of a project + within the application. + + Responsibilities: + - Load and save projects using the ProjectManager. + - Set the current project and initialize the project context. + - Provide project-related information to the main UI. + """ + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + expected = '''ProjectController manages the loading, saving, and setting of projects. +This controller handles the main project-related operations, ensuring that the +current project is properly set up and context is established. It interacts with +the ProjectManager and ProjectContext services to manage the lifecycle of a project +within the application. + +Responsibilities: +- Load and save projects using the ProjectManager. +- Set the current project and initialize the project context. +- Provide project-related information to the main UI.''' + assert comment_parser.get_file_purpose(str(file_path)) == expected + +def test_multiline_comment_python_with_leading_gyntree(tmpdir, comment_parser): + file_content = ''' + """ + GynTree: UIController manages the interaction between the project and the UI. + This controller is responsible for updating and resetting UI components whenever + a new project is loaded or created. It ensures that the correct project information + is displayed and that the user interface reflects the current project state. + + Responsibilities: + - Reset and update UI components like directory tree, exclusions, and analysis. + - Manage exclusion-related UI elements. + - Provide a clean interface for displaying project information in the main UI. + """ + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + expected = '''UIController manages the interaction between the project and the UI. +This controller is responsible for updating and resetting UI components whenever +a new project is loaded or created. It ensures that the correct project information +is displayed and that the user interface reflects the current project state. + +Responsibilities: +- Reset and update UI components like directory tree, exclusions, and analysis. +- Manage exclusion-related UI elements. +- Provide a clean interface for displaying project information in the main UI.''' + assert comment_parser.get_file_purpose(str(file_path)) == expected + +def test_no_comment(tmpdir, comment_parser): + file_path = tmpdir.join("test_file.py") + file_path.write("print('Hello World')") + assert comment_parser.get_file_purpose(str(file_path)) == "No description available" + +def test_multiple_comments(tmpdir, comment_parser): + file_content = """ + # Non-GynTree comment + # GynTree: First GynTree comment + # GynTree: Second GynTree comment + """ + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "First GynTree comment" + +def test_html_comment(tmpdir, comment_parser): + file_path = tmpdir.join("test_file.html") + file_path.write("") + assert comment_parser.get_file_purpose(str(file_path)) == "HTML file comment" + +def test_unsupported_file_type(tmpdir, comment_parser): + file_path = tmpdir.join("test_file.xyz") + file_path.write("GynTree: Not parsed") + assert comment_parser.get_file_purpose(str(file_path)) == "Unsupported file type" + +def test_empty_file(tmpdir, comment_parser): + file_path = tmpdir.join("empty_file.py") + file_path.write("") + assert comment_parser.get_file_purpose(str(file_path)) == "File found empty" + +def test_comment_with_special_characters(tmpdir, comment_parser): + file_path = tmpdir.join("test_file.py") + file_path.write("# GynTree: Special chars: !@#$%^&*()") + assert comment_parser.get_file_purpose(str(file_path)) == "Special chars: !@#$%^&*()" + +def test_case_insensitive_gyntree(tmpdir, comment_parser): + file_content = "# gyntree: Case insensitive test" + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Case insensitive test" + +def test_gyntree_not_at_start_of_line(tmpdir, comment_parser): + file_content = """ + /* + * Introduction + * GynTree: Description text + */ + """ + file_path = tmpdir.join("test_file.js") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Description text" + +def test_unsupported_file_type_logging(tmpdir, comment_parser, caplog): + file_path1 = tmpdir.join("test_file1.xyz") + file_path1.write("GynTree: Not parsed") + file_path2 = tmpdir.join("test_file2.xyz") + file_path2.write("GynTree: Not parsed either") + with caplog.at_level(logging.DEBUG): + comment_parser.get_file_purpose(str(file_path1)) + comment_parser.get_file_purpose(str(file_path2)) + assert len([record for record in caplog.records if "Unsupported file type: .xyz" in record.message]) == 2 + +def test_long_file(tmpdir, comment_parser): + lines = [f'# line {i}' for i in range(1000)] + lines.insert(0, '# GynTree: Description in long file') + file_content = '\n'.join(lines) + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Description in long file" + +def test_multiline_comment_with_code(tmpdir, comment_parser): + file_content = ''' + """ + GynTree: This is a multiline comment with code examples. + + Example: + def example_function(): + return "Hello, World!" + + This function returns a greeting. + """ + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + expected = '''This is a multiline comment with code examples. + +Example: +def example_function(): + return "Hello, World!" + +This function returns a greeting.''' + assert comment_parser.get_file_purpose(str(file_path)) == expected + +def test_comment_after_code(tmpdir, comment_parser): + file_content = ''' + import sys + + # GynTree: This comment comes after some code + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This comment comes after some code" + +def test_multiple_gyntree_comments(tmpdir, comment_parser): + file_content = ''' + # GynTree: First comment + print("Some code") + # GynTree: Second comment + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "First comment" + +def test_python_file_with_code_and_comments(tmpdir, comment_parser): + file_content = ''' + # GynTree: This is a file-level comment + + def example_function(): + """ + GynTree: This is a docstring comment + It should be captured correctly + """ + # This is a regular comment, not a GynTree comment + pass + + # GynTree: Another file-level comment + class ExampleClass: + pass + ''' + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This is a file-level comment" + +def test_comment_parser_self_parse(tmpdir, comment_parser): + file_content = ''' + class CommentParser: + def __init__(self, file_reader, comment_syntax): + self.file_reader = file_reader + self.comment_syntax = comment_syntax + # GynTree: This is a comment within the CommentParser class + self.gyntree_pattern = re.compile(r'gyntree:', re.IGNORECASE) + + def some_other_function(): + # This should not be captured + pass + ''' + file_path = tmpdir.join("comment_parser.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This is a comment within the CommentParser class" + +def test_comment_parser_edge_cases(tmpdir, comment_parser): + file_content = ''' + # This is a regular comment + # GynTree: This is the first GynTree comment + """ + This is a multiline string, not a comment + GynTree: This should not be captured + """ + # GynTree: This is the second GynTree comment + def some_function(): + """ + GynTree: This is a docstring GynTree comment + It should be captured if it's the first GynTree comment in the file + """ + pass + ''' + file_path = tmpdir.join("edge_case_test.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This is the first GynTree comment" diff --git a/tests/test_exclusion_aggregator.py b/tests/unit/test_exclusion_aggregator.py similarity index 50% rename from tests/test_exclusion_aggregator.py rename to tests/unit/test_exclusion_aggregator.py index 74dc99b..4d15a44 100644 --- a/tests/test_exclusion_aggregator.py +++ b/tests/unit/test_exclusion_aggregator.py @@ -3,6 +3,8 @@ from collections import defaultdict from services.ExclusionAggregator import ExclusionAggregator +pytestmark = pytest.mark.unit + def test_aggregate_exclusions(): exclusions = { 'root_exclusions': {os.path.normpath('/path/to/root_exclude')}, @@ -25,7 +27,6 @@ def test_aggregate_exclusions(): assert 'root_exclusions' in aggregated assert 'excluded_dirs' in aggregated assert 'excluded_files' in aggregated - assert os.path.normpath('/path/to/root_exclude') in aggregated['root_exclusions'] assert 'common' in aggregated['excluded_dirs'] assert 'build' in aggregated['excluded_dirs'] @@ -34,11 +35,10 @@ def test_aggregate_exclusions(): assert 'config' in aggregated['excluded_files'] assert 'init' in aggregated['excluded_files'] assert 'other' in aggregated['excluded_files'] - assert '__pycache__' in aggregated['excluded_dirs']['common'] assert '.git' in aggregated['excluded_dirs']['common'] assert 'venv' in aggregated['excluded_dirs']['common'] - assert 'build' in aggregated['excluded_dirs']['build'] + assert 'build' in aggregated['excluded_dirs']['common'] assert '/path/to/custom_dir' in aggregated['excluded_dirs']['other'] assert '/path/to' in aggregated['excluded_files']['cache'] assert '.gitignore' in aggregated['excluded_files']['config'] @@ -69,13 +69,13 @@ def test_format_aggregated_exclusions(): assert " Common: __pycache__, .git, venv" in formatted_lines assert " Build: build, dist" in formatted_lines assert " Other:" in formatted_lines - assert " - /path/to/custom_dir" in formatted_lines + assert " - /path/to/custom_dir" in formatted_lines assert "Files:" in formatted_lines assert " Cache: 1 items" in formatted_lines assert " Config: .dockerignore, .gitignore" in formatted_lines assert " Init: 1 items" in formatted_lines assert " Other:" in formatted_lines - assert " - /path/to/custom_file.txt" in formatted_lines + assert " - /path/to/custom_file.txt" in formatted_lines def test_empty_exclusions(): exclusions = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} @@ -95,91 +95,4 @@ def test_only_common_exclusions(): assert 'common' in aggregated['excluded_dirs'] assert 'config' in aggregated['excluded_files'] assert "Common: __pycache__, .git, venv" in formatted - assert "Config: .gitignore" in formatted - -def test_complex_directory_structure(): - exclusions = { - 'root_exclusions': {'/project/.git'}, - 'excluded_dirs': { - '/project/backend/__pycache__', - '/project/frontend/node_modules', - '/project/docs/build', - '/project/tests/.pytest_cache' - }, - 'excluded_files': { - '/project/.env', - '/project/backend/config.pyc', - '/project/frontend/package-lock.json' - } - } - aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) - formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - - assert len(aggregated['root_exclusions']) == 1 - assert len(aggregated['excluded_dirs']['common']) == 3 # __pycache__, node_modules, .pytest_cache - assert len(aggregated['excluded_dirs']['build']) == 1 # build - assert len(aggregated['excluded_files']['cache']) == 1 # .pyc - assert len(aggregated['excluded_files']['config']) == 1 # .env - assert len(aggregated['excluded_files']['package']) == 1 # package-lock.json - - assert "Root Exclusions:" in formatted - assert " - /project/.git" in formatted - assert "Common: __pycache__, .pytest_cache, node_modules" in formatted - assert "Build: build" in formatted - assert "Cache: 1 items" in formatted - assert "Config: .env" in formatted - assert "Package: package-lock.json" in formatted - -def test_nested_exclusions(): - exclusions = { - 'root_exclusions': {'/project/nested'}, - 'excluded_dirs': { - '/project/nested/inner/__pycache__', - '/project/nested/inner/node_modules' - }, - 'excluded_files': { - '/project/nested/inner/.env', - '/project/nested/inner/config.pyc' - } - } - aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) - formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - - assert len(aggregated['root_exclusions']) == 1 - assert len(aggregated['excluded_dirs']) == 0 # All should be under root exclusions - assert len(aggregated['excluded_files']) == 0 # All should be under root exclusions - - assert "Root Exclusions:" in formatted - assert " - /project/nested" in formatted - assert "Directories:" not in formatted - assert "Files:" not in formatted - -def test_exclusion_priority(): - exclusions = { - 'root_exclusions': {'/project/root_exclude'}, - 'excluded_dirs': { - '/project/root_exclude/subdir', - '/project/other_dir' - }, - 'excluded_files': { - '/project/root_exclude/file.txt', - '/project/other_file.txt' - } - } - aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) - formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - - assert '/project/root_exclude' in aggregated['root_exclusions'] - assert '/project/root_exclude/subdir' not in aggregated['excluded_dirs'].get('other', set()) - assert '/project/root_exclude/file.txt' not in aggregated['excluded_files'].get('other', set()) - assert '/project/other_dir' in aggregated['excluded_dirs'].get('other', set()) - assert '/project/other_file.txt' in aggregated['excluded_files'].get('other', set()) - - assert "Root Exclusions:" in formatted - assert " - /project/root_exclude" in formatted - assert "Directories:" in formatted - assert " Other:" in formatted - assert " - /project/other_dir" in formatted - assert "Files:" in formatted - assert " Other:" in formatted - assert " - /project/other_file.txt" in formatted \ No newline at end of file + assert "Config: .gitignore" in formatted \ No newline at end of file diff --git a/tests/test_project_manager.py b/tests/unit/test_project_manager.py similarity index 55% rename from tests/test_project_manager.py rename to tests/unit/test_project_manager.py index 1799b1f..c0af58c 100644 --- a/tests/test_project_manager.py +++ b/tests/unit/test_project_manager.py @@ -4,6 +4,8 @@ from services.ProjectManager import ProjectManager from models.Project import Project +pytestmark = pytest.mark.unit + @pytest.fixture def project_manager(tmpdir): ProjectManager.projects_dir = str(tmpdir.mkdir("projects")) @@ -18,10 +20,8 @@ def test_create_and_load_project(project_manager): excluded_files=[".env"] ) project_manager.save_project(project) - project_file = os.path.join(ProjectManager.projects_dir, 'test_project.json') assert os.path.exists(project_file) - loaded_project = project_manager.load_project("test_project") assert loaded_project.name == "test_project" assert loaded_project.start_directory == "/test/path" @@ -42,13 +42,11 @@ def test_update_existing_project(project_manager): excluded_files=["old_file"] ) project_manager.save_project(project) - project.start_directory = "/new/path" project.root_exclusions = ["new_root"] project.excluded_dirs = ["new_dir"] project.excluded_files = ["new_file"] project_manager.save_project(project) - loaded_project = project_manager.load_project("update_test") assert loaded_project.start_directory == "/new/path" assert loaded_project.root_exclusions == ["new_root"] @@ -62,7 +60,6 @@ def test_list_projects(project_manager): ] for project in projects: project_manager.save_project(project) - project_list = project_manager.list_projects() assert "project1" in project_list assert "project2" in project_list @@ -70,7 +67,6 @@ def test_list_projects(project_manager): def test_delete_project(project_manager): project = Project(name="to_delete", start_directory="/path") project_manager.save_project(project) - assert project_manager.delete_project("to_delete") assert project_manager.load_project("to_delete") is None @@ -91,10 +87,8 @@ def test_save_project_with_custom_settings(project_manager): excluded_files=["custom_file"] ) project_manager.save_project(project) - with open(os.path.join(ProjectManager.projects_dir, 'custom_settings.json'), 'r') as f: saved_data = json.load(f) - assert saved_data['name'] == "custom_settings" assert saved_data['start_directory'] == "/custom/path" assert saved_data['root_exclusions'] == ["custom_root"] @@ -108,7 +102,6 @@ def test_load_project_with_missing_fields(project_manager): } with open(os.path.join(ProjectManager.projects_dir, 'incomplete_project.json'), 'w') as f: json.dump(incomplete_project_data, f) - loaded_project = project_manager.load_project('incomplete_project') assert loaded_project.name == 'incomplete_project' assert loaded_project.start_directory == '/incomplete/path' @@ -117,4 +110,95 @@ def test_load_project_with_missing_fields(project_manager): assert loaded_project.excluded_files == [] def test_cleanup(project_manager): - project_manager.cleanup() \ No newline at end of file + project_manager.cleanup() + +def test_project_serialization(project_manager): + project = Project( + name="serialization_test", + start_directory="/test/path", + root_exclusions=["node_modules"], + excluded_dirs=["dist"], + excluded_files=[".env"] + ) + serialized = project.to_dict() + assert serialized['name'] == "serialization_test" + assert serialized['start_directory'] == "/test/path" + assert serialized['root_exclusions'] == ["node_modules"] + assert serialized['excluded_dirs'] == ["dist"] + assert serialized['excluded_files'] == [".env"] + +def test_project_deserialization(project_manager): + data = { + 'name': 'deserialization_test', + 'start_directory': '/test/path', + 'root_exclusions': ['node_modules'], + 'excluded_dirs': ['dist'], + 'excluded_files': ['.env'] + } + project = Project.from_dict(data) + assert project.name == 'deserialization_test' + assert project.start_directory == '/test/path' + assert project.root_exclusions == ['node_modules'] + assert project.excluded_dirs == ['dist'] + assert project.excluded_files == ['.env'] + +def test_save_and_load_multiple_projects(project_manager): + projects = [ + Project(name="project1", start_directory="/path1"), + Project(name="project2", start_directory="/path2"), + Project(name="project3", start_directory="/path3") + ] + for project in projects: + project_manager.save_project(project) + + loaded_projects = [project_manager.load_project(p.name) for p in projects] + assert all(loaded is not None for loaded in loaded_projects) + assert [p.name for p in loaded_projects] == ["project1", "project2", "project3"] + +def test_project_file_integrity(project_manager): + project = Project( + name="integrity_test", + start_directory="/test/path", + root_exclusions=["node_modules"], + excluded_dirs=["dist"], + excluded_files=[".env"] + ) + project_manager.save_project(project) + + file_path = os.path.join(ProjectManager.projects_dir, 'integrity_test.json') + with open(file_path, 'r') as f: + file_content = json.load(f) + + assert file_content['name'] == "integrity_test" + assert file_content['start_directory'] == "/test/path" + assert file_content['root_exclusions'] == ["node_modules"] + assert file_content['excluded_dirs'] == ["dist"] + assert file_content['excluded_files'] == [".env"] + +def test_project_overwrite(project_manager): + project = Project(name="overwrite_test", start_directory="/old/path") + project_manager.save_project(project) + + updated_project = Project(name="overwrite_test", start_directory="/new/path") + project_manager.save_project(updated_project) + + loaded_project = project_manager.load_project("overwrite_test") + assert loaded_project.start_directory == "/new/path" + +def test_invalid_project_name_characters(project_manager): + invalid_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'] + for char in invalid_chars: + with pytest.raises(ValueError): + Project(name=f"invalid{char}name", start_directory="/path") + +def test_empty_project_name(project_manager): + with pytest.raises(ValueError): + Project(name="", start_directory="/path") + +def test_project_name_whitespace(project_manager): + with pytest.raises(ValueError): + Project(name=" ", start_directory="/path") + +def test_project_name_too_long(project_manager): + with pytest.raises(ValueError): + Project(name="a" * 256, start_directory="/path") \ No newline at end of file diff --git a/tests/test_project_type_detector.py b/tests/unit/test_project_type_detector.py similarity index 58% rename from tests/test_project_type_detector.py rename to tests/unit/test_project_type_detector.py index 1c69d4f..88568f9 100644 --- a/tests/test_project_type_detector.py +++ b/tests/unit/test_project_type_detector.py @@ -1,12 +1,14 @@ import pytest from services.ProjectTypeDetector import ProjectTypeDetector +pytestmark = pytest.mark.unit + @pytest.fixture def detector(tmpdir): return ProjectTypeDetector(str(tmpdir)) def test_detect_python_project(detector, tmpdir): - tmpdir.join("main.py").write("print('Hello, world!')") + tmpdir.join("main.py").write("print('Hello, World!')") assert detector.detect_python_project() == True def test_detect_web_project(detector, tmpdir): @@ -27,7 +29,7 @@ def test_detect_database_project(detector, tmpdir): assert detector.detect_database_project() == True def test_detect_project_types(detector, tmpdir): - tmpdir.join("main.py").write("print('Hello, world!')") + tmpdir.join("main.py").write("print('Hello, World!')") tmpdir.join("index.html").write("") tmpdir.mkdir("migrations") detected_types = detector.detect_project_types() @@ -42,7 +44,7 @@ def test_no_project_type_detected(detector, tmpdir): assert all(value == False for value in detected_types.values()) def test_multiple_project_types(detector, tmpdir): - tmpdir.join("main.py").write("print('Hello, world!')") + tmpdir.join("main.py").write("print('Hello, World!')") tmpdir.join("package.json").write("{}") tmpdir.join("next.config.js").write("module.exports = {}") tmpdir.mkdir("pages") @@ -53,7 +55,7 @@ def test_multiple_project_types(detector, tmpdir): def test_nested_project_structure(detector, tmpdir): backend = tmpdir.mkdir("backend") - backend.join("main.py").write("print('Hello, world!')") + backend.join("main.py").write("print('Hello, World!')") frontend = tmpdir.mkdir("frontend") frontend.join("package.json").write("{}") detected_types = detector.detect_project_types() @@ -68,4 +70,42 @@ def test_only_config_files(detector, tmpdir): tmpdir.join(".gitignore").write("node_modules") tmpdir.join("README.md").write("# Project README") detected_types = detector.detect_project_types() - assert all(value == False for value in detected_types.values()) \ No newline at end of file + assert all(value == False for value in detected_types.values()) + +''' def test_detect_react_project(detector, tmpdir): + tmpdir.join("package.json").write('{"dependencies": {"react": "^17.0.2"}}') + assert detector.detect_react_project() == True + +def test_detect_vue_project(detector, tmpdir): + tmpdir.join("package.json").write('{"dependencies": {"vue": "^3.0.0"}}') + assert detector.detect_vue_project() == True + +def test_detect_angular_project(detector, tmpdir): + tmpdir.join("angular.json").write("{}") + assert detector.detect_angular_project() == True + +def test_detect_django_project(detector, tmpdir): + tmpdir.join("manage.py").write("#!/usr/bin/env python") + assert detector.detect_django_project() == True + +def test_detect_flask_project(detector, tmpdir): + tmpdir.join("app.py").write("from flask import Flask") + assert detector.detect_flask_project() == True + +def test_detect_ruby_on_rails_project(detector, tmpdir): + tmpdir.mkdir("app") + tmpdir.mkdir("config") + tmpdir.join("Gemfile").write("source 'https://rubygems.org'") + assert detector.detect_ruby_on_rails_project() == True + +def test_detect_laravel_project(detector, tmpdir): + tmpdir.join("artisan").write("#!/usr/bin/env php") + assert detector.detect_laravel_project() == True + +def test_detect_spring_boot_project(detector, tmpdir): + tmpdir.join("pom.xml").write("