diff --git a/docs/stories/3.1.story.md b/docs/stories/3.1.story.md new file mode 100644 index 0000000..84d4560 --- /dev/null +++ b/docs/stories/3.1.story.md @@ -0,0 +1,234 @@ +--- +id: "3.1" +title: "Basic Group Creation and Selection" +type: "Feature" +priority: "High" +status: "Draft" +assigned_agent: "dev" +epic_id: "3" +sprint_id: "" +created_date: "2025-01-20" +updated_date: "2025-01-20" +estimated_effort: "L" +dependencies: ["Complete undo/redo system (Epic 2)"] +tags: ["grouping", "selection", "context-menu", "keyboard-shortcuts", "multi-select"] + +user_type: "End User" +component_area: "Node Grouping System" +technical_complexity: "Medium" +business_value: "High" +--- + +# Story 3.1: Basic Group Creation and Selection + +## Story Description + +**As a** user, **I want** to select multiple nodes and create a group, **so that** I can organize related functionality into manageable containers. + +### Context +This story begins Epic 3 - Core Node Grouping System, building on the complete undo/redo infrastructure from Epic 2. This establishes the fundamental grouping capability that allows users to organize complex graphs into logical containers. This is the foundation for all future grouping features including visual representation, pin generation, and persistence. + +### Background +The node selection system already exists and supports multi-selection via Ctrl+Click and selectedItems(). The command pattern infrastructure from Epic 2 provides the foundation for undoable group operations. This story extends the existing selection and context menu systems to add group creation functionality. + +## Acceptance Criteria + +### AC1: Multi-select nodes using Ctrl+Click and drag-rectangle selection +**Given** multiple nodes exist in the graph +**When** user holds Ctrl and clicks nodes or uses drag-rectangle selection +**Then** multiple nodes are selected and visually highlighted + +### AC2: Right-click context menu "Group Selected" option on valid selections +**Given** multiple nodes are selected +**When** user right-clicks on selection +**Then** context menu shows "Group Selected" option when valid selection exists + +### AC3: Keyboard shortcut Ctrl+G for grouping selected nodes +**Given** multiple nodes are selected +**When** user presses Ctrl+G +**Then** group creation dialog appears for selected nodes + +### AC4: Group creation validation preventing invalid selections (isolated nodes, etc.) +**Given** user attempts to group nodes +**When** selection contains invalid combinations +**Then** validation prevents grouping and shows appropriate error message + +### AC5: Automatic group naming with user override option in creation dialog +**Given** user creates a group +**When** group creation dialog appears +**Then** default name is generated with option for user to customize + +## Tasks / Subtasks + +### Implementation Tasks +- [ ] **Task 1**: Extend existing context menu system for group operations (AC: 2) + - [ ] Subtask 1.1: Add "Group Selected" option to NodeEditorView.show_context_menu() + - [ ] Subtask 1.2: Implement group validation logic for context menu enabling + - [ ] Subtask 1.3: Connect context menu action to group creation workflow + - [ ] Subtask 1.4: Add proper icon and styling for group menu option + +- [ ] **Task 2**: Implement keyboard shortcut system (AC: 3) + - [ ] Subtask 2.1: Add Ctrl+G handling to NodeGraph.keyPressEvent() + - [ ] Subtask 2.2: Integrate with existing keyboard shortcut patterns + - [ ] Subtask 2.3: Ensure proper event propagation and handling + - [ ] Subtask 2.4: Add shortcut documentation and tooltips + +- [ ] **Task 3**: Create Group class and basic data model (AC: 1, 4, 5) + - [ ] Subtask 3.1: Design Group class inheriting from QGraphicsItem + - [ ] Subtask 3.2: Implement group data structure with member nodes tracking + - [ ] Subtask 3.3: Add serialization/deserialization for group persistence + - [ ] Subtask 3.4: Integrate with existing node identification system (UUID) + +- [ ] **Task 4**: Implement group creation validation (AC: 4) + - [ ] Subtask 4.1: Create validation rules for groupable selections + - [ ] Subtask 4.2: Check for minimum node count and connectivity requirements + - [ ] Subtask 4.3: Validate node types and prevent invalid combinations + - [ ] Subtask 4.4: Implement user-friendly error messaging + +- [ ] **Task 5**: Create Group Creation Dialog (AC: 5) + - [ ] Subtask 5.1: Design GroupCreationDialog class inheriting from QDialog + - [ ] Subtask 5.2: Implement automatic name generation based on selected nodes + - [ ] Subtask 5.3: Add user input validation and name override functionality + - [ ] Subtask 5.4: Integrate with existing dialog patterns and styling + +- [ ] **Task 6**: Implement CreateGroupCommand for undo/redo (AC: 1-5) + - [ ] Subtask 6.1: Create CreateGroupCommand following established command pattern + - [ ] Subtask 6.2: Implement proper state preservation for undo operations + - [ ] Subtask 6.3: Handle group creation, node membership, and state transitions + - [ ] Subtask 6.4: Integrate with existing command history system + +### Testing Tasks +- [ ] **Task 7**: Create unit tests for group functionality (AC: 1, 4, 5) + - [ ] Test Group class creation and data management + - [ ] Test group validation logic with various node combinations + - [ ] Test automatic naming generation and customization + - [ ] Test serialization and persistence of group data + +- [ ] **Task 8**: Create integration tests for UI interactions (AC: 2, 3) + - [ ] Test context menu integration and option enabling/disabling + - [ ] Test keyboard shortcut handling and event propagation + - [ ] Test dialog workflow and user input validation + - [ ] Test command pattern integration and undo/redo functionality + +- [ ] **Task 9**: Add user workflow tests (AC: 1-5) + - [ ] Test complete group creation workflow from selection to completion + - [ ] Test error handling and user feedback for invalid selections + - [ ] Test integration with existing selection and clipboard systems + - [ ] Test undo/redo behavior for group operations + +### Documentation Tasks +- [ ] **Task 10**: Update user documentation + - [ ] Document group creation workflow and keyboard shortcuts + - [ ] Add group creation tutorial and best practices + - [ ] Update UI documentation for new context menu options + +## Dev Notes + +### Previous Story Insights +Key learnings from Epic 2 (Undo/Redo System): +- Command pattern integration works smoothly with existing infrastructure +- PySide6 signal/slot connections require careful setup and proper disconnection +- Real-time UI updates need proper event handling and state synchronization +- Context menu patterns are established in NodeEditorView.show_context_menu() +- Testing GUI components requires careful mocking and QTest framework consideration +[Source: docs/stories/2.4.story.md#previous-story-insights] + +### Technical Implementation Details + +#### Existing Selection Infrastructure +- **Selection System**: NodeGraph.selectedItems() provides multi-selection capability +- **Copy System**: copy_selected() method shows pattern for working with selected nodes +- **Key Handling**: keyPressEvent() in NodeGraph handles Ctrl+Z/Y shortcuts, pattern for Ctrl+G +- **Context Menu**: NodeEditorView.show_context_menu() provides right-click menu framework +[Source: src/core/node_graph.py lines 161-195, 105-159; src/ui/editor/node_editor_view.py lines 54-83] + +#### Command System Integration Points +- **Command History**: CommandHistory class in `src/commands/command_history.py` manages undoable operations +- **NodeGraph Integration**: NodeGraph.command_history provides access to command operations +- **Existing Commands**: DeleteNodeCommand, CompositeCommand patterns established +- **State Methods**: execute_command(), undo_last_command(), redo_last_command() available +[Source: src/commands/command_history.py, src/core/node_graph.py lines 54-91] + +#### File Locations & Structure +- **Main Graph**: `src/core/node_graph.py` - Add group creation and management methods +- **View System**: `src/ui/editor/node_editor_view.py` - Extend context menu for group options +- **New Group Class**: `src/core/group.py` - Create new group data model and QGraphicsItem +- **New Dialog**: `src/ui/dialogs/group_creation_dialog.py` - Create group configuration dialog +- **New Command**: `src/commands/create_group_command.py` - Implement undoable group creation +- **Test Files**: `tests/test_group_system.py` (new), extend existing command tests +[Source: docs/architecture/source-tree.md#user-interface, docs/architecture/source-tree.md#core-application-files] + +#### Data Models and Integration +- **Node Objects**: Node class with UUID-based identification system +- **Selection Access**: selectedItems() returns list of QGraphicsItem objects for processing +- **UUID System**: Existing node.uuid pattern for unique identification +- **Serialization**: Existing node.serialize() pattern for data persistence +[Source: src/core/node.py, src/core/node_graph.py lines 161-195] + +#### Context Menu Architecture Patterns +- **Menu Creation**: QMenu and QAction patterns established in show_context_menu() +- **Action Enabling**: Dynamic enabling/disabling based on selection state +- **Icon Integration**: Font Awesome icon creation via create_fa_icon() function +- **Signal Connections**: Action triggered signals connected to methods +[Source: src/ui/editor/node_editor_view.py lines 54-83, src/ui/utils/ui_utils.py] + +#### Dialog Architecture Patterns +- **Base Pattern**: Inherit from QDialog (established pattern in project) +- **Existing Examples**: SettingsDialog, EnvironmentManagerDialog, GraphPropertiesDialog, UndoHistoryDialog +- **Layout**: Use QVBoxLayout and QHBoxLayout for responsive design +- **Integration**: Parent to main window for proper modal behavior +- **Resource Management**: Proper Qt object parenting for automatic cleanup +[Source: docs/architecture/coding-standards.md#widget-structure, docs/architecture/source-tree.md#user-interface] + +#### Group Data Structure Requirements +- **Member Tracking**: List of member node UUIDs for group membership +- **Metadata**: Group name, description, creation timestamp +- **State Management**: Expanded/collapsed state, position, size +- **Serialization**: JSON-compatible format for file persistence +- **Validation**: Rules for valid group compositions and member types + +#### Performance Considerations +- **Selection Performance**: Existing selectedItems() optimized for large graphs +- **Memory Usage**: Group metadata lightweight, references not copies of nodes +- **Response Time**: Group creation must remain under 100ms for responsiveness (NFR1) +- **Large Selections**: Efficient handling of 50+ node selections +[Source: docs/prd.md#non-functional-requirements] + +#### Technical Constraints +- **Windows Platform**: Use Windows-compatible commands and paths, no Unicode characters +- **PySide6 Framework**: Follow established Qt patterns and QGraphicsItem architecture +- **Existing Patterns**: Leverage established command pattern and UUID systems +- **Error Handling**: Graceful handling of invalid selections with user feedback +[Source: docs/architecture/coding-standards.md#prohibited-practices, CLAUDE.md] + +### Testing + +#### Test File Locations +- **Unit Tests**: `tests/test_group_system.py` (new) - Group class and validation logic +- **Integration Tests**: Extend existing `tests/test_command_system.py` for group commands +- **UI Tests**: `tests/test_group_ui_integration.py` (new) - Context menu and dialog testing +- **Test Naming**: Follow `test_{behavior}_when_{condition}` pattern +[Source: docs/architecture/coding-standards.md#testing-standards] + +#### Testing Framework and Patterns +- **Framework**: Python unittest (established pattern in project) +- **Test Runner**: Custom PySide6 GUI test runner for interactive testing +- **Timeout**: All tests must complete within 10 seconds maximum +- **Coverage**: Focus on group creation logic, selection validation, and command integration +[Source: docs/architecture/tech-stack.md#testing-framework, CLAUDE.md#testing] + +#### Specific Testing Requirements +- Test group creation with various node selection combinations +- Test validation logic for invalid selections and edge cases +- Test context menu integration and proper enabling/disabling +- Test keyboard shortcut handling and event propagation +- Test dialog workflow, input validation, and user feedback +- Test command pattern integration and undo/redo functionality +- Test group data serialization and persistence +- Test integration with existing selection and clipboard systems + +## Change Log + +| Date | Version | Description | Author | +| ---------- | ------- | --------------------------- | --------- | +| 2025-01-20 | 1.0 | Initial story creation based on PRD Epic 3 | Bob (SM) | \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/src/commands/create_group_command.py b/src/commands/create_group_command.py new file mode 100644 index 0000000..b9a9d1c --- /dev/null +++ b/src/commands/create_group_command.py @@ -0,0 +1,171 @@ +# create_group_command.py +# Command for creating groups with full undo/redo support and state preservation. + +import sys +import os +import uuid +from typing import List, Dict, Any, Optional +from datetime import datetime + +from PySide6.QtCore import QPointF + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from commands.command_base import CommandBase + + +class CreateGroupCommand(CommandBase): + """Command for creating groups with full state preservation and undo/redo support.""" + + def __init__(self, node_graph, group_properties: Dict[str, Any]): + """ + Initialize create group command. + + Args: + node_graph: The NodeGraph instance + group_properties: Dictionary containing group configuration: + - name: Group name + - description: Group description + - member_node_uuids: List of member node UUIDs + - auto_size: Whether to auto-size the group + - padding: Padding around member nodes + """ + super().__init__(f"Create group '{group_properties.get('name', 'Group')}'") + self.node_graph = node_graph + self.group_properties = group_properties.copy() + self.created_group = None + self.group_uuid = str(uuid.uuid4()) + + # Store creation timestamp + self.group_properties["creation_timestamp"] = datetime.now().isoformat() + + def execute(self) -> bool: + """Create the group and add to graph.""" + try: + # Import here to avoid circular imports + from core.group import Group + + # Validate that all member nodes exist + member_nodes = self._get_member_nodes() + if len(member_nodes) != len(self.group_properties["member_node_uuids"]): + print(f"Warning: Some member nodes not found. Expected {len(self.group_properties['member_node_uuids'])}, found {len(member_nodes)}") + return False + + # Create the group + self.created_group = Group( + name=self.group_properties["name"], + member_node_uuids=self.group_properties["member_node_uuids"] + ) + + # Set properties + self.created_group.uuid = self.group_uuid + self.created_group.description = self.group_properties.get("description", "") + self.created_group.creation_timestamp = self.group_properties["creation_timestamp"] + self.created_group.padding = self.group_properties.get("padding", 20.0) + + # Auto-size if requested + if self.group_properties.get("auto_size", True): + self.created_group.calculate_bounds_from_members(self.node_graph) + + # Add to graph + self.node_graph.addItem(self.created_group) + + # Store reference in node graph (groups list will be added to NodeGraph) + if not hasattr(self.node_graph, 'groups'): + self.node_graph.groups = [] + self.node_graph.groups.append(self.created_group) + + print(f"Created group '{self.created_group.name}' with {len(self.created_group.member_node_uuids)} members") + self._mark_executed() + return True + + except Exception as e: + print(f"Failed to create group: {e}") + return False + + def undo(self) -> bool: + """Remove the created group.""" + if not self.created_group: + return False + + try: + # Remove from groups list if it exists + if hasattr(self.node_graph, 'groups') and self.created_group in self.node_graph.groups: + self.node_graph.groups.remove(self.created_group) + + # Remove from scene if it's still there + if self.created_group.scene() == self.node_graph: + self.node_graph.removeItem(self.created_group) + + print(f"Undid creation of group '{self.created_group.name}'") + self._mark_undone() + return True + + except Exception as e: + print(f"Failed to undo group creation: {e}") + return False + + def redo(self) -> bool: + """Re-execute the group creation.""" + # For redo, we can re-execute if the group still exists + if self.created_group: + try: + # Add back to graph + self.node_graph.addItem(self.created_group) + + # Add back to groups list + if not hasattr(self.node_graph, 'groups'): + self.node_graph.groups = [] + if self.created_group not in self.node_graph.groups: + self.node_graph.groups.append(self.created_group) + + print(f"Redid creation of group '{self.created_group.name}'") + self._mark_executed() + return True + + except Exception as e: + print(f"Failed to redo group creation: {e}") + return False + else: + # If group was destroyed, re-execute from scratch + return self.execute() + + def _get_member_nodes(self) -> List: + """Get the actual node objects for the member UUIDs.""" + member_nodes = [] + member_uuids = set(self.group_properties["member_node_uuids"]) + + for node in self.node_graph.nodes: + if hasattr(node, 'uuid') and node.uuid in member_uuids: + member_nodes.append(node) + + return member_nodes + + def get_memory_usage(self) -> int: + """Estimate memory usage of this command.""" + base_size = 1024 # Base command overhead + name_size = len(self.group_properties.get("name", "")) * 2 + description_size = len(self.group_properties.get("description", "")) * 2 + members_size = len(self.group_properties.get("member_node_uuids", [])) * 40 # UUID strings + return base_size + name_size + description_size + members_size + + def can_merge_with(self, other_command) -> bool: + """Groups creation commands cannot be merged.""" + return False + + def get_affected_items(self) -> List: + """Return list of items affected by this command.""" + affected = [] + + # Include the created group + if self.created_group: + affected.append(self.created_group) + + # Include member nodes + member_nodes = self._get_member_nodes() + affected.extend(member_nodes) + + return affected \ No newline at end of file diff --git a/src/core/group.py b/src/core/group.py new file mode 100644 index 0000000..6ebae9e --- /dev/null +++ b/src/core/group.py @@ -0,0 +1,253 @@ +# group.py +# Group class for managing collections of nodes with visual representation and persistence. + +import sys +import os +import uuid +from typing import List, Dict, Any, Optional + +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem +from PySide6.QtCore import Qt, QRectF, QPointF +from PySide6.QtGui import QPainter, QPen, QBrush, QColor, QFont + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + + +class Group(QGraphicsRectItem): + """ + Represents a visual grouping of nodes that can be organized, collapsed, and persisted. + Inherits from QGraphicsRectItem for visual representation in the scene. + """ + + def __init__(self, name: str = "Group", member_node_uuids: Optional[List[str]] = None, parent=None): + super().__init__(parent) + + # Unique identification + self.uuid = str(uuid.uuid4()) + + # Group metadata + self.name = name + self.description = "" + self.creation_timestamp = "" + + # Member tracking - store UUIDs instead of direct references to avoid circular dependencies + self.member_node_uuids = member_node_uuids or [] + + # Visual state + self.is_expanded = True + self.is_selected = False + + # Dimensions and positioning + self.width = 200.0 + self.height = 150.0 + self.padding = 20.0 + + # Visual styling + self.color_background = QColor(45, 45, 55, 120) # Semi-transparent background + self.color_border = QColor(100, 150, 200, 180) # Blue border + self.color_title_bg = QColor(60, 60, 70, 200) # Title bar background + self.color_title_text = QColor(220, 220, 220) # Title text + self.color_selection = QColor(255, 165, 0, 100) # Orange selection highlight + + # Pens and brushes + self.pen_border = QPen(self.color_border, 2.0) + self.pen_selected = QPen(self.color_selection, 3.0) + self.brush_background = QBrush(self.color_background) + self.brush_title = QBrush(self.color_title_bg) + + # Setup graphics item properties + self.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable) + self.setZValue(-1) # Groups should be behind nodes + + def add_member_node(self, node_uuid: str): + """Add a node UUID to the group membership""" + if node_uuid not in self.member_node_uuids: + self.member_node_uuids.append(node_uuid) + + def remove_member_node(self, node_uuid: str): + """Remove a node UUID from the group membership""" + if node_uuid in self.member_node_uuids: + self.member_node_uuids.remove(node_uuid) + + def get_member_count(self) -> int: + """Get the number of member nodes""" + return len(self.member_node_uuids) + + def is_member(self, node_uuid: str) -> bool: + """Check if a node UUID is a member of this group""" + return node_uuid in self.member_node_uuids + + def calculate_bounds_from_members(self, scene): + """Calculate and update group bounds based on member node positions""" + if not self.member_node_uuids: + return + + # Find all member nodes in the scene + member_nodes = [] + for item in scene.items(): + if hasattr(item, 'uuid') and item.uuid in self.member_node_uuids: + member_nodes.append(item) + + if not member_nodes: + return + + # Calculate bounding rectangle of all member nodes + min_x = float('inf') + min_y = float('inf') + max_x = float('-inf') + max_y = float('-inf') + + for node in member_nodes: + node_rect = node.boundingRect() + node_pos = node.pos() + + node_min_x = node_pos.x() + node_rect.left() + node_min_y = node_pos.y() + node_rect.top() + node_max_x = node_pos.x() + node_rect.right() + node_max_y = node_pos.y() + node_rect.bottom() + + min_x = min(min_x, node_min_x) + min_y = min(min_y, node_min_y) + max_x = max(max_x, node_max_x) + max_y = max(max_y, node_max_y) + + # Add padding around the group + self.width = max_x - min_x + (2 * self.padding) + self.height = max_y - min_y + (2 * self.padding) + + # Position the group to encompass all nodes + self.setPos(min_x - self.padding, min_y - self.padding) + self.setRect(0, 0, self.width, self.height) + + def boundingRect(self) -> QRectF: + """Return the bounding rectangle for this group""" + return QRectF(0, 0, self.width, self.height) + + def paint(self, painter: QPainter, option, widget=None): + """Custom paint method for group visualization""" + # Set up painter + painter.setRenderHint(QPainter.Antialiasing) + + # Draw background + painter.setBrush(self.brush_background) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(self.boundingRect(), 8, 8) + + # Draw title bar + title_height = 30 + title_rect = QRectF(0, 0, self.width, title_height) + painter.setBrush(self.brush_title) + painter.drawRoundedRect(title_rect, 8, 8) + + # Draw border + border_pen = self.pen_selected if self.isSelected() else self.pen_border + painter.setBrush(Qt.NoBrush) + painter.setPen(border_pen) + painter.drawRoundedRect(self.boundingRect(), 8, 8) + + # Draw title text + painter.setPen(self.color_title_text) + font = QFont("Arial", 10, QFont.Bold) + painter.setFont(font) + + title_text = f"{self.name} ({len(self.member_node_uuids)} nodes)" + painter.drawText(title_rect, Qt.AlignCenter, title_text) + + def serialize(self) -> Dict[str, Any]: + """Serialize group data for persistence""" + return { + "uuid": self.uuid, + "name": self.name, + "description": self.description, + "creation_timestamp": self.creation_timestamp, + "member_node_uuids": self.member_node_uuids, + "is_expanded": self.is_expanded, + "position": {"x": self.pos().x(), "y": self.pos().y()}, + "size": {"width": self.width, "height": self.height}, + "padding": self.padding + } + + @classmethod + def deserialize(cls, data: Dict[str, Any]) -> 'Group': + """Create a Group instance from serialized data""" + group = cls( + name=data.get("name", "Group"), + member_node_uuids=data.get("member_node_uuids", []) + ) + + # Restore properties + group.uuid = data.get("uuid", str(uuid.uuid4())) + group.description = data.get("description", "") + group.creation_timestamp = data.get("creation_timestamp", "") + group.is_expanded = data.get("is_expanded", True) + + # Restore position and size + position = data.get("position", {"x": 0, "y": 0}) + group.setPos(position["x"], position["y"]) + + size = data.get("size", {"width": 200, "height": 150}) + group.width = size["width"] + group.height = size["height"] + group.setRect(0, 0, group.width, group.height) + + group.padding = data.get("padding", 20.0) + + return group + + +def validate_group_creation(selected_nodes) -> tuple[bool, str]: + """ + Validate whether the selected nodes can form a valid group. + Returns (is_valid, error_message) + """ + # Must have at least 2 nodes + if len(selected_nodes) < 2: + return False, "Groups require at least 2 nodes" + + # Check for valid node types - try different import paths + try: + from core.node import Node + except ImportError: + try: + from src.core.node import Node + except ImportError: + # Fallback - check for Node-like objects + for node in selected_nodes: + if not hasattr(node, 'uuid') or not hasattr(node, 'title'): + return False, f"Invalid item type: {type(node).__name__}. Only nodes can be grouped." + # Skip type check if we can't import Node class + Node = None + + if Node is not None: + for node in selected_nodes: + if not isinstance(node, Node): + return False, f"Invalid item type: {type(node).__name__}. Only nodes can be grouped." + + # Check for duplicate UUIDs (should not happen, but safety check) + uuids = [node.uuid for node in selected_nodes] + if len(uuids) != len(set(uuids)): + return False, "Duplicate nodes detected in selection" + + # Additional validation rules can be added here + # For example: prevent grouping nodes that are already in other groups + + return True, "" + + +def generate_group_name(selected_nodes) -> str: + """ + Generate a default group name based on selected nodes. + """ + if not selected_nodes: + return "Empty Group" + + # Use the first few node titles + node_titles = [getattr(node, 'title', 'Node') for node in selected_nodes[:3]] + + if len(selected_nodes) <= 3: + return f"Group ({', '.join(node_titles)})" + else: + return f"Group ({', '.join(node_titles)}, +{len(selected_nodes) - 3} more)" \ No newline at end of file diff --git a/src/core/node_graph.py b/src/core/node_graph.py index 7a0194b..6adc10e 100644 --- a/src/core/node_graph.py +++ b/src/core/node_graph.py @@ -118,6 +118,14 @@ def keyPressEvent(self, event: QKeyEvent): print(f"\n=== KEYBOARD REDO (Y) TRIGGERED ===") self.redo_last_command() return + elif event.key() == Qt.Key_G: + print(f"\n=== KEYBOARD GROUP TRIGGERED ===") + selected_nodes = [item for item in self.selectedItems() if isinstance(item, Node)] + if len(selected_nodes) >= 2: + self._create_group_from_selection(selected_nodes) + else: + print(f"DEBUG: Cannot group - need at least 2 nodes, found {len(selected_nodes)}") + return # Handle delete operations if event.key() == Qt.Key_Delete: @@ -158,6 +166,38 @@ def keyPressEvent(self, event: QKeyEvent): print(f"DEBUG: No items selected for deletion") else: super().keyPressEvent(event) + + def _create_group_from_selection(self, selected_nodes): + """Create a group from selected nodes using the group creation dialog""" + # Validate selection + from core.group import validate_group_creation + is_valid, error_message = validate_group_creation(selected_nodes) + + if not is_valid: + from PySide6.QtWidgets import QMessageBox + msg = QMessageBox() + msg.setWindowTitle("Invalid Selection") + msg.setText(f"Cannot create group: {error_message}") + msg.setIcon(QMessageBox.Warning) + msg.exec() + return + + # Show group creation dialog + from ui.dialogs.group_creation_dialog import show_group_creation_dialog + + # Get the main window as parent for the dialog + main_window = None + views = self.views() + if views: + main_window = views[0].window() + + group_properties = show_group_creation_dialog(selected_nodes, main_window) + + if group_properties: + # Create and execute the group creation command + from commands.create_group_command import CreateGroupCommand + command = CreateGroupCommand(self, group_properties) + self.execute_command(command) def copy_selected(self): """Copies selected nodes, their connections, and the graph's requirements to the clipboard.""" diff --git a/src/ui/dialogs/group_creation_dialog.py b/src/ui/dialogs/group_creation_dialog.py new file mode 100644 index 0000000..55cd92e --- /dev/null +++ b/src/ui/dialogs/group_creation_dialog.py @@ -0,0 +1,215 @@ +# group_creation_dialog.py +# Dialog for configuring group creation parameters with automatic name generation and validation. + +import sys +import os +from typing import List, Optional + +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QTextEdit, QPushButton, QDialogButtonBox, + QFormLayout, QSpinBox, QCheckBox, QMessageBox) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +# Add project root to path for cross-package imports +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + + +class GroupCreationDialog(QDialog): + """ + Dialog for creating a new group with automatic name generation and validation. + Allows users to customize group properties before creation. + """ + + def __init__(self, selected_nodes, parent=None): + super().__init__(parent) + self.selected_nodes = selected_nodes + self.setWindowTitle("Create Group") + self.setModal(True) + self.resize(400, 300) + + # Initialize dialog properties + self._setup_ui() + self._generate_default_name() + self._validate_inputs() + + def _setup_ui(self): + """Setup the dialog user interface""" + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Create New Group") + title_font = QFont() + title_font.setBold(True) + title_font.setPointSize(12) + title_label.setFont(title_font) + layout.addWidget(title_label) + + # Group information section + form_layout = QFormLayout() + + # Group name input + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("Enter group name...") + self.name_edit.textChanged.connect(self._validate_inputs) + form_layout.addRow("Group Name:", self.name_edit) + + # Group description input + self.description_edit = QTextEdit() + self.description_edit.setPlaceholderText("Optional description...") + self.description_edit.setMaximumHeight(80) + form_layout.addRow("Description:", self.description_edit) + + # Member count display (read-only) + self.member_count_label = QLabel(str(len(self.selected_nodes))) + form_layout.addRow("Member Nodes:", self.member_count_label) + + layout.addLayout(form_layout) + + # Group options section + options_label = QLabel("Options") + options_font = QFont() + options_font.setBold(True) + options_label.setFont(options_font) + layout.addWidget(options_label) + + options_layout = QVBoxLayout() + + # Auto-size checkbox + self.auto_size_checkbox = QCheckBox("Auto-size group to fit members") + self.auto_size_checkbox.setChecked(True) + options_layout.addWidget(self.auto_size_checkbox) + + # Padding spinbox + padding_layout = QHBoxLayout() + padding_layout.addWidget(QLabel("Padding:")) + self.padding_spinbox = QSpinBox() + self.padding_spinbox.setRange(10, 100) + self.padding_spinbox.setValue(20) + self.padding_spinbox.setSuffix(" px") + padding_layout.addWidget(self.padding_spinbox) + padding_layout.addStretch() + options_layout.addLayout(padding_layout) + + layout.addLayout(options_layout) + + # Member nodes preview + preview_label = QLabel("Selected Nodes:") + preview_font = QFont() + preview_font.setBold(True) + preview_label.setFont(preview_font) + layout.addWidget(preview_label) + + self.nodes_preview = QTextEdit() + self.nodes_preview.setMaximumHeight(100) + self.nodes_preview.setReadOnly(True) + self._update_nodes_preview() + layout.addWidget(self.nodes_preview) + + # Buttons + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.ok_button = button_box.button(QDialogButtonBox.Ok) + self.ok_button.setText("Create Group") + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Store reference for validation + self.button_box = button_box + + def _generate_default_name(self): + """Generate a default group name based on selected nodes""" + from core.group import generate_group_name + default_name = generate_group_name(self.selected_nodes) + self.name_edit.setText(default_name) + + def _update_nodes_preview(self): + """Update the preview of selected nodes""" + if not self.selected_nodes: + self.nodes_preview.setPlainText("No nodes selected") + return + + node_info = [] + for i, node in enumerate(self.selected_nodes, 1): + title = getattr(node, 'title', f'Node {i}') + node_type = type(node).__name__ + node_info.append(f"{i}. {title} ({node_type})") + + self.nodes_preview.setPlainText("\n".join(node_info)) + + def _validate_inputs(self): + """Validate user inputs and enable/disable OK button""" + is_valid = True + error_messages = [] + + # Validate group name + name = self.name_edit.text().strip() + if not name: + is_valid = False + error_messages.append("Group name is required") + elif len(name) > 100: + is_valid = False + error_messages.append("Group name too long (max 100 characters)") + + # Validate member count + if len(self.selected_nodes) < 2: + is_valid = False + error_messages.append("Groups require at least 2 nodes") + + # Additional validation using group validation function + from core.group import validate_group_creation + group_valid, group_error = validate_group_creation(self.selected_nodes) + if not group_valid: + is_valid = False + error_messages.append(group_error) + + # Update UI based on validation + self.ok_button.setEnabled(is_valid) + + # Show tooltip with error messages if invalid + if error_messages: + self.ok_button.setToolTip("Cannot create group:\n" + "\n".join(error_messages)) + else: + self.ok_button.setToolTip("Create group with selected nodes") + + def get_group_properties(self) -> dict: + """Get the configured group properties from the dialog""" + return { + "name": self.name_edit.text().strip(), + "description": self.description_edit.toPlainText().strip(), + "member_node_uuids": [node.uuid for node in self.selected_nodes], + "auto_size": self.auto_size_checkbox.isChecked(), + "padding": self.padding_spinbox.value() + } + + def accept(self): + """Override accept to perform final validation""" + # Final validation before accepting + properties = self.get_group_properties() + + if not properties["name"]: + QMessageBox.warning(self, "Invalid Input", "Group name is required.") + return + + from core.group import validate_group_creation + is_valid, error_message = validate_group_creation(self.selected_nodes) + if not is_valid: + QMessageBox.warning(self, "Invalid Selection", error_message) + return + + super().accept() + + +def show_group_creation_dialog(selected_nodes, parent=None) -> Optional[dict]: + """ + Convenience function to show the group creation dialog and return properties. + Returns None if user cancels, otherwise returns group properties dict. + """ + dialog = GroupCreationDialog(selected_nodes, parent) + + if dialog.exec() == QDialog.Accepted: + return dialog.get_group_properties() + else: + return None \ No newline at end of file diff --git a/src/ui/editor/node_editor_view.py b/src/ui/editor/node_editor_view.py index 94cb85e..ca0c90a 100644 --- a/src/ui/editor/node_editor_view.py +++ b/src/ui/editor/node_editor_view.py @@ -68,20 +68,64 @@ def show_context_menu(self, event: QContextMenuEvent): menu = QMenu(self) + # Get selected items for group operations + selected_items = [item for item in self.scene().selectedItems() if isinstance(item, Node)] + if node: # Context menu for a node properties_action = menu.addAction("Properties") + + # Add group option if multiple nodes are selected + group_action = None + if len(selected_items) >= 2: + group_action = menu.addAction("Group Selected") + # Basic validation - ensure we have valid nodes + if not self._can_group_nodes(selected_items): + group_action.setEnabled(False) + action = menu.exec(event.globalPos()) if action == properties_action: node.show_properties_dialog() + elif action == group_action and group_action: + self._create_group_from_selection(selected_items) else: # Context menu for empty space add_node_action = menu.addAction("Add Node") + + # Add group option if multiple nodes are selected + group_action = None + if len(selected_items) >= 2: + group_action = menu.addAction("Group Selected") + # Basic validation - ensure we have valid nodes + if not self._can_group_nodes(selected_items): + group_action.setEnabled(False) + action = menu.exec(event.globalPos()) if action == add_node_action: main_window = self.window() if hasattr(main_window, "on_add_node"): main_window.on_add_node(scene_pos=scene_pos) + elif action == group_action and group_action: + self._create_group_from_selection(selected_items) + + def _can_group_nodes(self, nodes): + """Basic validation for group creation""" + # Must have at least 2 nodes + if len(nodes) < 2: + return False + + # All items must be valid Node instances + from core.node import Node + for node in nodes: + if not isinstance(node, Node): + return False + + return True + + def _create_group_from_selection(self, selected_nodes): + """Create a group from selected nodes""" + # Delegate to the node graph for actual group creation + self.scene()._create_group_from_selection(selected_nodes) def mousePressEvent(self, event: QMouseEvent): is_pan_button = event.button() in (Qt.RightButton, Qt.MiddleButton) diff --git a/tests/test_group_system.py b/tests/test_group_system.py new file mode 100644 index 0000000..9b95660 --- /dev/null +++ b/tests/test_group_system.py @@ -0,0 +1,370 @@ +# test_group_system.py +# Unit tests for group functionality including creation, validation, and persistence. + +import unittest +import sys +import os +import uuid +from unittest.mock import Mock, MagicMock, patch + +# Add project root to path +project_root = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0, project_root) + +from PySide6.QtWidgets import QApplication, QGraphicsScene +from PySide6.QtCore import QPointF + +# Ensure QApplication exists for Qt widgets +if not QApplication.instance(): + app = QApplication([]) + +from src.core.group import Group, validate_group_creation, generate_group_name +from src.core.node import Node +from src.commands.create_group_command import CreateGroupCommand + + +class TestGroup(unittest.TestCase): + """Test Group class creation and data management.""" + + def setUp(self): + """Set up test fixtures.""" + self.group = Group("Test Group") + self.mock_scene = Mock() + + def test_group_creation_with_defaults(self): + """Test group creation with default parameters.""" + group = Group() + self.assertEqual(group.name, "Group") + self.assertEqual(group.member_node_uuids, []) + self.assertTrue(group.is_expanded) + self.assertIsNotNone(group.uuid) + + def test_group_creation_with_parameters(self): + """Test group creation with custom parameters.""" + member_uuids = ["uuid1", "uuid2", "uuid3"] + group = Group("Custom Group", member_uuids) + + self.assertEqual(group.name, "Custom Group") + self.assertEqual(group.member_node_uuids, member_uuids) + self.assertEqual(group.get_member_count(), 3) + + def test_add_member_node(self): + """Test adding member nodes to group.""" + self.group.add_member_node("uuid1") + self.group.add_member_node("uuid2") + + self.assertEqual(self.group.get_member_count(), 2) + self.assertTrue(self.group.is_member("uuid1")) + self.assertTrue(self.group.is_member("uuid2")) + + # Test adding duplicate + self.group.add_member_node("uuid1") + self.assertEqual(self.group.get_member_count(), 2) # Should not increase + + def test_remove_member_node(self): + """Test removing member nodes from group.""" + self.group.add_member_node("uuid1") + self.group.add_member_node("uuid2") + + self.group.remove_member_node("uuid1") + self.assertEqual(self.group.get_member_count(), 1) + self.assertFalse(self.group.is_member("uuid1")) + self.assertTrue(self.group.is_member("uuid2")) + + # Test removing non-existent member + self.group.remove_member_node("uuid3") + self.assertEqual(self.group.get_member_count(), 1) # Should not change + + def test_group_serialization(self): + """Test group data serialization.""" + self.group.description = "Test description" + self.group.add_member_node("uuid1") + self.group.add_member_node("uuid2") + self.group.setPos(100, 200) + + data = self.group.serialize() + + self.assertEqual(data["name"], "Test Group") + self.assertEqual(data["description"], "Test description") + self.assertEqual(len(data["member_node_uuids"]), 2) + self.assertEqual(data["position"]["x"], 100) + self.assertEqual(data["position"]["y"], 200) + self.assertIn("uuid", data) + + def test_group_deserialization(self): + """Test group data deserialization.""" + data = { + "uuid": "test-uuid", + "name": "Restored Group", + "description": "Restored description", + "member_node_uuids": ["uuid1", "uuid2"], + "is_expanded": False, + "position": {"x": 150, "y": 250}, + "size": {"width": 300, "height": 200}, + "padding": 25.0 + } + + group = Group.deserialize(data) + + self.assertEqual(group.uuid, "test-uuid") + self.assertEqual(group.name, "Restored Group") + self.assertEqual(group.description, "Restored description") + self.assertEqual(group.member_node_uuids, ["uuid1", "uuid2"]) + self.assertFalse(group.is_expanded) + self.assertEqual(group.pos().x(), 150) + self.assertEqual(group.pos().y(), 250) + self.assertEqual(group.width, 300) + self.assertEqual(group.height, 200) + self.assertEqual(group.padding, 25.0) + + +class TestGroupValidation(unittest.TestCase): + """Test group validation logic with various node combinations.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_node1 = Mock(spec=Node) + self.mock_node1.uuid = "uuid1" + self.mock_node1.title = "Node 1" + + self.mock_node2 = Mock(spec=Node) + self.mock_node2.uuid = "uuid2" + self.mock_node2.title = "Node 2" + + self.mock_node3 = Mock(spec=Node) + self.mock_node3.uuid = "uuid3" + self.mock_node3.title = "Node 3" + + def test_valid_group_creation(self): + """Test validation with valid node selection.""" + # Create actual Node instances for realistic testing + from src.core.node import Node + + node1 = Node("Test Node 1") + node1.uuid = "uuid1" + + node2 = Node("Test Node 2") + node2.uuid = "uuid2" + + nodes = [node1, node2] + is_valid, error = validate_group_creation(nodes) + + self.assertTrue(is_valid) + self.assertEqual(error, "") + + def test_insufficient_nodes(self): + """Test validation with insufficient nodes.""" + # Empty selection + is_valid, error = validate_group_creation([]) + self.assertFalse(is_valid) + self.assertIn("at least 2 nodes", error) + + # Single node + is_valid, error = validate_group_creation([self.mock_node1]) + self.assertFalse(is_valid) + self.assertIn("at least 2 nodes", error) + + def test_invalid_node_types(self): + """Test validation with invalid node types.""" + invalid_item = Mock() # Not a Node instance + nodes = [self.mock_node1, invalid_item] + + is_valid, error = validate_group_creation(nodes) + self.assertFalse(is_valid) + self.assertIn("Invalid item type", error) + + def test_duplicate_nodes(self): + """Test validation with duplicate node UUIDs.""" + # Create two nodes with same UUID + from src.core.node import Node + + node1 = Node("Test Node 1") + node1.uuid = "uuid1" + + node2 = Node("Test Node 2") + node2.uuid = "uuid1" # Same UUID as node1 + + nodes = [node1, node2] + is_valid, error = validate_group_creation(nodes) + + self.assertFalse(is_valid) + self.assertIn("Duplicate nodes", error) + + +class TestGroupNameGeneration(unittest.TestCase): + """Test automatic naming generation and customization.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_node1 = Mock(spec=Node) + self.mock_node1.title = "Math Node" + + self.mock_node2 = Mock(spec=Node) + self.mock_node2.title = "String Processor" + + self.mock_node3 = Mock(spec=Node) + self.mock_node3.title = "Data Transformer" + + def test_name_generation_few_nodes(self): + """Test name generation with 3 or fewer nodes.""" + # Two nodes + nodes = [self.mock_node1, self.mock_node2] + name = generate_group_name(nodes) + self.assertIn("Math Node", name) + self.assertIn("String Processor", name) + + # Three nodes + nodes = [self.mock_node1, self.mock_node2, self.mock_node3] + name = generate_group_name(nodes) + self.assertIn("Math Node", name) + self.assertIn("String Processor", name) + self.assertIn("Data Transformer", name) + + def test_name_generation_many_nodes(self): + """Test name generation with more than 3 nodes.""" + mock_node4 = Mock(spec=Node) + mock_node4.title = "Output Handler" + + nodes = [self.mock_node1, self.mock_node2, self.mock_node3, mock_node4] + name = generate_group_name(nodes) + + self.assertIn("Math Node", name) + self.assertIn("String Processor", name) + self.assertIn("Data Transformer", name) + self.assertIn("+1 more", name) + + def test_name_generation_empty_selection(self): + """Test name generation with empty selection.""" + name = generate_group_name([]) + self.assertEqual(name, "Empty Group") + + def test_name_generation_nodes_without_titles(self): + """Test name generation with nodes that have no title attribute.""" + mock_node_no_title = Mock(spec=Node) + # Don't set title attribute + + nodes = [mock_node_no_title, self.mock_node1] + name = generate_group_name(nodes) + + # Should handle missing title gracefully + self.assertIn("Node", name) + self.assertIn("Math Node", name) + + +class TestCreateGroupCommand(unittest.TestCase): + """Test CreateGroupCommand for undo/redo functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_scene = Mock() + self.mock_scene.addItem = Mock() + self.mock_scene.removeItem = Mock() + self.mock_scene.nodes = [] + self.mock_scene.groups = [] + + # Create mock nodes + self.mock_node1 = Mock(spec=Node) + self.mock_node1.uuid = "uuid1" + self.mock_node1.title = "Node 1" + + self.mock_node2 = Mock(spec=Node) + self.mock_node2.uuid = "uuid2" + self.mock_node2.title = "Node 2" + + self.mock_scene.nodes = [self.mock_node1, self.mock_node2] + + self.group_properties = { + "name": "Test Group", + "description": "Test description", + "member_node_uuids": ["uuid1", "uuid2"], + "auto_size": True, + "padding": 20 + } + + def test_command_creation(self): + """Test command creation with valid properties.""" + command = CreateGroupCommand(self.mock_scene, self.group_properties) + + self.assertEqual(command.description, "Create group 'Test Group'") + self.assertEqual(command.group_properties["name"], "Test Group") + self.assertIn("creation_timestamp", command.group_properties) + + @patch('src.core.group.Group') + def test_command_execute(self, mock_group_class): + """Test successful command execution.""" + # Setup mock group instance + mock_group = Mock() + mock_group.name = "Test Group" + mock_group.calculate_bounds_from_members = Mock() + mock_group_class.return_value = mock_group + + command = CreateGroupCommand(self.mock_scene, self.group_properties) + result = command.execute() + + self.assertTrue(result) + self.assertIsNotNone(command.created_group) + self.mock_scene.addItem.assert_called_once_with(mock_group) + self.assertIn(mock_group, self.mock_scene.groups) + + @patch('src.core.group.Group') + def test_command_undo(self, mock_group_class): + """Test successful command undo.""" + # Setup and execute command first + mock_group = Mock() + mock_group.name = "Test Group" + mock_group.scene.return_value = self.mock_scene + mock_group.calculate_bounds_from_members = Mock() + mock_group_class.return_value = mock_group + + command = CreateGroupCommand(self.mock_scene, self.group_properties) + command.execute() + + # Test undo + result = command.undo() + + self.assertTrue(result) + self.mock_scene.removeItem.assert_called_with(mock_group) + self.assertNotIn(mock_group, self.mock_scene.groups) + + @patch('src.core.group.Group') + def test_command_redo(self, mock_group_class): + """Test successful command redo.""" + # Setup, execute, and undo first + mock_group = Mock() + mock_group.name = "Test Group" + mock_group.scene.return_value = self.mock_scene + mock_group.calculate_bounds_from_members = Mock() + mock_group_class.return_value = mock_group + + command = CreateGroupCommand(self.mock_scene, self.group_properties) + command.execute() + command.undo() + + # Reset mock call counts + self.mock_scene.addItem.reset_mock() + + # Test redo + result = command.redo() + + self.assertTrue(result) + self.mock_scene.addItem.assert_called_with(mock_group) + self.assertIn(mock_group, self.mock_scene.groups) + + def test_command_memory_usage(self): + """Test memory usage estimation.""" + command = CreateGroupCommand(self.mock_scene, self.group_properties) + memory_usage = command.get_memory_usage() + + self.assertIsInstance(memory_usage, int) + self.assertGreater(memory_usage, 0) + + def test_command_cannot_merge(self): + """Test that group commands cannot be merged.""" + command1 = CreateGroupCommand(self.mock_scene, self.group_properties) + command2 = CreateGroupCommand(self.mock_scene, self.group_properties) + + self.assertFalse(command1.can_merge_with(command2)) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_group_ui_integration.py b/tests/test_group_ui_integration.py new file mode 100644 index 0000000..29eb98f --- /dev/null +++ b/tests/test_group_ui_integration.py @@ -0,0 +1,336 @@ +# test_group_ui_integration.py +# Integration tests for group UI interactions including context menu and keyboard shortcuts. + +import unittest +import sys +import os +from unittest.mock import Mock, MagicMock, patch + +# Add project root to path +project_root = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0, project_root) + +from PySide6.QtWidgets import QApplication, QMenu +from PySide6.QtCore import Qt, QPointF +from PySide6.QtGui import QKeyEvent, QContextMenuEvent + +# Ensure QApplication exists for Qt widgets +if not QApplication.instance(): + app = QApplication([]) + +from src.ui.editor.node_editor_view import NodeEditorView +from src.core.node_graph import NodeGraph +from src.core.node import Node + + +class TestContextMenuIntegration(unittest.TestCase): + """Test context menu integration and option enabling/disabling.""" + + def setUp(self): + """Set up test fixtures.""" + self.scene = NodeGraph() + self.view = NodeEditorView(self.scene) + + # Create mock nodes + self.node1 = Mock(spec=Node) + self.node1.uuid = "uuid1" + self.node1.title = "Node 1" + + self.node2 = Mock(spec=Node) + self.node2.uuid = "uuid2" + self.node2.title = "Node 2" + + self.node3 = Mock(spec=Node) + self.node3.uuid = "uuid3" + self.node3.title = "Node 3" + + def test_context_menu_no_selection(self): + """Test context menu when no nodes are selected.""" + # Clear selection + self.scene.clearSelection() + + with patch.object(self.view, 'mapToScene', return_value=QPointF(0, 0)): + with patch.object(self.scene, 'itemAt', return_value=None): + with patch('PySide6.QtWidgets.QMenu') as mock_menu_class: + mock_menu = Mock() + mock_menu_class.return_value = mock_menu + mock_menu.addAction.return_value = Mock() + mock_menu.exec.return_value = None + + # Create mock context menu event + event = Mock() + event.pos.return_value = QPointF(0, 0) + event.globalPos.return_value = QPointF(0, 0) + + self.view.show_context_menu(event) + + # Should create menu with "Add Node" but no "Group Selected" + mock_menu.addAction.assert_any_call("Add Node") + + # Verify "Group Selected" was not added for empty selection + actions = [call[0][0] for call in mock_menu.addAction.call_args_list] + self.assertNotIn("Group Selected", actions) + + def test_context_menu_single_selection(self): + """Test context menu when single node is selected.""" + # Set up single selection + self.scene.selectedItems = Mock(return_value=[self.node1]) + + with patch.object(self.view, 'mapToScene', return_value=QPointF(0, 0)): + with patch.object(self.scene, 'itemAt', return_value=self.node1): + with patch('PySide6.QtWidgets.QMenu') as mock_menu_class: + mock_menu = Mock() + mock_menu_class.return_value = mock_menu + mock_menu.addAction.return_value = Mock() + mock_menu.exec.return_value = None + + event = Mock() + event.pos.return_value = QPointF(0, 0) + event.globalPos.return_value = QPointF(0, 0) + + self.view.show_context_menu(event) + + # Should create menu with "Properties" but no "Group Selected" + mock_menu.addAction.assert_any_call("Properties") + + actions = [call[0][0] for call in mock_menu.addAction.call_args_list] + self.assertNotIn("Group Selected", actions) + + def test_context_menu_multiple_selection(self): + """Test context menu when multiple nodes are selected.""" + # Set up multiple selection + self.scene.selectedItems = Mock(return_value=[self.node1, self.node2]) + + with patch.object(self.view, 'mapToScene', return_value=QPointF(0, 0)): + with patch.object(self.scene, 'itemAt', return_value=self.node1): + with patch('PySide6.QtWidgets.QMenu') as mock_menu_class: + mock_menu = Mock() + mock_menu_class.return_value = mock_menu + + # Mock the addAction to return different actions + properties_action = Mock() + group_action = Mock() + mock_menu.addAction.side_effect = [properties_action, group_action] + mock_menu.exec.return_value = None + + # Mock the validation to return True + with patch.object(self.view, '_can_group_nodes', return_value=True): + event = Mock() + event.pos.return_value = QPointF(0, 0) + event.globalPos.return_value = QPointF(0, 0) + + self.view.show_context_menu(event) + + # Should create menu with both "Properties" and "Group Selected" + mock_menu.addAction.assert_any_call("Properties") + mock_menu.addAction.assert_any_call("Group Selected") + + # Group action should be enabled + group_action.setEnabled.assert_not_called() + + def test_context_menu_invalid_selection(self): + """Test context menu when selection cannot be grouped.""" + # Set up selection with invalid items + invalid_item = Mock() # Not a Node + self.scene.selectedItems = Mock(return_value=[self.node1, invalid_item]) + + with patch.object(self.view, 'mapToScene', return_value=QPointF(0, 0)): + with patch.object(self.scene, 'itemAt', return_value=self.node1): + with patch('PySide6.QtWidgets.QMenu') as mock_menu_class: + mock_menu = Mock() + mock_menu_class.return_value = mock_menu + + properties_action = Mock() + group_action = Mock() + mock_menu.addAction.side_effect = [properties_action, group_action] + mock_menu.exec.return_value = None + + # Mock validation to return False + with patch.object(self.view, '_can_group_nodes', return_value=False): + event = Mock() + event.pos.return_value = QPointF(0, 0) + event.globalPos.return_value = QPointF(0, 0) + + self.view.show_context_menu(event) + + # Group action should be disabled + group_action.setEnabled.assert_called_with(False) + + def test_can_group_nodes_validation(self): + """Test the _can_group_nodes validation method.""" + # Valid selection + valid_nodes = [self.node1, self.node2] + self.assertTrue(self.view._can_group_nodes(valid_nodes)) + + # Too few nodes + self.assertFalse(self.view._can_group_nodes([self.node1])) + self.assertFalse(self.view._can_group_nodes([])) + + # Invalid node type + invalid_item = Mock() # Not a Node instance + invalid_selection = [self.node1, invalid_item] + self.assertFalse(self.view._can_group_nodes(invalid_selection)) + + +class TestKeyboardShortcutHandling(unittest.TestCase): + """Test keyboard shortcut handling and event propagation.""" + + def setUp(self): + """Set up test fixtures.""" + self.scene = NodeGraph() + + # Create mock nodes + self.node1 = Mock(spec=Node) + self.node1.uuid = "uuid1" + + self.node2 = Mock(spec=Node) + self.node2.uuid = "uuid2" + + self.scene.nodes = [self.node1, self.node2] + + def test_ctrl_g_with_valid_selection(self): + """Test Ctrl+G with valid node selection.""" + # Set up selection + self.scene.selectedItems = Mock(return_value=[self.node1, self.node2]) + + with patch.object(self.scene, '_create_group_from_selection') as mock_create: + # Create Ctrl+G key event + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_G, Qt.ControlModifier) + + self.scene.keyPressEvent(event) + + # Should call group creation + mock_create.assert_called_once_with([self.node1, self.node2]) + + def test_ctrl_g_with_insufficient_selection(self): + """Test Ctrl+G with insufficient node selection.""" + # Set up single node selection + self.scene.selectedItems = Mock(return_value=[self.node1]) + + with patch.object(self.scene, '_create_group_from_selection') as mock_create: + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_G, Qt.ControlModifier) + + self.scene.keyPressEvent(event) + + # Should not call group creation + mock_create.assert_not_called() + + def test_ctrl_g_with_no_selection(self): + """Test Ctrl+G with no selection.""" + self.scene.selectedItems = Mock(return_value=[]) + + with patch.object(self.scene, '_create_group_from_selection') as mock_create: + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_G, Qt.ControlModifier) + + self.scene.keyPressEvent(event) + + # Should not call group creation + mock_create.assert_not_called() + + def test_other_shortcuts_unaffected(self): + """Test that other keyboard shortcuts still work.""" + # Test Ctrl+Z (undo) + with patch.object(self.scene, 'undo_last_command') as mock_undo: + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Z, Qt.ControlModifier) + self.scene.keyPressEvent(event) + mock_undo.assert_called_once() + + # Test Ctrl+Y (redo) + with patch.object(self.scene, 'redo_last_command') as mock_redo: + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Y, Qt.ControlModifier) + self.scene.keyPressEvent(event) + mock_redo.assert_called_once() + + def test_non_ctrl_g_events_propagated(self): + """Test that non-Ctrl+G events are properly propagated.""" + with patch('PySide6.QtWidgets.QGraphicsScene.keyPressEvent') as mock_super: + # Regular 'G' key without Ctrl + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_G, Qt.NoModifier) + self.scene.keyPressEvent(event) + + # Should call super().keyPressEvent() + mock_super.assert_called_once_with(event) + + +class TestGroupCreationWorkflow(unittest.TestCase): + """Test complete group creation workflow from selection to completion.""" + + def setUp(self): + """Set up test fixtures.""" + self.scene = NodeGraph() + + # Create real Node instances for more realistic testing + self.node1 = Node("Test Node 1") + self.node1.uuid = "uuid1" + + self.node2 = Node("Test Node 2") + self.node2.uuid = "uuid2" + + self.scene.nodes = [self.node1, self.node2] + + @patch('src.ui.dialogs.group_creation_dialog.show_group_creation_dialog') + @patch('src.commands.create_group_command.CreateGroupCommand') + def test_complete_workflow_success(self, mock_command_class, mock_dialog): + """Test complete successful group creation workflow.""" + # Mock dialog to return valid properties + mock_properties = { + "name": "Test Group", + "description": "Test description", + "member_node_uuids": ["uuid1", "uuid2"], + "auto_size": True, + "padding": 20 + } + mock_dialog.return_value = mock_properties + + # Mock command + mock_command = Mock() + mock_command_class.return_value = mock_command + + # Mock execute_command + with patch.object(self.scene, 'execute_command') as mock_execute: + # Test the workflow + self.scene._create_group_from_selection([self.node1, self.node2]) + + # Verify dialog was shown + mock_dialog.assert_called_once() + + # Verify command was created and executed + mock_command_class.assert_called_once_with(self.scene, mock_properties) + mock_execute.assert_called_once_with(mock_command) + + @patch('src.ui.dialogs.group_creation_dialog.show_group_creation_dialog') + def test_workflow_user_cancels(self, mock_dialog): + """Test workflow when user cancels dialog.""" + # Mock dialog to return None (user canceled) + mock_dialog.return_value = None + + with patch.object(self.scene, 'execute_command') as mock_execute: + self.scene._create_group_from_selection([self.node1, self.node2]) + + # Verify dialog was shown + mock_dialog.assert_called_once() + + # Verify no command was executed + mock_execute.assert_not_called() + + @patch('PySide6.QtWidgets.QMessageBox') + def test_workflow_invalid_selection(self, mock_messagebox): + """Test workflow with invalid selection.""" + # Create mock message box + mock_msg = Mock() + mock_messagebox.return_value = mock_msg + + # Test with single node (invalid) + with patch.object(self.scene, 'execute_command') as mock_execute: + self.scene._create_group_from_selection([self.node1]) + + # Should show error message + mock_messagebox.assert_called_once() + mock_msg.exec.assert_called_once() + + # Should not execute command + mock_execute.assert_not_called() + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file