diff --git a/.github/ISSUE_TEMPLATE/ai-task.yml b/.github/ISSUE_TEMPLATE/ai-task.yml new file mode 100644 index 0000000..549e6bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ai-task.yml @@ -0,0 +1,31 @@ +name: AI Task +about: Track implementation tasks that use AI-assisted coding workflows. +title: "[AI] " +labels: ["ai", "automation"] +body: + - type: textarea + id: objective + attributes: + label: Objective + description: Clear success criteria for the AI-assisted task. + validations: + required: true + - type: textarea + id: scope + attributes: + label: Scope + description: In-scope files/modules and explicit out-of-scope boundaries. + validations: + required: true + - type: textarea + id: constraints + attributes: + label: Constraints + description: Product, security, and architectural constraints. + - type: textarea + id: validation + attributes: + label: Validation Plan + description: Tests/lint/manual checks required before merge. + validations: + required: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0ff8779 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,23 @@ +# Copilot Instructions — Argus_Overview + +Start by reading `CLAUDE.md` and keep changes aligned with its constraints. + +## Project Focus +- Python desktop app for EVE multiboxing. +- UI stack: PySide6. +- Platform abstraction is required: Linux and Windows implementations should stay in their platform modules. + +## Guardrails +- Keep business logic out of UI widgets where possible. +- Do not hardcode user-specific paths or machine assumptions. +- Never commit secrets, tokens, or local environment artifacts. + +## Preferred Commands +- `python -m pip install -e ".[dev,linux]"` +- `ruff check .` +- `ruff format .` +- `pytest -q` + +## Validation Expectations +- Run lint + tests before proposing final changes. +- If touching platform-specific code, verify no regressions on the other platform layer. diff --git a/.github/workflows/ai-quality-gate.yml b/.github/workflows/ai-quality-gate.yml new file mode 100644 index 0000000..d8aec63 --- /dev/null +++ b/.github/workflows/ai-quality-gate.yml @@ -0,0 +1,120 @@ +name: AI Quality Gate + +on: + pull_request: + branches: [main, develop, master] + paths: + - "**/*.py" + - "pyproject.toml" + - "requirements*.txt" + - ".pre-commit-config.yaml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ai-quality-gate-${{ github.ref }} + cancel-in-progress: true + +env: + RUFF_VERSION: "0.15.11" + MYPY_VERSION: "1.19.1" + PYTEST_VERSION: "8.4.2" + PYTEST_ASYNCIO_VERSION: "1.2.0" + PYTEST_COV_VERSION: "7.1.0" + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + python_files: ${{ steps.collect.outputs.python_files }} + test_files: ${{ steps.collect.outputs.test_files }} + python_changed: ${{ steps.collect.outputs.python_changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Collect changed Python files + id: collect + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + BASE_SHA="${{ github.event.before }}" + HEAD_SHA="${{ github.sha }}" + fi + + changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + python_files="$(printf '%s\n' "$changed_files" | grep -E '\.py$' || true)" + test_files="$(printf '%s\n' "$python_files" | grep -E '^tests/.*\.py$' || true)" + + if [[ -n "$python_files" ]]; then + echo "python_changed=true" >> "$GITHUB_OUTPUT" + else + echo "python_changed=false" >> "$GITHUB_OUTPUT" + fi + + { + echo "python_files<> "$GITHUB_OUTPUT" + + quality-gate: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.python_changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -e ".[dev,linux]" + pip install "ruff==${RUFF_VERSION}" "mypy==${MYPY_VERSION}" "pytest==${PYTEST_VERSION}" "pytest-asyncio==${PYTEST_ASYNCIO_VERSION}" "pytest-cov==${PYTEST_COV_VERSION}" + + - name: Ruff on changed files + shell: bash + run: | + mapfile -t py_files < <(printf '%s\n' "${{ needs.detect-changes.outputs.python_files }}" | sed '/^\s*$/d') + if [[ ${#py_files[@]} -eq 0 ]]; then + echo "No changed Python files detected." + exit 0 + fi + ruff check "${py_files[@]}" + ruff format --check "${py_files[@]}" + + - name: Mypy on changed source files + shell: bash + run: | + mapfile -t mypy_files < <(printf '%s\n' "${{ needs.detect-changes.outputs.python_files }}" | grep -E '^src/.*\.py$' | grep -v '^src/argus_overview/platform/windows.py$' || true) + if [[ ${#mypy_files[@]} -eq 0 ]]; then + echo "No changed source files for mypy." + exit 0 + fi + mypy "${mypy_files[@]}" --ignore-missing-imports --no-error-summary + + - name: Pytest on changed tests only + shell: bash + env: + QT_QPA_PLATFORM: offscreen + run: | + mapfile -t test_files < <(printf '%s\n' "${{ needs.detect-changes.outputs.test_files }}" | sed '/^\s*$/d') + if [[ ${#test_files[@]} -eq 0 ]]; then + echo "No changed test files detected; skipping pytest in quality gate." + exit 0 + fi + pytest -q "${test_files[@]}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e452f..04ed390 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,13 @@ permissions: contents: read security-events: write +env: + RUFF_VERSION: "0.15.11" + MYPY_VERSION: "1.19.1" + PYTEST_VERSION: "8.4.2" + PYTEST_ASYNCIO_VERSION: "1.2.0" + PYTEST_COV_VERSION: "7.1.0" + jobs: lint: name: Lint & Format @@ -40,7 +47,7 @@ jobs: python-version: '3.12' - name: Install ruff - run: pip install ruff + run: pip install "ruff==${RUFF_VERSION}" - name: Run ruff linter run: ruff check . --output-format=github @@ -65,11 +72,10 @@ jobs: pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - pip install mypy types-requests types-cachetools + pip install "mypy==${MYPY_VERSION}" types-requests types-cachetools - name: Run mypy run: mypy . --ignore-missing-imports --no-error-summary --exclude 'src/argus_overview/platform/windows.py' - continue-on-error: true # Type checking is advisory, not blocking test: name: Test (Python ${{ matrix.python-version }}) @@ -97,7 +103,7 @@ jobs: run: | pip install --upgrade pip pip install -e ".[dev,linux]" - pip install pytest pytest-cov pytest-asyncio + pip install "pytest==${PYTEST_VERSION}" "pytest-asyncio==${PYTEST_ASYNCIO_VERSION}" "pytest-cov==${PYTEST_COV_VERSION}" - name: Run tests with coverage env: diff --git a/.gitignore b/.gitignore index 94441de..ade0ada 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ ENV/ env/ # IDE -.vscode/ +.vscode/* +!.vscode/extensions.json .idea/ *.swp *.swo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3f3b84f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=750"] + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..95f191a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "github.copilot", + "github.copilot-chat", + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.debugpy" + ] +} diff --git a/README.md b/README.md index 1aef7dd..3e818a6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ tar -xzf Argus-Overview-*-Linux.tar.gz && cd argus-overview-linux > Cross-platform support (Windows native, Mac) planned for v3. Community interest in Qt/Rust port welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). +## First Launch + +Argus is designed to get you from launch to usable cycling quickly: + +1. Start Argus with your EVE clients already running when possible. +2. Let Argus auto-import detected clients on startup, or click `Import Windows` in the `Overview` tab. +3. Use `Cycle Control` to tune hotkeys and groups only after the windows you care about are visible. + +If you prefer a hands-on setup flow, use `Add Window` in `Overview` for manual control and disable startup auto-import in `Settings`. + --- ## Screenshots diff --git a/docs/FORUM_POST.md b/docs/FORUM_POST.md index e463a16..3363b7b 100644 --- a/docs/FORUM_POST.md +++ b/docs/FORUM_POST.md @@ -100,7 +100,7 @@ Argus is MIT-licensed open source. No account linking, no third-party servers, n ### Technical Details Built with: -- Python 3.8+ / PySide6 (Qt) +- Python 3.10+ / PySide6 (Qt) - Native X11 window management (python-xlib, wmctrl, xdotool) - 1,500+ automated tests, 96% code coverage diff --git a/docs/REDDIT_LAUNCH.md b/docs/REDDIT_LAUNCH.md index ab480e8..afcf9b5 100644 --- a/docs/REDDIT_LAUNCH.md +++ b/docs/REDDIT_LAUNCH.md @@ -64,7 +64,7 @@ cd Argus_Overview && ./install.sh **Requirements:** - Linux with X11 (Wayland works via XWayland) -- Python 3.8+ +- Python 3.10+ - wmctrl, xdotool, ImageMagick **Windows users:** Check the releases page for the Windows .exe build. diff --git a/install.sh b/install.sh index a68d3a8..ef01ed4 100755 --- a/install.sh +++ b/install.sh @@ -21,8 +21,8 @@ PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}') PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d. -f1) PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d. -f2) -if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 8 ]); then - echo "Error: Python 3.8 or higher is required. Found: $PYTHON_VERSION" +if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then + echo "Error: Python 3.10 or higher is required. Found: $PYTHON_VERSION" exit 1 fi echo "✓ Python $PYTHON_VERSION found" diff --git a/pyproject.toml b/pyproject.toml index 0d25eaf..f7066a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,12 @@ windows = [ "pywin32>=306", ] dev = [ - "ruff", + "ruff==0.15.11", "isort", - "pytest", - "pytest-cov", + "mypy==1.19.1", + "pytest==8.4.2", + "pytest-asyncio==1.2.0", + "pytest-cov==7.1.0", "bandit[toml]", ] @@ -60,7 +62,7 @@ argus-overview = "main:main" [tool.ruff] line-length = 100 -target-version = "py38" +target-version = "py310" exclude = ["windows/", "build/", "dist/", ".venv/"] [tool.ruff.lint] diff --git a/run.sh b/run.sh index 8310185..ed9501f 100755 --- a/run.sh +++ b/run.sh @@ -3,13 +3,23 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$SCRIPT_DIR" -# Use venv python directly if available, otherwise system python +# Use venv python directly if available, otherwise system python. export PYTHONPATH="$SCRIPT_DIR/src:$PYTHONPATH" if [ -f "venv/bin/python3" ]; then - exec venv/bin/python3 src/main.py "$@" + PYTHON_CMD="venv/bin/python3" elif [ -f ".venv/bin/python3" ]; then - exec .venv/bin/python3 src/main.py "$@" + PYTHON_CMD=".venv/bin/python3" else - exec python3 src/main.py "$@" + PYTHON_CMD="python3" fi + +# Fail fast on unsupported runtimes before Qt initializes. +if ! "$PYTHON_CMD" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then + PY_VER="$("$PYTHON_CMD" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' 2>/dev/null || echo "unknown")" + echo "Argus Overview requires Python 3.10+ (detected ${PY_VER} from ${PYTHON_CMD})." >&2 + echo "Recreate your environment with Python 3.10+ and reinstall dependencies." >&2 + exit 1 +fi + +exec "$PYTHON_CMD" src/main.py "$@" diff --git a/src/argus_overview/core/character_manager.py b/src/argus_overview/core/character_manager.py index d2d0c20..466ba02 100644 --- a/src/argus_overview/core/character_manager.py +++ b/src/argus_overview/core/character_manager.py @@ -10,6 +10,8 @@ from datetime import datetime from pathlib import Path +AUTO_CREATED_NOTE = "Auto-created from detected window" + def sanitize_character_name(name: str) -> str: """Sanitize a character name to prevent path traversal and injection. @@ -213,6 +215,34 @@ def add_character(self, character: Character) -> bool: self.logger.info(f"Added character '{character.name}'") return True + def ensure_character(self, char_name: str, auto_save: bool = True) -> bool: + """Ensure a character exists, creating a minimal record if needed.""" + try: + sanitized = sanitize_character_name(char_name) + except ValueError: + self.logger.error(f"Rejected invalid character name: '{char_name}'") + return False + + if sanitized in self.characters: + return True + + self.characters[sanitized] = Character( + name=sanitized, + notes=AUTO_CREATED_NOTE, + ) + if auto_save: + self.save_data() + self.logger.info(f"Auto-created character '{sanitized}' from detected window") + return True + + def get_characters_needing_setup(self) -> list[Character]: + """Return auto-created characters that likely still need review.""" + return [ + char + for char in self.characters.values() + if (char.notes or "").strip() == AUTO_CREATED_NOTE + ] + def remove_character(self, char_name: str) -> bool: """Remove a character""" if char_name not in self.characters: @@ -333,14 +363,15 @@ def get_teams_for_character(self, char_name: str) -> list[Team]: return [team for team in self.teams.values() if char_name in team.characters] # Window Assignment - def assign_window(self, char_name: str, window_id: str) -> bool: + def assign_window(self, char_name: str, window_id: str, auto_save: bool = True) -> bool: """Assign a window ID to a character""" if char_name not in self.characters: return False self.characters[char_name].window_id = window_id self.characters[char_name].last_seen = datetime.now().isoformat() - self.save_data() + if auto_save: + self.save_data() return True def unassign_window(self, char_name: str) -> bool: diff --git a/src/argus_overview/core/cycle_controller.py b/src/argus_overview/core/cycle_controller.py new file mode 100644 index 0000000..84e11f6 --- /dev/null +++ b/src/argus_overview/core/cycle_controller.py @@ -0,0 +1,104 @@ +"""Centralized window activation and cycling behavior.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + + +class CycleController: + """Owns activation policy for preview clicks, hotkeys, and cycling.""" + + def __init__(self, window_ops, settings_manager): + self.window_ops = window_ops + self.settings_manager = settings_manager + self.logger = logging.getLogger(__name__) + + def _is_valid_window_id(self, window_id: str) -> bool: + return self.window_ops._window_mgr.is_valid_window_id(window_id) + + def activate_window(self, window_id: str) -> bool: + """Activate a window and optionally minimize the previously active one.""" + if not self._is_valid_window_id(window_id): + self.logger.warning("Invalid window ID format: %s", window_id) + return False + + try: + auto_minimize = self.settings_manager.get( + "performance.auto_minimize_inactive", + False, + ) + + if auto_minimize: + last_window = self.settings_manager.get_last_activated_window() + if ( + last_window + and last_window != window_id + and self._is_valid_window_id(last_window) + ): + self.window_ops.minimize_window(last_window) + self.logger.info("Auto-minimized previous EVE window: %s", last_window) + + self.settings_manager.set_last_activated_window(window_id) + result = self.window_ops.activate_window(window_id) + + if result: + self.logger.info("Activated window: %s", window_id) + else: + self.logger.warning("Failed to activate window: %s", window_id) + + return bool(result) + + except (OSError, RuntimeError, ValueError) as exc: + self.logger.error("Failed to activate window %s: %s", window_id, exc) + return False + + def activate_character( + self, + char_name: str, + window_lookup: Callable[[str], str | None], + ) -> bool: + """Resolve a character name to a live window and activate it.""" + window_id = window_lookup(char_name) + if not window_id: + self.logger.warning("Character not found: %s", char_name) + return False + return self.activate_window(window_id) + + def cycle( + self, + members: list[str], + current_index: int, + direction: int, + window_lookup: Callable[[str], str | None], + ) -> tuple[int, str | None]: + """Cycle through group members until a live window is activated.""" + if not members: + self.logger.warning("No members in cycling group") + return current_index, None + + next_index = current_index + + for _ in range(len(members)): + next_index = (next_index + direction) % len(members) + char_name = members[next_index] + window_id = window_lookup(char_name) + + if not window_id: + self.logger.warning( + "Character '%s' not found in active windows, skipping", + char_name, + ) + continue + + if self.activate_window(window_id): + self.logger.info( + "Cycled to: %s (%d/%d)", + char_name, + next_index + 1, + len(members), + ) + return next_index, char_name + + self.logger.warning("No active windows found in cycling group") + return current_index, None diff --git a/src/argus_overview/ui/characters_teams_tab.py b/src/argus_overview/ui/characters_teams_tab.py index 709844b..d063ae6 100644 --- a/src/argus_overview/ui/characters_teams_tab.py +++ b/src/argus_overview/ui/characters_teams_tab.py @@ -31,7 +31,7 @@ QWidget, ) -from argus_overview.core.character_manager import Character, Team +from argus_overview.core.character_manager import AUTO_CREATED_NOTE, Character, Team from argus_overview.ui.menu_builder import ToolbarBuilder @@ -41,6 +41,7 @@ class CharacterTable(QTableWidget): character_selected = Signal(str) # character name ROLES = ["DPS", "Miner", "Scout", "Logi", "Hauler", "Trader", "FC", "Booster"] + NEEDS_SETUP_TEXT = "Needs setup" def __init__(self, character_manager, parent=None): super().__init__(parent) @@ -91,18 +92,26 @@ def _do_populate_table(self): self.setRowCount(len(characters)) for row, char in enumerate(characters): + needs_setup = (char.notes or "").strip() == AUTO_CREATED_NOTE + # Name name_item = QTableWidgetItem(char.name) if char.is_main: name_item.setForeground(QColor(66, 135, 245)) # Blue for main + elif needs_setup: + name_item.setForeground(QColor(240, 195, 109)) # Amber for review-needed self.setItem(row, 0, name_item) # Account account_item = QTableWidgetItem(char.account or "") + if needs_setup: + account_item.setForeground(QColor(240, 195, 109)) self.setItem(row, 1, account_item) # Role role_item = QTableWidgetItem(char.role) + if needs_setup: + role_item.setForeground(QColor(240, 195, 109)) self.setItem(row, 2, role_item) # Status @@ -120,7 +129,10 @@ def _do_populate_table(self): self.setItem(row, 4, window_item) # Notes - notes_item = QTableWidgetItem(char.notes or "") + notes_text = self.NEEDS_SETUP_TEXT if needs_setup else (char.notes or "") + notes_item = QTableWidgetItem(notes_text) + if needs_setup: + notes_item.setForeground(QColor(240, 195, 109)) self.setItem(row, 5, notes_item) self.setSortingEnabled(True) @@ -163,6 +175,27 @@ def get_selected_characters(self) -> list[str]: names.append(item.text()) return names + def apply_filters(self, search_text: str = "", needs_setup_only: bool = False): + """Filter visible rows by text and/or setup-needed state.""" + search = search_text.strip().lower() + + for row in range(self.rowCount()): + name_item = self.item(row, 0) + account_item = self.item(row, 1) + role_item = self.item(row, 2) + notes_item = self.item(row, 5) + + name = name_item.text() if name_item else "" + account = account_item.text() if account_item else "" + role = role_item.text() if role_item else "" + notes = notes_item.text() if notes_item else "" + + haystack = " ".join([name, account, role, notes]).lower() + matches_search = not search or search in haystack + matches_setup = (not needs_setup_only) or notes == self.NEEDS_SETUP_TEXT + + self.setRowHidden(row, not (matches_search and matches_setup)) + def _on_selection_changed(self): """Handle selection change""" names = self.get_selected_characters() @@ -669,12 +702,76 @@ def _create_left_panel(self) -> QWidget: layout.addLayout(toolbar_layout) + self.setup_summary_label = QLabel() + self.setup_summary_label.setWordWrap(True) + self.setup_summary_label.setStyleSheet( + "color: #f0c36d; background-color: rgba(255, 140, 0, 0.08); " + "border: 1px solid rgba(255, 140, 0, 0.25); border-radius: 8px; padding: 8px;" + ) + layout.addWidget(self.setup_summary_label) + + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("Filter:")) + + self.character_filter_edit = QLineEdit() + self.character_filter_edit.setPlaceholderText("Search characters, account, role, or notes") + self.character_filter_edit.setClearButtonEnabled(True) + filter_layout.addWidget(self.character_filter_edit) + + self.needs_setup_only_check = QCheckBox("Needs setup only") + filter_layout.addWidget(self.needs_setup_only_check) + layout.addLayout(filter_layout) + # Character table self.character_table = CharacterTable(self.character_manager) layout.addWidget(self.character_table) + self.character_filter_edit.textChanged.connect(self._apply_character_filters) + self.needs_setup_only_check.toggled.connect(self._apply_character_filters) + self._refresh_setup_summary() + self._apply_character_filters() return panel + def _refresh_setup_summary(self): + """Show a lightweight summary for auto-created characters needing review.""" + if not hasattr(self, "setup_summary_label"): + return + + pending = self.character_manager.get_characters_needing_setup() + count = len(pending) + + if count == 0: + self.setup_summary_label.hide() + return + + preview = ", ".join(char.name for char in pending[:3]) + if count > 3: + preview = f"{preview}, +{count - 3} more" + + self.setup_summary_label.setText( + "Review imported characters: " + f"{preview}. Edit account, role, or notes to finish setup." + ) + self.setup_summary_label.show() + + def _apply_character_filters(self): + """Apply current roster filter controls to the character table.""" + if not hasattr(self, "character_table"): + return + + search_text = "" + if hasattr(self, "character_filter_edit"): + search_text = self.character_filter_edit.text() + + needs_setup_only = False + if hasattr(self, "needs_setup_only_check"): + needs_setup_only = self.needs_setup_only_check.isChecked() + + self.character_table.apply_filters( + search_text=search_text, + needs_setup_only=needs_setup_only, + ) + def _create_right_panel(self) -> QWidget: """Create right panel with team builder""" panel = QWidget() @@ -735,6 +832,7 @@ def _add_character(self): char = dialog.get_character() if self.character_manager.add_character(char): self.character_table.populate_table() + self._refresh_setup_summary() self.logger.info(f"Added character: {char.name}") def _edit_character(self): @@ -760,6 +858,7 @@ def _edit_character(self): notes=updated_char.notes, ) self.character_table.populate_table() + self._refresh_setup_summary() self.logger.info(f"Updated character: {char_name}") def _delete_character(self): @@ -781,6 +880,7 @@ def _delete_character(self): if reply == QMessageBox.StandardButton.Yes: if self.character_manager.remove_character(char_name): self.character_table.populate_table() + self._refresh_setup_summary() self.logger.info(f"Deleted character: {char_name}") def _scan_eve_folder(self): @@ -815,6 +915,7 @@ def _scan_eve_folder(self): # Refresh table self.character_table.populate_table() + self._refresh_setup_summary() # Show results QMessageBox.information( @@ -852,3 +953,4 @@ def _on_team_modified(self): def update_character_status(self, char_name: str, window_id: str | None): """Update character status (called from main window)""" self.character_table.update_character_status(char_name, window_id) + self._refresh_setup_summary() diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 40f7735..ecbb494 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -1122,6 +1122,9 @@ class MainTab(QWidget): character_detected = Signal(str, str) # window_id, char_name thumbnails_toggled = Signal(bool) # visible layout_applied = Signal(str) # pattern name + window_focus_requested = Signal(str) # window_id + roster_navigation_requested = Signal() + cycle_control_navigation_requested = Signal() def __init__(self, capture_system, character_manager, settings_manager=None, parent=None): super().__init__(parent) @@ -1139,6 +1142,14 @@ def __init__(self, capture_system, character_manager, settings_manager=None, par if settings_manager else False ) + self._status_override_text: str | None = None + self._recent_import_summary: str | None = None + self._status_override_timer = QTimer(self) + self._status_override_timer.setSingleShot(True) + self._status_override_timer.timeout.connect(self._clear_status_override) + self._import_summary_timer = QTimer(self) + self._import_summary_timer.setSingleShot(True) + self._import_summary_timer.timeout.connect(self._clear_import_completion_summary) # v2.3: Layout controls self._refresh_sources_timer = QTimer() @@ -1189,6 +1200,10 @@ def _setup_ui(self): layout_controls = self._create_layout_controls() layout.addWidget(layout_controls) + # Empty-state onboarding card + self.empty_state_panel = self._create_empty_state_panel() + layout.addWidget(self.empty_state_panel) + # Scroll area for preview frames scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -1206,6 +1221,156 @@ def _setup_ui(self): # Status bar status_bar = self._create_status_bar() layout.addWidget(status_bar) + self._update_empty_state_visibility() + + def _create_empty_state_panel(self) -> QWidget: + """Create a lightweight onboarding card for the empty Overview state.""" + panel = QFrame() + panel.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) + panel.setStyleSheet(""" + QFrame { + background-color: rgba(255, 140, 0, 0.08); + border: 1px solid rgba(255, 140, 0, 0.35); + border-radius: 10px; + } + """) + + layout = QVBoxLayout() + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + panel.setLayout(layout) + + title = QLabel("Get Your Clients Ready") + title.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + self.empty_state_hint = QLabel(self._get_empty_state_message()) + self.empty_state_hint.setWordWrap(True) + self.empty_state_hint.setStyleSheet("color: #b8b8b8;") + layout.addWidget(self.empty_state_hint) + + actions = QHBoxLayout() + actions.setSpacing(8) + + self.empty_state_import_btn = QPushButton("Import Windows") + self.empty_state_import_btn.clicked.connect(self.one_click_import) + actions.addWidget(self.empty_state_import_btn) + + self.empty_state_add_btn = QPushButton("Add Window") + self.empty_state_add_btn.clicked.connect(self.show_add_window_dialog) + actions.addWidget(self.empty_state_add_btn) + actions.addStretch() + + layout.addLayout(actions) + + self.empty_state_summary = QLabel("") + self.empty_state_summary.setWordWrap(True) + self.empty_state_summary.setStyleSheet("color: #f6d38b;") + self.empty_state_summary.hide() + layout.addWidget(self.empty_state_summary) + + next_steps = QHBoxLayout() + next_steps.setSpacing(8) + + self.empty_state_roster_btn = QPushButton("Open Roster") + self.empty_state_roster_btn.clicked.connect(self.roster_navigation_requested.emit) + self.empty_state_roster_btn.hide() + next_steps.addWidget(self.empty_state_roster_btn) + + self.empty_state_cycle_btn = QPushButton("Open Cycle Control") + self.empty_state_cycle_btn.clicked.connect(self.cycle_control_navigation_requested.emit) + self.empty_state_cycle_btn.hide() + next_steps.addWidget(self.empty_state_cycle_btn) + next_steps.addStretch() + layout.addLayout(next_steps) + return panel + + def _set_empty_state_busy(self, busy: bool, message: str | None = None): + """Update onboarding card controls during import/setup actions.""" + import_btn = getattr(self, "empty_state_import_btn", None) + add_btn = getattr(self, "empty_state_add_btn", None) + hint = getattr(self, "empty_state_hint", None) + summary = getattr(self, "empty_state_summary", None) + roster_btn = getattr(self, "empty_state_roster_btn", None) + cycle_btn = getattr(self, "empty_state_cycle_btn", None) + + if import_btn is not None: + import_btn.setEnabled(not busy) + import_btn.setText("Importing..." if busy else "Import Windows") + + if add_btn is not None: + add_btn.setEnabled(not busy) + + if roster_btn is not None: + roster_btn.setVisible(not busy and bool(self._recent_import_summary)) + + if cycle_btn is not None: + cycle_btn.setVisible(not busy and bool(self._recent_import_summary)) + + if summary is not None: + summary.setVisible(bool(self._recent_import_summary) and not busy) + if self._recent_import_summary and not busy: + summary.setText(self._recent_import_summary) + + if hint is not None: + if busy: + hint.setText(message or "Scanning for EVE clients and preparing previews...") + if summary is not None: + summary.hide() + else: + hint.setText(self._get_empty_state_message()) + + def _set_empty_state_progress(self, current: int, total: int, added: int, skipped: int): + """Surface import progress inside the onboarding card.""" + hint = getattr(self, "empty_state_hint", None) + if hint is None: + return + + remaining = max(total - current, 0) + hint.setText( + f"Processing clients... Imported {added} client(s), skipped {skipped}. " + f"{remaining} remaining." + ) + + def _update_empty_state_visibility(self): + """Show onboarding card only when no active preview windows are loaded.""" + panel = getattr(self, "empty_state_panel", None) + if panel is None: + return + + count = self.window_manager.get_active_window_count() + panel.setVisible(count == 0 or bool(self._recent_import_summary)) + + hint = getattr(self, "empty_state_hint", None) + if hint is not None: + hint.setText(self._get_empty_state_message()) + + summary = getattr(self, "empty_state_summary", None) + if summary is not None: + summary.setVisible(bool(self._recent_import_summary)) + if self._recent_import_summary: + summary.setText(self._recent_import_summary) + + roster_btn = getattr(self, "empty_state_roster_btn", None) + if roster_btn is not None: + roster_btn.setVisible(bool(self._recent_import_summary)) + + cycle_btn = getattr(self, "empty_state_cycle_btn", None) + if cycle_btn is not None: + cycle_btn.setVisible(bool(self._recent_import_summary)) + + def _show_import_completion_summary(self, added: int, skipped: int, detected: int): + """Temporarily turn the onboarding card into a post-import next-steps card.""" + self._recent_import_summary = ( + f"Import complete: {added} added, {skipped} skipped, {detected} detected." + ) + self._update_empty_state_visibility() + self._import_summary_timer.start(12000) + + def _clear_import_completion_summary(self): + """Clear temporary post-import guidance from the onboarding card.""" + self._recent_import_summary = None + self._update_empty_state_visibility() def _create_toolbar(self) -> QWidget: """Create toolbar using ActionRegistry (v2.3)""" @@ -1534,64 +1699,112 @@ def refresh_layout_groups(self): self._refresh_layout_sources() self._on_layout_source_changed() - def one_click_import(self): + def _set_status_message(self, message: str, timeout_ms: int = 5000): + """Show a transient status message without losing live status updates.""" + self._status_override_text = message + status_label = getattr(self, "status_label", None) + if status_label is not None: + status_label.setText(message) + timer = getattr(self, "_status_override_timer", None) + if timer is not None: + timer.start(timeout_ms) + + def _clear_status_override(self): + """Restore live status after a transient message expires.""" + self._status_override_text = None + self._update_status() + + def _get_empty_state_message(self) -> str: + """Return the most helpful empty-state guidance for the overview tab.""" + settings_manager = getattr(self, "settings_manager", None) + if settings_manager and settings_manager.get("general.show_setup_guidance", True): + if settings_manager.get("general.auto_discovery", True): + return ( + "No windows in preview. Click 'Import Windows' to start, or launch EVE and " + "let auto-discovery add clients for you." + ) + return ( + "No windows in preview. Click 'Import Windows' for fastest setup, or use " + "'Add Window' for manual control." + ) + return "No windows in preview" + + def one_click_import(self, show_dialogs: bool = True) -> tuple[int, int, int]: """ v2.2 One-Click Import: Scan and import all EVE windows automatically """ self.logger.info("Starting one-click import...") + self._set_empty_state_busy(True) - # Scan for EVE windows - eve_windows = scan_eve_windows() + try: + # Scan for EVE windows + eve_windows = scan_eve_windows() - if not eve_windows: - QMessageBox.information( - self, - "No EVE Windows Found", - "No EVE Online windows were detected.\n\n" - "Make sure EVE Online clients are running and visible.", + if not eve_windows: + self._set_status_message( + "No EVE windows detected yet. Launch clients, then try Import Windows again.", + timeout_ms=7000, + ) + if show_dialogs: + QMessageBox.information( + self, + "No EVE Windows Found", + "No EVE Online windows were detected.\n\n" + "Make sure EVE Online clients are running and visible.", + ) + return 0, 0, 0 + + self._set_empty_state_busy( + True, + f"Found {len(eve_windows)} EVE client(s). Preparing previews...", ) - return - # Count how many are new - added_count = 0 - skipped_count = 0 + # Count how many are new + added_count = 0 + skipped_count = 0 + + for index, (window_id, _window_title, char_name) in enumerate(eve_windows, start=1): + # Skip if already in preview + if window_id in self.window_manager.preview_frames: + skipped_count += 1 + self._set_empty_state_progress( + current=index, + total=len(eve_windows), + added=added_count, + skipped=skipped_count, + ) + continue - for window_id, _window_title, char_name in eve_windows: - # Skip if already in preview - if window_id in self.window_manager.preview_frames: - skipped_count += 1 - continue + if self.import_detected_window(window_id, char_name): + added_count += 1 - # Add to window manager - frame = self.window_manager.add_window(window_id, char_name) - if frame: - # Connect signals - frame.window_activated.connect( - self._on_window_activated, Qt.ConnectionType.UniqueConnection - ) - frame.window_removed.connect( - self._on_window_removed, Qt.ConnectionType.UniqueConnection + self._set_empty_state_progress( + current=index, + total=len(eve_windows), + added=added_count, + skipped=skipped_count, ) - # Add to layout - self.preview_layout.addWidget(frame) - added_count += 1 - - # Emit character detected signal - self.character_detected.emit(window_id, char_name) - - # Show result - if added_count > 0: - self.status_label.setText(f"Imported {added_count} character(s)") - self.logger.info( - f"One-click import: Added {added_count}, skipped {skipped_count} duplicates" - ) - elif skipped_count > 0: - self.status_label.setText(f"All {skipped_count} EVE windows already imported") - else: - self.status_label.setText("No new EVE windows found") + # Show result + if added_count > 0: + self._set_status_message(f"Imported {added_count} character(s)") + self._show_import_completion_summary( + added=added_count, + skipped=skipped_count, + detected=len(eve_windows), + ) + self.logger.info( + f"One-click import: Added {added_count}, skipped {skipped_count} duplicates" + ) + elif skipped_count > 0: + self._set_status_message(f"All {skipped_count} EVE windows already imported") + else: + self._set_status_message("No new EVE windows found") - self._update_status() + self._update_status() + return added_count, skipped_count, len(eve_windows) + finally: + self._set_empty_state_busy(False) def _toggle_lock(self): """Toggle thumbnail position lock""" @@ -1661,6 +1874,33 @@ def _get_available_windows(self) -> list: (wid, title) for wid, title in windows if wid not in self.window_manager.preview_frames ] + def import_detected_window( + self, + window_id: str, + character_name: str, + *, + emit_character_detected: bool = True, + ) -> bool: + """Add a detected character window using the shared preview-import path.""" + frame = self.window_manager.add_window(window_id, character_name) + if not frame: + return False + + frame.window_activated.connect( + self._on_window_activated, + Qt.ConnectionType.UniqueConnection, + ) + frame.window_removed.connect( + self._on_window_removed, + Qt.ConnectionType.UniqueConnection, + ) + self.preview_layout.addWidget(frame) + + if emit_character_detected: + self.character_detected.emit(window_id, character_name) + + return True + def _add_window_to_preview(self, window_id: str, window_title: str) -> bool: """Add a single window to preview. Returns True if successful.""" # Extract character name from window title @@ -1674,21 +1914,13 @@ def _add_window_to_preview(self, window_id: str, window_title: str) -> bool: for detected_name, wid in assignments.items(): if wid == window_id: char_name = detected_name - self.character_detected.emit(window_id, char_name) break - # Add to window manager - frame = self.window_manager.add_window(window_id, char_name) - if frame: - frame.window_activated.connect( - self._on_window_activated, Qt.ConnectionType.UniqueConnection - ) - frame.window_removed.connect( - self._on_window_removed, Qt.ConnectionType.UniqueConnection - ) - self.preview_layout.addWidget(frame) - return True - return False + return self.import_detected_window( + window_id, + char_name, + emit_character_detected=True, + ) def show_add_window_dialog(self): """Show dialog to add windows""" @@ -1745,39 +1977,8 @@ def show_add_window_dialog(self): self._update_status() def _on_window_activated(self, window_id: str): - """Handle window activation with optional auto-minimize of previous window""" - from argus_overview.utils.window_utils import run_x11_subprocess - - try: - # Check if auto-minimize is enabled - auto_minimize = ( - self.settings_manager.get("performance.auto_minimize_inactive", False) - if self.settings_manager - else False - ) - - if auto_minimize and self.settings_manager: - # Get the last activated EVE window - last_window = self.settings_manager.get_last_activated_window() - if last_window and last_window != window_id: - # Minimize the previous EVE window - try: - run_x11_subprocess(["xdotool", "windowminimize", last_window], timeout=2) - self.logger.info(f"Auto-minimized previous EVE window: {last_window}") - except (OSError, subprocess.SubprocessError) as e: - self.logger.warning(f"Failed to auto-minimize window {last_window}: {e}") - - # Track this as the last activated EVE window - if self.settings_manager: - self.settings_manager.set_last_activated_window(window_id) - - result = self.capture_system.activate_window(window_id) - if result: - self.logger.info(f"Activated window: {window_id}") - else: - self.logger.warning(f"Failed to activate window: {window_id}") - except (OSError, RuntimeError, ValueError) as e: - self.logger.error(f"Error activating window: {e}") + """Forward window focus intent to the main window/controller.""" + self.window_focus_requested.emit(window_id) def _on_window_removed(self, window_id: str): """Handle window removal — disconnect frame signals before deletion""" @@ -1914,9 +2115,14 @@ def _update_status(self): """Update status bar""" count = self.window_manager.get_active_window_count() self.active_count_label.setText(f"Active: {count}") + self._update_empty_state_visibility() + + if getattr(self, "_status_override_text", None): + self.status_label.setText(self._status_override_text) + return if count == 0: - self.status_label.setText("No windows in preview - Click 'Add Window' to start") + self.status_label.setText(self._get_empty_state_message()) else: self.status_label.setText( f"Capturing {count} window(s) at {self.window_manager.refresh_rate} FPS" @@ -1968,12 +2174,9 @@ def _activate_window_by_index(self, index: int): windows = list(self.window_manager.preview_frames.items()) if 0 <= index < len(windows): window_id, frame = windows[index] - # Activate the window - if self.capture_system.activate_window(window_id): - self.logger.info(f"Activated window {index + 1}: {frame.character_name}") - self.status_label.setText(f"Activated: {frame.character_name}") - else: - self.logger.warning(f"Failed to activate window {index + 1}") + self.window_focus_requested.emit(window_id) + self.logger.info(f"Requested activation for window {index + 1}: {frame.character_name}") + self.status_label.setText(f"Activating: {frame.character_name}") else: self.logger.debug( f"Window index {index + 1} out of range (have {len(windows)} windows)" diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index 7451d39..94d7612 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -58,6 +58,7 @@ # Import version and core modules from argus_overview import __version__ from argus_overview.core.character_manager import CharacterManager +from argus_overview.core.cycle_controller import CycleController from argus_overview.core.discovery import AutoDiscovery from argus_overview.core.eve_settings_sync import EVESettingsSync from argus_overview.core.hotkey_manager import HotkeyManager @@ -78,6 +79,21 @@ def __init__(self): self.logger = logging.getLogger(__name__) self.setWindowTitle(f"Argus Overview v{__version__}") self.setMinimumSize(1000, 700) + self._is_quitting = False + self._auto_discovery_connected = False + self._tab_indexes: dict[str, int] = {} + self._bulk_import_active = False + self._bulk_import_dirty_characters = False + self._bulk_import_dirty_groups = False + self._pending_discovery_names: list[str] = [] + self._discovery_notification_timer = QTimer(self) + self._discovery_notification_timer.setSingleShot(True) + self._discovery_notification_timer.setInterval(1200) + self._discovery_notification_timer.timeout.connect(self._flush_discovery_notifications) + self._status_refresh_timer = QTimer(self) + self._status_refresh_timer.setSingleShot(True) + self._status_refresh_timer.setInterval(150) + self._status_refresh_timer.timeout.connect(self._flush_main_tab_status_refresh) # Set window icon self._set_window_icon() @@ -93,6 +109,7 @@ def __init__(self): # Initialize capture system with settings (after settings_manager) capture_workers = self.settings_manager.get("performance.capture_workers", 4) self.capture_system = WindowCaptureThreaded(max_workers=capture_workers) + self.cycle_controller = CycleController(self.capture_system, self.settings_manager) # v2.2: Auto-discovery self.auto_discovery = AutoDiscovery( @@ -156,10 +173,8 @@ def __init__(self): self.hotkey_manager.start() # v2.2: Start auto-discovery if enabled - if self.settings_manager.get("general.auto_discovery", True): - self.auto_discovery.new_character_found.connect(self._on_new_character_discovered) - self.auto_discovery.character_gone.connect(self._on_character_gone) - self.auto_discovery.start() + self._ensure_auto_discovery_state() + QTimer.singleShot(250, self._run_startup_assistant) self.logger.info("Main window v2.2 initialized successfully") @@ -186,6 +201,52 @@ def _create_system_tray(self): self.system_tray.show() self.logger.info("System tray initialized (ActionRegistry)") + def _connect_auto_discovery(self): + """Ensure auto-discovery signals are connected exactly once.""" + if self._auto_discovery_connected: + return + + self.auto_discovery.new_character_found.connect( + self._on_new_character_discovered, + Qt.ConnectionType.UniqueConnection, + ) + self.auto_discovery.character_gone.connect( + self._on_character_gone, + Qt.ConnectionType.UniqueConnection, + ) + self._auto_discovery_connected = True + + def _disconnect_auto_discovery(self): + """Disconnect auto-discovery signals if they were connected.""" + if not self._auto_discovery_connected: + return + + try: + self.auto_discovery.new_character_found.disconnect(self._on_new_character_discovered) + except (RuntimeError, TypeError): + pass + + try: + self.auto_discovery.character_gone.disconnect(self._on_character_gone) + except (RuntimeError, TypeError): + pass + + self._auto_discovery_connected = False + + def _ensure_auto_discovery_state(self): + """Synchronize auto-discovery wiring and runtime state with settings.""" + enabled = self.settings_manager.get("general.auto_discovery", True) + interval = self.settings_manager.get("general.auto_discovery_interval", 5) + + self.auto_discovery.set_interval(interval) + + if enabled: + self._connect_auto_discovery() + if not self.auto_discovery.scan_timer.isActive(): + self.auto_discovery.start() + else: + self.auto_discovery.stop() + def _register_hotkeys(self): """Register global hotkeys (v2.2)""" # Minimize all @@ -281,20 +342,74 @@ def _get_cycling_group_members(self) -> list: return members - def _add_to_default_cycling_group(self, char_name: str): + def _add_to_default_cycling_group(self, char_name: str, auto_save: bool = True): """Add a character to the Default cycling group if not already present.""" groups = self.settings_manager.get("cycling_groups", {}) if "Default" not in groups: groups["Default"] = [] if char_name not in groups["Default"]: groups["Default"].append(char_name) - self.settings_manager.set("cycling_groups", groups, auto_save=True) + self.settings_manager.set("cycling_groups", groups, auto_save=auto_save) # Refresh the hotkeys tab UI if it exists if hasattr(self, "hotkeys_tab") and self.hotkeys_tab.current_group == "Default": self.hotkeys_tab._load_group_members("Default") self.hotkeys_tab.cycling_groups = groups self.logger.info(f"Added {char_name} to Default cycling group") + def _begin_bulk_import(self): + """Batch persistence during startup import bursts.""" + self._bulk_import_active = True + self._bulk_import_dirty_characters = False + self._bulk_import_dirty_groups = False + + def _finish_bulk_import(self): + """Flush any deferred saves from a bulk import session.""" + if self._bulk_import_dirty_characters: + self.character_manager.save_data() + if self._bulk_import_dirty_groups: + self.settings_manager.save_settings() + self._bulk_import_active = False + self._bulk_import_dirty_characters = False + self._bulk_import_dirty_groups = False + + def _queue_discovery_notification(self, char_name: str): + """Batch rapid auto-discovery notifications into one tray update.""" + if char_name not in self._pending_discovery_names: + self._pending_discovery_names.append(char_name) + self._discovery_notification_timer.start() + + def _flush_discovery_notifications(self): + """Show a single notification for any queued discoveries.""" + if not self._pending_discovery_names: + return + + names = self._pending_discovery_names[:] + self._pending_discovery_names.clear() + + if len(names) == 1: + title = "New Character Detected" + message = f"Added: {names[0]}" + else: + title = "New Characters Detected" + preview = ", ".join(names[:3]) + if len(names) > 3: + preview = f"{preview}, +{len(names) - 3} more" + message = preview + + self.system_tray.show_notification(title, message) + + def _queue_main_tab_status_refresh(self): + """Debounce expensive overview status refreshes during burst discovery.""" + if hasattr(self, "_status_refresh_timer"): + self._status_refresh_timer.start() + else: + self._flush_main_tab_status_refresh() + + def _flush_main_tab_status_refresh(self): + """Refresh overview status if the main tab is available.""" + if hasattr(self, "main_tab"): + self.main_tab._update_status() + def _get_window_id_for_character(self, char_name: str) -> str | None: """Get window ID for a character name""" if hasattr(self, "main_tab") and hasattr(self.main_tab, "window_manager"): @@ -310,26 +425,12 @@ def _cycle_window(self, direction: int = 1): direction: 1 for next, -1 for previous """ members = self._get_cycling_group_members() - if not members: - self.logger.warning("No members in cycling group") - return - - # Try each member at most once to avoid infinite loop - for _ in range(len(members)): - self.cycling_index = (self.cycling_index + direction) % len(members) - char_name = members[self.cycling_index] - - window_id = self._get_window_id_for_character(char_name) - if window_id: - self._activate_window(window_id) - self.logger.info( - f"Cycled to: {char_name} ({self.cycling_index + 1}/{len(members)})" - ) - return - - self.logger.warning(f"Character '{char_name}' not found in active windows, skipping") - - self.logger.warning("No active windows found in cycling group") + self.cycling_index, _ = self.cycle_controller.cycle( + members=members, + current_index=self.cycling_index, + direction=direction, + window_lookup=self._get_window_id_for_character, + ) @Slot() def _cycle_next(self): @@ -350,37 +451,8 @@ def _perform_cycle(self): self._pending_cycle_direction = None def _activate_window(self, window_id: str): - """Activate a window by ID, optionally minimizing previous EVE window. - - Uses the platform abstraction layer instead of raw subprocess calls. - """ - if not self.capture_system._window_mgr.is_valid_window_id(window_id): - self.logger.warning(f"Invalid window ID format: {window_id}") - return - - try: - # Check if auto-minimize is enabled - auto_minimize = self.settings_manager.get("performance.auto_minimize_inactive", False) - - if auto_minimize: - # Get the last activated EVE window - last_eve_window = self.settings_manager.get_last_activated_window() - - if ( - last_eve_window - and last_eve_window != window_id - and self.capture_system._window_mgr.is_valid_window_id(last_eve_window) - ): - self.capture_system.minimize_window(last_eve_window) - self.logger.info(f"Auto-minimized previous EVE window: {last_eve_window}") - - # Track this as the last activated EVE window - self.settings_manager.set_last_activated_window(window_id) - - # Activate the new window - self.capture_system.activate_window(window_id) - except (OSError, RuntimeError) as e: - self.logger.error(f"Failed to activate window {window_id}: {e}") + """Activate a window by ID.""" + self.cycle_controller.activate_window(window_id) @Slot(str) def _on_profile_selected(self, profile_name: str): @@ -396,9 +468,27 @@ def _show_settings(self): """Show settings tab""" self.show() self.raise_() - self.tabs.setCurrentIndex( - 4 - ) # Settings tab (Overview=0, Cycle Control=1, Roster=2, Sync=3, Settings=4) + settings_index = self._tab_indexes.get("Settings") + if settings_index is not None: + self.tabs.setCurrentIndex(settings_index) + + @Slot() + def _show_roster(self): + """Show roster tab.""" + self.show() + self.raise_() + roster_index = self._tab_indexes.get("Roster") + if roster_index is not None: + self.tabs.setCurrentIndex(roster_index) + + @Slot() + def _show_cycle_control(self): + """Show cycle control tab.""" + self.show() + self.raise_() + cycle_index = self._tab_indexes.get("Cycle Control") + if cycle_index is not None: + self.tabs.setCurrentIndex(cycle_index) @Slot() def _reload_config(self): @@ -411,15 +501,7 @@ def _reload_config(self): theme = self.settings_manager.get("appearance.theme", "dark") self.theme_manager.apply_theme(theme) - # Update auto-discovery - if self.settings_manager.get("general.auto_discovery", True): - self.auto_discovery.set_interval( - self.settings_manager.get("general.auto_discovery_interval", 5) - ) - if not self.auto_discovery.scan_timer.isActive(): - self.auto_discovery.start() - else: - self.auto_discovery.stop() + self._ensure_auto_discovery_state() self.system_tray.show_notification("Config Reloaded", "Settings have been reloaded") self.logger.info("Configuration reloaded successfully") @@ -428,7 +510,42 @@ def _reload_config(self): def _quit_application(self): """Quit the application""" self.logger.info("Quit requested from tray") - QApplication.quit() + self._is_quitting = True + self.close() + + def _run_startup_assistant(self): + """Improve first-use UX without interrupting startup.""" + if not hasattr(self, "main_tab") or not hasattr(self.main_tab, "window_manager"): + return + + if self.main_tab.window_manager.get_active_window_count() > 0: + return + + if self.settings_manager.get("general.auto_import_on_startup", True): + self._begin_bulk_import() + try: + added_count, _skipped_count, _detected_count = self.main_tab.one_click_import( + show_dialogs=False + ) + finally: + self._finish_bulk_import() + if added_count > 0: + self.statusBar().showMessage( + f"Imported {added_count} EVE window(s) automatically", + 6000, + ) + if self.settings_manager.get("general.show_notifications", True): + self.system_tray.show_notification( + "Setup Complete", + f"Imported {added_count} running EVE client(s)", + ) + return + + if self.settings_manager.get("general.show_setup_guidance", True): + self.statusBar().showMessage( + "Quick start: click Import Windows to detect running EVE clients automatically", + 8000, + ) def _apply_to_all_windows(self, action: str): """Apply action to all EVE windows @@ -462,14 +579,7 @@ def _restore_all_windows(self): def _activate_character(self, char_name: str): """Activate window for a specific character (v2.2 per-character hotkeys)""" - if hasattr(self, "main_tab"): - for window_id, frame in self.main_tab.window_manager.preview_frames.items(): - if frame.character_name == char_name: - # Use _activate_window which has auto-minimize logic - self._activate_window(window_id) - self.logger.info(f"Activated character: {char_name}") - return - self.logger.warning(f"Character not found: {char_name}") + self.cycle_controller.activate_character(char_name, self._get_window_id_for_character) @Slot(str, str, str) def _on_new_character_discovered(self, char_name: str, window_id: str, window_title: str): @@ -479,27 +589,12 @@ def _on_new_character_discovered(self, char_name: str, window_id: str, window_ti # Add to main tab if not already there if hasattr(self, "main_tab"): if window_id not in self.main_tab.window_manager.preview_frames: - frame = self.main_tab.window_manager.add_window(window_id, char_name) - if frame: - frame.window_activated.connect( - self.main_tab._on_window_activated, - Qt.ConnectionType.UniqueConnection, - ) - frame.window_removed.connect( - self.main_tab._on_window_removed, - Qt.ConnectionType.UniqueConnection, - ) - self.main_tab.preview_layout.addWidget(frame) - self.main_tab._update_status() - - # Auto-add to Default cycling group - self._add_to_default_cycling_group(char_name) + if self.main_tab.import_detected_window(window_id, char_name): + self._queue_main_tab_status_refresh() # Show notification if self.settings_manager.get("general.show_notifications", True): - self.system_tray.show_notification( - "New Character Detected", f"Added: {char_name}" - ) + self._queue_discovery_notification(char_name) def _create_menu_bar(self): """Create menu bar with Help menu (v2.4 - uses ActionRegistry)""" @@ -584,11 +679,23 @@ def _create_main_tab(self): self.character_manager, settings_manager=self.settings_manager, ) - self.tabs.addTab(self.main_tab, "Overview") + self._tab_indexes["Overview"] = self.tabs.addTab(self.main_tab, "Overview") # Connect signals self.main_tab.character_detected.connect(self._on_character_detected) self.main_tab.layout_applied.connect(self._on_layout_applied) + self.main_tab.window_focus_requested.connect( + self._activate_window, + Qt.ConnectionType.UniqueConnection, + ) + self.main_tab.roster_navigation_requested.connect( + self._show_roster, + Qt.ConnectionType.UniqueConnection, + ) + self.main_tab.cycle_control_navigation_requested.connect( + self._show_cycle_control, + Qt.ConnectionType.UniqueConnection, + ) def _create_characters_tab(self): """Create Roster tab (character & team management) - formerly 'Characters & Teams'""" @@ -599,7 +706,7 @@ def _create_characters_tab(self): self.layout_manager, settings_sync=self.settings_sync, # v2.2: Enable EVE folder scanning ) - self.tabs.addTab(self.characters_tab, "Roster") + self._tab_indexes["Roster"] = self.tabs.addTab(self.characters_tab, "Roster") # Connect signals self.characters_tab.team_selected.connect(self._on_team_selected) @@ -611,7 +718,7 @@ def _create_hotkeys_tab(self): self.hotkeys_tab = HotkeysTab( self.character_manager, self.settings_manager, main_tab=self.main_tab ) - self.tabs.addTab(self.hotkeys_tab, "Cycle Control") + self._tab_indexes["Cycle Control"] = self.tabs.addTab(self.hotkeys_tab, "Cycle Control") # Connect group changes to refresh layout sources in overview tab self.hotkeys_tab.group_changed.connect(self.main_tab.refresh_layout_groups) @@ -630,7 +737,7 @@ def _create_intel_tab(self): from argus_overview.ui.intel_tab import IntelTab self.intel_tab = IntelTab(self.settings_manager) - self.tabs.addTab(self.intel_tab, "Intel") + self._tab_indexes["Intel"] = self.tabs.addTab(self.intel_tab, "Intel") # Connect alert signals to main window for visual feedback self.intel_tab.alert_triggered.connect(self._on_intel_alert) @@ -677,14 +784,14 @@ def _create_settings_sync_tab(self): from argus_overview.ui.settings_sync_tab import SettingsSyncTab self.settings_sync_tab = SettingsSyncTab(self.settings_sync, self.character_manager) - self.tabs.addTab(self.settings_sync_tab, "Sync") + self._tab_indexes["Sync"] = self.tabs.addTab(self.settings_sync_tab, "Sync") def _create_settings_tab(self): """Create Settings tab (application settings)""" from argus_overview.ui.settings_tab import SettingsTab self.settings_tab = SettingsTab(self.settings_manager, self.hotkey_manager) - self.tabs.addTab(self.settings_tab, "Settings") + self._tab_indexes["Settings"] = self.tabs.addTab(self.settings_tab, "Settings") # Connect signals self.settings_tab.settings_changed.connect(self._apply_setting) @@ -704,6 +811,9 @@ def _disconnect_signals(self): [ ("character_detected", self._on_character_detected), ("layout_applied", self._on_layout_applied), + ("window_focus_requested", self._activate_window), + ("roster_navigation_requested", self._show_roster), + ("cycle_control_navigation_requested", self._show_cycle_control), ], ), # characters_tab signals @@ -746,15 +856,6 @@ def _disconnect_signals(self): ("quit_requested", self._quit_application), ], ), - # auto_discovery signals - ( - self, - "auto_discovery", - [ - ("new_character_found", self._on_new_character_discovered), - ("character_gone", self._on_character_gone), - ], - ), # intel_tab signals ( self, @@ -906,8 +1007,15 @@ def _on_character_detected(self, window_id: str, char_name: str): """ self.logger.info(f"Character detected: {char_name} (window: {window_id})") + auto_save = not self._bulk_import_active + self.character_manager.ensure_character(char_name, auto_save=auto_save) # Assign window in character manager - self.character_manager.assign_window(char_name, window_id) + self.character_manager.assign_window(char_name, window_id, auto_save=auto_save) + self._add_to_default_cycling_group(char_name, auto_save=auto_save) + + if self._bulk_import_active: + self._bulk_import_dirty_characters = True + self._bulk_import_dirty_groups = True # Update characters tab if it exists and has the method if hasattr(self, "characters_tab") and hasattr( @@ -932,6 +1040,7 @@ def _on_character_gone(self, char_name: str, window_id: str): # Remove preview frame so capture loop stops hitting dead window if hasattr(self, "main_tab") and hasattr(self.main_tab, "window_manager"): self.main_tab.window_manager.remove_window(window_id) + self._queue_main_tab_status_refresh() # Update characters tab if it exists and has the method if hasattr(self, "characters_tab") and hasattr( @@ -975,7 +1084,7 @@ def _handle_hotkey(self, hotkey_name: str): def closeEvent(self, event: QCloseEvent): """Handle application close - v2.2 minimize to tray support""" # Check if we should minimize to tray instead of closing - if self.settings_manager.get("general.minimize_to_tray", True): + if not self._is_quitting and self.settings_manager.get("general.minimize_to_tray", True): if hasattr(self, "system_tray") and self.system_tray.is_visible(): self.logger.info("Minimizing to system tray") self.hide() @@ -988,6 +1097,12 @@ def closeEvent(self, event: QCloseEvent): # Actually closing the application self.logger.info(f"Shutting down Argus Overview v{__version__}...") + if hasattr(self, "_discovery_notification_timer"): + self._discovery_notification_timer.stop() + if hasattr(self, "_status_refresh_timer"): + self._status_refresh_timer.stop() + self._pending_discovery_names.clear() + self._disconnect_auto_discovery() # Disconnect signals to break reference cycles self._disconnect_signals() diff --git a/src/argus_overview/ui/settings_manager.py b/src/argus_overview/ui/settings_manager.py index 73e850d..a99bc62 100644 --- a/src/argus_overview/ui/settings_manager.py +++ b/src/argus_overview/ui/settings_manager.py @@ -23,6 +23,8 @@ class SettingsManager: "start_with_system": False, "minimize_to_tray": True, "show_notifications": True, + "auto_import_on_startup": True, + "show_setup_guidance": True, "auto_save_interval": 5, # minutes "auto_discovery": True, "auto_discovery_interval": 5, # seconds diff --git a/src/argus_overview/ui/settings_tab.py b/src/argus_overview/ui/settings_tab.py index 8c20376..64f911a 100644 --- a/src/argus_overview/ui/settings_tab.py +++ b/src/argus_overview/ui/settings_tab.py @@ -185,6 +185,36 @@ def _setup_ui(self): ) form.addRow("Show notifications:", self.notifications_check) + self.auto_import_check = QCheckBox() + self.auto_import_check.setChecked( + self.settings_manager.get("general.auto_import_on_startup", True) + ) + self.auto_import_check.setToolTip( + "Scan for running EVE windows when Argus starts and import them automatically." + ) + self.auto_import_check.stateChanged.connect( + lambda: self.setting_changed.emit( + "general.auto_import_on_startup", + self.auto_import_check.isChecked(), + ) + ) + form.addRow("Auto-import on startup:", self.auto_import_check) + + self.setup_guidance_check = QCheckBox() + self.setup_guidance_check.setChecked( + self.settings_manager.get("general.show_setup_guidance", True) + ) + self.setup_guidance_check.setToolTip( + "Show quick-start guidance when no EVE clients are loaded yet." + ) + self.setup_guidance_check.stateChanged.connect( + lambda: self.setting_changed.emit( + "general.show_setup_guidance", + self.setup_guidance_check.isChecked(), + ) + ) + form.addRow("Show setup guidance:", self.setup_guidance_check) + # Auto-save interval self.auto_save_spin = QSpinBox() self.auto_save_spin.setRange(1, 60) diff --git a/src/argus_overview/utils/constants.py b/src/argus_overview/utils/constants.py index 58437b8..c632f9e 100644 --- a/src/argus_overview/utils/constants.py +++ b/src/argus_overview/utils/constants.py @@ -1,6 +1,7 @@ """Centralized constants for Argus Overview.""" import os +import tempfile from pathlib import Path # Subprocess timeout values (seconds) @@ -14,10 +15,21 @@ # Configuration paths _DEFAULT_CONFIG_DIR = Path.home() / ".config" / "argus-overview" -CONFIG_DIR = Path(os.environ.get("ARGUS_CONFIG_DIR", _DEFAULT_CONFIG_DIR)).expanduser() +_REQUESTED_CONFIG_DIR = Path(os.environ.get("ARGUS_CONFIG_DIR", _DEFAULT_CONFIG_DIR)).expanduser() -# Ensure config directory exists -CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +def _resolve_config_dir() -> Path: + """Return a writable config directory, falling back when home is unavailable.""" + try: + _REQUESTED_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return _REQUESTED_CONFIG_DIR + except OSError: + fallback = Path(tempfile.gettempdir()) / "argus-overview" + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + +CONFIG_DIR = _resolve_config_dir() # Config file paths SETTINGS_FILE = CONFIG_DIR / "settings.json" diff --git a/src/main.py b/src/main.py index 6bb7068..5bc7a97 100644 --- a/src/main.py +++ b/src/main.py @@ -36,6 +36,15 @@ from io import TextIOWrapper from pathlib import Path +# Enforce runtime floor before importing PySide6/Qt. +if sys.version_info < (3, 10): # noqa: UP036 + raise SystemExit( + "Argus Overview requires Python 3.10+.\n" + "Detected: " + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n" + "Please recreate your environment with Python 3.10+." + ) + # Platform-specific imports for single-instance locking if sys.platform == "win32": import msvcrt diff --git a/tests/conftest.py b/tests/conftest.py index 032ca04..2e0190d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,19 +2,37 @@ import os import sys +from pathlib import Path import pytest +# Enforce supported runtime before importing Qt to avoid hard aborts in old interpreters. +if sys.version_info < (3, 10): # noqa: UP036 + raise RuntimeError( + "Argus Overview tests require Python 3.10+. " + "Recreate your test environment with Python 3.10+." + ) + +from PySide6 import __file__ as pyside6_file + # Set Qt platform plugin before importing PySide6 # Use offscreen platform for CI environments (GitHub Actions sets CI=true) # This avoids display server compatibility issues with PySide6 if "QT_QPA_PLATFORM" not in os.environ: os.environ["QT_QPA_PLATFORM"] = "offscreen" -from PySide6.QtWidgets import QApplication +_pyside6_root = Path(pyside6_file).resolve().parent +_qt_plugins_dir = _pyside6_root / "Qt" / "plugins" +_qt_platforms_dir = _qt_plugins_dir / "platforms" +os.environ.setdefault("QT_PLUGIN_PATH", str(_qt_plugins_dir)) +os.environ.setdefault("QT_QPA_PLATFORM_PLUGIN_PATH", str(_qt_platforms_dir)) + +from PySide6.QtCore import QCoreApplication # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 # Create QApplication at module load time to ensure it exists # before any Qt widgets are imported by test files +QCoreApplication.setLibraryPaths([str(_qt_plugins_dir)]) _qapp_instance = QApplication.instance() if _qapp_instance is None: _qapp_instance = QApplication(sys.argv[:1]) diff --git a/tests/test_characters_teams_tab.py b/tests/test_characters_teams_tab.py index 1d9e1e5..9f248f9 100644 --- a/tests/test_characters_teams_tab.py +++ b/tests/test_characters_teams_tab.py @@ -322,6 +322,7 @@ class TestCharacterTableMethods: def test_populate_table_with_characters(self): """Test populate_table with character list""" from argus_overview.ui.characters_teams_tab import CharacterTable + from argus_overview.core.character_manager import AUTO_CREATED_NOTE with patch.object(CharacterTable, "__init__", return_value=None): table = CharacterTable.__new__(CharacterTable) @@ -343,7 +344,7 @@ def test_populate_table_with_characters(self): char2.role = "Miner" char2.is_main = False char2.window_id = None - char2.notes = None + char2.notes = AUTO_CREATED_NOTE table.character_manager.get_all_characters.return_value = [char1, char2] @@ -359,6 +360,42 @@ def test_populate_table_with_characters(self): table.setRowCount.assert_called_once_with(2) assert mock_item.call_count >= 12 # 6 columns * 2 rows + def test_populate_table_shows_needs_setup_for_auto_created_characters(self): + """Test auto-created characters render a friendlier setup cue in Notes.""" + from argus_overview.ui.characters_teams_tab import CharacterTable + from argus_overview.core.character_manager import AUTO_CREATED_NOTE + + with patch.object(CharacterTable, "__init__", return_value=None): + table = CharacterTable.__new__(CharacterTable) + table.logger = MagicMock() + table.character_manager = MagicMock() + + char = MagicMock() + char.name = "Pilot1" + char.account = "" + char.role = "DPS" + char.is_main = False + char.window_id = None + char.notes = AUTO_CREATED_NOTE + + table.character_manager.get_all_characters.return_value = [char] + table.setSortingEnabled = MagicMock() + table.setRowCount = MagicMock() + table.setItem = MagicMock() + + created_items = [] + + def make_item(text): + item = MagicMock() + item._text = text + created_items.append((text, item)) + return item + + with patch("argus_overview.ui.characters_teams_tab.QTableWidgetItem", side_effect=make_item): + table._do_populate_table() + + assert any(text == CharacterTable.NEEDS_SETUP_TEXT for text, _item in created_items) + def test_update_character_status_active(self): """Test update_character_status when character becomes active""" from argus_overview.ui.characters_teams_tab import CharacterTable @@ -433,6 +470,65 @@ def test_update_character_status_not_found(self): # Should not raise, just not find the character table.update_character_status("NonExistent", "0x123") + def test_apply_filters_hides_non_matching_rows(self): + """Test text filtering hides rows that do not match the search.""" + from argus_overview.ui.characters_teams_tab import CharacterTable + + with patch.object(CharacterTable, "__init__", return_value=None): + table = CharacterTable.__new__(CharacterTable) + table.rowCount = MagicMock(return_value=2) + table.setRowHidden = MagicMock() + + def item_side_effect(row, col): + data = { + (0, 0): MagicMock(text=MagicMock(return_value="Pilot One")), + (0, 1): MagicMock(text=MagicMock(return_value="AccountA")), + (0, 2): MagicMock(text=MagicMock(return_value="DPS")), + (0, 5): MagicMock(text=MagicMock(return_value="Main")), + (1, 0): MagicMock(text=MagicMock(return_value="Pilot Two")), + (1, 1): MagicMock(text=MagicMock(return_value="AccountB")), + (1, 2): MagicMock(text=MagicMock(return_value="Miner")), + (1, 5): MagicMock(text=MagicMock(return_value="Needs setup")), + } + return data.get((row, col)) + + table.item = MagicMock(side_effect=item_side_effect) + + table.apply_filters(search_text="pilot one", needs_setup_only=False) + + table.setRowHidden.assert_any_call(0, False) + table.setRowHidden.assert_any_call(1, True) + + def test_apply_filters_respects_needs_setup_toggle(self): + """Test setup-only filtering keeps only review-needed rows visible.""" + from argus_overview.ui.characters_teams_tab import CharacterTable + + with patch.object(CharacterTable, "__init__", return_value=None): + table = CharacterTable.__new__(CharacterTable) + table.NEEDS_SETUP_TEXT = "Needs setup" + table.rowCount = MagicMock(return_value=2) + table.setRowHidden = MagicMock() + + def item_side_effect(row, col): + data = { + (0, 0): MagicMock(text=MagicMock(return_value="Pilot One")), + (0, 1): MagicMock(text=MagicMock(return_value="AccountA")), + (0, 2): MagicMock(text=MagicMock(return_value="DPS")), + (0, 5): MagicMock(text=MagicMock(return_value="Main")), + (1, 0): MagicMock(text=MagicMock(return_value="Pilot Two")), + (1, 1): MagicMock(text=MagicMock(return_value="")), + (1, 2): MagicMock(text=MagicMock(return_value="Miner")), + (1, 5): MagicMock(text=MagicMock(return_value="Needs setup")), + } + return data.get((row, col)) + + table.item = MagicMock(side_effect=item_side_effect) + + table.apply_filters(search_text="", needs_setup_only=True) + + table.setRowHidden.assert_any_call(0, True) + table.setRowHidden.assert_any_call(1, False) + def test_get_selected_characters_with_selection(self): """Test get_selected_characters with selected items""" from argus_overview.ui.characters_teams_tab import CharacterTable @@ -1213,6 +1309,7 @@ def test_add_character_dialog_accepted(self): tab.logger = MagicMock() tab.character_manager = MagicMock() tab.character_manager.add_character.return_value = True + tab._refresh_setup_summary = MagicMock() mock_char = MagicMock() mock_char.name = "NewPilot" @@ -1229,6 +1326,7 @@ def test_add_character_dialog_accepted(self): tab.character_manager.add_character.assert_called_once_with(mock_char) tab.character_table.populate_table.assert_called_once() + tab._refresh_setup_summary.assert_called_once() def test_add_character_dialog_cancelled(self): """Test _add_character when dialog is cancelled""" @@ -1274,6 +1372,7 @@ def test_edit_character_success(self): tab = CharactersTeamsTab.__new__(CharactersTeamsTab) tab.logger = MagicMock() tab.character_manager = MagicMock() + tab._refresh_setup_summary = MagicMock() mock_char = MagicMock() mock_char.name = "Pilot1" @@ -1293,6 +1392,7 @@ def test_edit_character_success(self): tab.character_manager.update_character.assert_called_once() tab.character_table.populate_table.assert_called_once() + tab._refresh_setup_summary.assert_called_once() def test_delete_character_no_selection(self): """Test _delete_character with no selection""" @@ -1319,6 +1419,7 @@ def test_delete_character_confirmed(self): tab.logger = MagicMock() tab.character_manager = MagicMock() tab.character_manager.remove_character.return_value = True + tab._refresh_setup_summary = MagicMock() tab.character_table = MagicMock() tab.character_table.get_selected_characters.return_value = ["Pilot1"] @@ -1331,6 +1432,7 @@ def test_delete_character_confirmed(self): tab._delete_character() tab.character_manager.remove_character.assert_called_once_with("Pilot1") + tab._refresh_setup_summary.assert_called_once() def test_delete_character_cancelled(self): """Test _delete_character when user cancels""" @@ -1395,6 +1497,7 @@ def test_scan_eve_folder_success(self): tab.character_manager = MagicMock() tab.character_manager.import_from_eve_sync.return_value = 2 + tab._refresh_setup_summary = MagicMock() tab.character_table = MagicMock() tab.characters_imported = MagicMock() @@ -1404,6 +1507,7 @@ def test_scan_eve_folder_success(self): tab.character_manager.import_from_eve_sync.assert_called_once() tab.character_table.populate_table.assert_called_once() + tab._refresh_setup_summary.assert_called_once() tab.characters_imported.emit.assert_called_once_with(2) def test_scan_eve_folder_exception(self): @@ -1486,10 +1590,68 @@ def test_update_character_status(self): with patch.object(CharactersTeamsTab, "__init__", return_value=None): tab = CharactersTeamsTab.__new__(CharactersTeamsTab) tab.character_table = MagicMock() + tab._refresh_setup_summary = MagicMock() tab.update_character_status("Pilot1", "0x123") tab.character_table.update_character_status.assert_called_once_with("Pilot1", "0x123") + tab._refresh_setup_summary.assert_called_once() + + def test_refresh_setup_summary_shows_pending_imports(self): + """Test roster summary shows auto-created characters needing review.""" + from argus_overview.ui.characters_teams_tab import CharactersTeamsTab + + with patch.object(CharactersTeamsTab, "__init__", return_value=None): + tab = CharactersTeamsTab.__new__(CharactersTeamsTab) + tab.character_manager = MagicMock() + tab.setup_summary_label = MagicMock() + + pending_one = MagicMock() + pending_one.name = "Pilot1" + pending_two = MagicMock() + pending_two.name = "Pilot2" + tab.character_manager.get_characters_needing_setup.return_value = [ + pending_one, + pending_two, + ] + + tab._refresh_setup_summary() + + tab.setup_summary_label.setText.assert_called_once() + tab.setup_summary_label.show.assert_called_once() + + def test_refresh_setup_summary_hides_when_clean(self): + """Test roster summary hides when nothing needs review.""" + from argus_overview.ui.characters_teams_tab import CharactersTeamsTab + + with patch.object(CharactersTeamsTab, "__init__", return_value=None): + tab = CharactersTeamsTab.__new__(CharactersTeamsTab) + tab.character_manager = MagicMock() + tab.setup_summary_label = MagicMock() + tab.character_manager.get_characters_needing_setup.return_value = [] + + tab._refresh_setup_summary() + + tab.setup_summary_label.hide.assert_called_once() + + def test_apply_character_filters_delegates_to_table(self): + """Test roster filter controls delegate to the character table.""" + from argus_overview.ui.characters_teams_tab import CharactersTeamsTab + + with patch.object(CharactersTeamsTab, "__init__", return_value=None): + tab = CharactersTeamsTab.__new__(CharactersTeamsTab) + tab.character_table = MagicMock() + tab.character_filter_edit = MagicMock() + tab.character_filter_edit.text.return_value = "pilot" + tab.needs_setup_only_check = MagicMock() + tab.needs_setup_only_check.isChecked.return_value = True + + tab._apply_character_filters() + + tab.character_table.apply_filters.assert_called_once_with( + search_text="pilot", + needs_setup_only=True, + ) def test_edit_character_not_found(self): """Test _edit_character when character not found in manager""" diff --git a/tests/test_cycle_controller.py b/tests/test_cycle_controller.py new file mode 100644 index 0000000..49216ab --- /dev/null +++ b/tests/test_cycle_controller.py @@ -0,0 +1,123 @@ +"""Unit tests for centralized window activation and cycling behavior.""" + +from unittest.mock import MagicMock + +from argus_overview.core.cycle_controller import CycleController + + +def create_controller(): + """Build a controller with mocked window operations and settings.""" + window_ops = MagicMock() + window_ops._window_mgr = MagicMock() + window_ops._window_mgr.is_valid_window_id.return_value = True + window_ops.activate_window.return_value = True + window_ops.minimize_window.return_value = True + + settings_manager = MagicMock() + settings_manager.get.return_value = False + settings_manager.get_last_activated_window.return_value = None + + return CycleController(window_ops, settings_manager), window_ops, settings_manager + + +class TestActivateWindow: + def test_activate_window_activates_valid_window(self): + controller, window_ops, settings = create_controller() + + result = controller.activate_window("0x123") + + assert result is True + settings.set_last_activated_window.assert_called_once_with("0x123") + window_ops.activate_window.assert_called_once_with("0x123") + window_ops.minimize_window.assert_not_called() + + def test_activate_window_rejects_invalid_window_id(self): + controller, window_ops, settings = create_controller() + window_ops._window_mgr.is_valid_window_id.return_value = False + + result = controller.activate_window("bad") + + assert result is False + settings.set_last_activated_window.assert_not_called() + window_ops.activate_window.assert_not_called() + + def test_activate_window_auto_minimizes_previous_window(self): + controller, window_ops, settings = create_controller() + + def get_side_effect(key, default=None): + if key == "performance.auto_minimize_inactive": + return True + return default + + settings.get.side_effect = get_side_effect + settings.get_last_activated_window.return_value = "0xOLD" + + result = controller.activate_window("0xNEW") + + assert result is True + window_ops.minimize_window.assert_called_once_with("0xOLD") + settings.set_last_activated_window.assert_called_once_with("0xNEW") + window_ops.activate_window.assert_called_once_with("0xNEW") + + def test_activate_window_handles_activation_exception(self): + controller, window_ops, settings = create_controller() + window_ops.activate_window.side_effect = OSError("display unavailable") + + result = controller.activate_window("0x123") + + assert result is False + settings.set_last_activated_window.assert_called_once_with("0x123") + + +class TestActivateCharacter: + def test_activate_character_looks_up_and_activates_window(self): + controller, window_ops, settings = create_controller() + + result = controller.activate_character("Pilot", lambda name: "0xABC") + + assert result is True + settings.set_last_activated_window.assert_called_once_with("0xABC") + window_ops.activate_window.assert_called_once_with("0xABC") + + def test_activate_character_returns_false_when_lookup_fails(self): + controller, window_ops, settings = create_controller() + + result = controller.activate_character("Missing", lambda name: None) + + assert result is False + settings.set_last_activated_window.assert_not_called() + window_ops.activate_window.assert_not_called() + + +class TestCycle: + def test_cycle_advances_to_next_live_member(self): + controller, window_ops, settings = create_controller() + members = ["Alpha", "Bravo", "Charlie"] + lookup = {"Bravo": None, "Charlie": "0xCCC"} + + index, character = controller.cycle( + members=members, + current_index=0, + direction=1, + window_lookup=lambda name: lookup.get(name), + ) + + assert index == 2 + assert character == "Charlie" + settings.set_last_activated_window.assert_called_once_with("0xCCC") + window_ops.activate_window.assert_called_once_with("0xCCC") + + def test_cycle_returns_current_index_when_no_live_windows_exist(self): + controller, window_ops, settings = create_controller() + + index, character = controller.cycle( + members=["Alpha", "Bravo"], + current_index=1, + direction=1, + window_lookup=lambda name: None, + ) + + assert index == 1 + assert character is None + settings.set_last_activated_window.assert_not_called() + window_ops.activate_window.assert_not_called() diff --git a/tests/test_docs_runtime_requirements.py b/tests/test_docs_runtime_requirements.py new file mode 100644 index 0000000..7b8e39f --- /dev/null +++ b/tests/test_docs_runtime_requirements.py @@ -0,0 +1,41 @@ +"""Docs/runtime consistency tests.""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_launch_docs_match_pyproject_python_floor() -> None: + pyproject = (REPO_ROOT / "pyproject.toml").read_text() + reddit = (REPO_ROOT / "docs" / "REDDIT_LAUNCH.md").read_text() + forum = (REPO_ROOT / "docs" / "FORUM_POST.md").read_text() + + assert 'requires-python = ">=3.10"' in pyproject + assert "Python 3.10+" in reddit + assert "Python 3.10+" in forum + + +def test_launch_docs_do_not_claim_legacy_python_floor() -> None: + reddit = (REPO_ROOT / "docs" / "REDDIT_LAUNCH.md").read_text() + forum = (REPO_ROOT / "docs" / "FORUM_POST.md").read_text() + + for doc in (reddit, forum): + assert "Python 3.8+" not in doc + assert "Python 3.9+" not in doc + + +def test_runtime_guards_exist_for_python_floor() -> None: + main_py = (REPO_ROOT / "src" / "main.py").read_text() + conftest = (REPO_ROOT / "tests" / "conftest.py").read_text() + run_sh = (REPO_ROOT / "run.sh").read_text() + install_sh = (REPO_ROOT / "install.sh").read_text() + + assert "sys.version_info < (3, 10)" in main_py + assert "Python 3.10+" in main_py + assert "sys.version_info < (3, 10)" in conftest + assert "tests require Python 3.10+" in conftest + assert "Python 3.10+" in run_sh + assert "sys.version_info >= (3, 10)" in run_sh + assert "Python 3.10 or higher is required" in install_sh diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index fa3c32e..8e0d0f4 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -480,34 +480,30 @@ def test_init(self): assert applier.logger is not None def test_get_screen_geometry_with_xrandr(self): - """Test get_screen_geometry parses xrandr output""" - from argus_overview.ui.main_tab import GridApplier + """Test get_screen_geometry delegates to the shared screen helper.""" + from argus_overview.ui.main_tab import GridApplier, ScreenGeometry applier = GridApplier() - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - stdout="DP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm", - returncode=0, - ) + expected = ScreenGeometry(0, 0, 1920, 1080, True) - applier.get_screen_geometry(0) + with patch("argus_overview.ui.main_tab.get_screen_geometry", return_value=expected) as mock_get: + result = applier.get_screen_geometry(0) - # Returns None or ScreenGeometry based on parsing - # Just verify no exception + assert result == expected + mock_get.assert_called_once_with(0) def test_get_screen_geometry_no_display(self): - """Test get_screen_geometry with no connected display""" + """Test get_screen_geometry tolerates no detected display.""" from argus_overview.ui.main_tab import GridApplier applier = GridApplier() - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(stdout="", returncode=0) - - applier.get_screen_geometry(0) + with patch("argus_overview.ui.main_tab.get_screen_geometry", return_value=None) as mock_get: + result = applier.get_screen_geometry(0) - # Result depends on fallback logic, just verify no exception + assert result is None + mock_get.assert_called_once_with(0) def test_apply_arrangement_empty(self): """Test apply_arrangement with empty arrangement""" @@ -1415,104 +1411,67 @@ def test_update_frame_exception(self): class TestMainTabAutoMinimize: - """Tests for MainTab auto-minimize functionality""" + """Tests for MainTab activation forwarding.""" def test_on_window_activated_without_auto_minimize(self): - """Test _on_window_activated when auto_minimize is disabled""" + """Test _on_window_activated emits a focus request.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - tab.logger = MagicMock() + tab.window_focus_requested = MagicMock() - with patch("subprocess.run") as mock_run: - tab._on_window_activated("0x123") + tab._on_window_activated("0x123") - # Should NOT minimize anything - mock_run.assert_not_called() + tab.window_focus_requested.emit.assert_called_once_with("0x123") def test_on_window_activated_with_auto_minimize(self): - """Test _on_window_activated when auto_minimize is enabled""" + """Test _on_window_activated no longer performs auto-minimize logic locally.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = True - tab.settings_manager.get_last_activated_window.return_value = "0x111" - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - tab.logger = MagicMock() + tab.window_focus_requested = MagicMock() - with patch("subprocess.run") as mock_run: - mock_result = MagicMock() - mock_result.returncode = 0 - mock_run.return_value = mock_result - tab._on_window_activated("0x123") + tab._on_window_activated("0x123") - # Should minimize previous window - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert "xdotool" in call_args - assert "windowminimize" in call_args - assert "0x111" in call_args + tab.window_focus_requested.emit.assert_called_once_with("0x123") def test_on_window_activated_same_window(self): - """Test _on_window_activated with same window (no minimize)""" + """Test _on_window_activated emits even when the same window is requested.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = True - tab.settings_manager.get_last_activated_window.return_value = "0x123" - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - tab.logger = MagicMock() + tab.window_focus_requested = MagicMock() - with patch("subprocess.run") as mock_run: - tab._on_window_activated("0x123") + tab._on_window_activated("0x123") - # Should NOT minimize same window - mock_run.assert_not_called() + tab.window_focus_requested.emit.assert_called_once_with("0x123") def test_on_window_activated_tracks_last_window(self): - """Test _on_window_activated updates last activated window""" + """Test _on_window_activated does not mutate settings directly anymore.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - tab.logger = MagicMock() + tab.window_focus_requested = MagicMock() tab._on_window_activated("0x456") - tab.settings_manager.set_last_activated_window.assert_called_with("0x456") + tab.window_focus_requested.emit.assert_called_once_with("0x456") def test_on_window_activated_no_previous(self): - """Test _on_window_activated with no previous window""" + """Test _on_window_activated only forwards the activation request.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = True - tab.settings_manager.get_last_activated_window.return_value = None - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - tab.logger = MagicMock() + tab.window_focus_requested = MagicMock() - with patch("subprocess.run") as mock_run: - # Should not raise, should not minimize - tab._on_window_activated("0x123") - mock_run.assert_not_called() + tab._on_window_activated("0x123") + + tab.window_focus_requested.emit.assert_called_once_with("0x123") # ============================================================================= @@ -4050,20 +4009,17 @@ def test_toggle_lock_off(self): tab.lock_btn.setText.assert_called_with("Lock") def test_on_window_activated(self): - """Test _on_window_activated sets last activated window""" + """Test _on_window_activated forwards focus requests.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False # auto_minimize off - tab.capture_system = MagicMock() + tab.window_focus_requested = MagicMock() tab._on_window_activated("12345") - # Should set the last activated window on settings_manager - tab.settings_manager.set_last_activated_window.assert_called_with("12345") + tab.window_focus_requested.emit.assert_called_once_with("12345") def test_on_window_removed(self): """Test _on_window_removed removes frame""" @@ -4812,12 +4768,19 @@ def test_update_status_no_windows(self): tab.window_manager.get_active_window_count.return_value = 0 tab.active_count_label = MagicMock() tab.status_label = MagicMock() + tab.settings_manager = MagicMock() + tab.settings_manager.get.side_effect = lambda key, default=None: { + "general.show_setup_guidance": True, + "general.auto_discovery": True, + }.get(key, default) + tab._status_override_text = None + tab._get_empty_state_message = lambda: MainTab._get_empty_state_message(tab) tab._update_status() tab.active_count_label.setText.assert_called_with("Active: 0") tab.status_label.setText.assert_called_with( - "No windows in preview - Click 'Add Window' to start" + "No windows in preview. Click 'Import Windows' to start, or launch EVE and let auto-discovery add clients for you." ) @@ -7121,10 +7084,9 @@ def test_number_key_activates_window(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True tab.status_label = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() # Mock preview frames frame1 = MagicMock() @@ -7136,8 +7098,7 @@ def test_number_key_activates_window(self): tab.keyPressEvent(event) - # Window should be activated - tab.capture_system.activate_window.assert_called_once_with("win1") + tab.window_focus_requested.emit.assert_called_once_with("win1") def test_number_key_2_activates_second_window(self): """Test pressing number key 2 activates second window""" @@ -7149,10 +7110,9 @@ def test_number_key_2_activates_second_window(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True tab.status_label = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() frame1 = MagicMock() frame1.character_name = "FirstCharacter" @@ -7164,7 +7124,7 @@ def test_number_key_2_activates_second_window(self): tab.keyPressEvent(event) - tab.capture_system.activate_window.assert_called_once_with("win2") + tab.window_focus_requested.emit.assert_called_once_with("win2") def test_number_key_out_of_range_does_nothing(self): """Test pressing number key higher than window count does nothing""" @@ -7176,8 +7136,8 @@ def test_number_key_out_of_range_does_nothing(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() frame1 = MagicMock() frame1.character_name = "OnlyCharacter" @@ -7189,7 +7149,7 @@ def test_number_key_out_of_range_does_nothing(self): tab.keyPressEvent(event) # Should not call activate - tab.capture_system.activate_window.assert_not_called() + tab.window_focus_requested.emit.assert_not_called() def test_non_number_key_does_not_activate(self): """Test non-number keys do not activate any windows""" @@ -7198,8 +7158,8 @@ def test_non_number_key_does_not_activate(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() frame1 = MagicMock() frame1.character_name = "Character" @@ -7207,7 +7167,7 @@ def test_non_number_key_does_not_activate(self): # Key 'A' (not a number) should not activate any window # We test by checking activate_window is never called - tab.capture_system.activate_window.assert_not_called() + tab.window_focus_requested.emit.assert_not_called() class TestMainTabActivateWindowByIndex: @@ -7220,10 +7180,9 @@ def test_activate_valid_index(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True tab.status_label = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() frame = MagicMock() frame.character_name = "TestCharacter" @@ -7231,10 +7190,9 @@ def test_activate_valid_index(self): tab._activate_window_by_index(0) - tab.capture_system.activate_window.assert_called_once_with("win123") - # Status should be updated + tab.window_focus_requested.emit.assert_called_once_with("win123") tab.status_label.setText.assert_called() - assert "TestCharacter" in tab.status_label.setText.call_args[0][0] + assert "Activating: TestCharacter" == tab.status_label.setText.call_args[0][0] def test_activate_invalid_index(self): """Test activating window at invalid index does nothing""" @@ -7243,24 +7201,24 @@ def test_activate_invalid_index(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() tab.window_manager = MagicMock() tab.window_manager.preview_frames = {} + tab.window_focus_requested = MagicMock() tab._activate_window_by_index(5) - tab.capture_system.activate_window.assert_not_called() + tab.window_focus_requested.emit.assert_not_called() def test_activate_failed_logs_warning(self): - """Test failed activation logs warning""" + """Test valid index no longer depends on direct activation success.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = False tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() + tab.status_label = MagicMock() frame = MagicMock() frame.character_name = "TestCharacter" @@ -7268,7 +7226,7 @@ def test_activate_failed_logs_warning(self): tab._activate_window_by_index(0) - tab.logger.warning.assert_called() + tab.window_focus_requested.emit.assert_called_once_with("win123") def test_activate_multiple_windows(self): """Test activating different windows by index""" @@ -7277,10 +7235,9 @@ def test_activate_multiple_windows(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True tab.status_label = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() frame1 = MagicMock() frame1.character_name = "First" @@ -7298,7 +7255,7 @@ def test_activate_multiple_windows(self): # Activate third window (index 2) tab._activate_window_by_index(2) - tab.capture_system.activate_window.assert_called_once_with("win3") + tab.window_focus_requested.emit.assert_called_once_with("win3") def test_activate_index_9_works(self): """Test activating window at index 8 (key 9) works""" @@ -7307,10 +7264,9 @@ def test_activate_index_9_works(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True tab.status_label = MagicMock() tab.window_manager = MagicMock() + tab.window_focus_requested = MagicMock() # Create 9 windows frames = {} @@ -7323,7 +7279,7 @@ def test_activate_index_9_works(self): # Activate 9th window (index 8) tab._activate_window_by_index(8) - tab.capture_system.activate_window.assert_called_once_with("win8") + tab.window_focus_requested.emit.assert_called_once_with("win8") # ============================================================================= @@ -7608,6 +7564,53 @@ def test_get_available_windows_exception(self): tab.logger.error.assert_called() +# ============================================================================= +# Shared Import Path Tests +# ============================================================================= + + +class TestImportDetectedWindow: + """Tests for MainTab.import_detected_window shared import path.""" + + def test_import_detected_window_adds_frame_and_emits(self): + """Test detected windows use the shared add/connect/layout path.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab.window_manager = MagicMock() + mock_frame = MagicMock() + tab.window_manager.add_window.return_value = mock_frame + tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() + + result = tab.import_detected_window("0x123", "DetectedPilot") + + assert result is True + tab.window_manager.add_window.assert_called_once_with("0x123", "DetectedPilot") + tab.preview_layout.addWidget.assert_called_once_with(mock_frame) + tab.character_detected.emit.assert_called_once_with("0x123", "DetectedPilot") + + def test_import_detected_window_returns_false_when_add_fails(self): + """Test shared import path returns False when no frame is created.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab.window_manager = MagicMock() + tab.window_manager.add_window.return_value = None + tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() + + result = tab.import_detected_window("0x123", "DetectedPilot") + + assert result is False + tab.preview_layout.addWidget.assert_not_called() + tab.character_detected.emit.assert_not_called() + + # ============================================================================= # Add Window To Preview Tests # ============================================================================= @@ -7811,6 +7814,143 @@ def test_create_status_bar_timer_connects_update_status(self): mock_timer.timeout.connect.assert_called_once_with(tab._update_status) +class TestEmptyStateVisibility: + """Tests for Overview empty-state onboarding visibility.""" + + def test_update_empty_state_visibility_shows_panel_when_empty(self): + """Test empty-state panel is shown when no windows are loaded.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.window_manager = MagicMock() + tab.window_manager.get_active_window_count.return_value = 0 + tab.empty_state_panel = MagicMock() + tab.empty_state_hint = MagicMock() + tab.settings_manager = MagicMock() + tab.settings_manager.get.side_effect = lambda key, default=None: { + "general.show_setup_guidance": True, + "general.auto_discovery": True, + }.get(key, default) + + tab._update_empty_state_visibility() + + tab.empty_state_panel.setVisible.assert_called_once_with(True) + tab.empty_state_hint.setText.assert_called_once() + + def test_update_empty_state_visibility_hides_panel_when_windows_exist(self): + """Test empty-state panel is hidden once windows are active.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.window_manager = MagicMock() + tab.window_manager.get_active_window_count.return_value = 2 + tab.empty_state_panel = MagicMock() + tab.empty_state_hint = MagicMock() + tab.settings_manager = MagicMock() + tab.settings_manager.get.return_value = True + + tab._update_empty_state_visibility() + + tab.empty_state_panel.setVisible.assert_called_once_with(False) + + +class TestEmptyStateBusy: + """Tests for Overview empty-state busy state handling.""" + + def test_set_empty_state_busy_updates_controls(self): + """Test onboarding actions switch into a busy/importing state.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.empty_state_import_btn = MagicMock() + tab.empty_state_add_btn = MagicMock() + tab.empty_state_hint = MagicMock() + + tab._set_empty_state_busy(True, "Scanning now...") + + tab.empty_state_import_btn.setEnabled.assert_called_once_with(False) + tab.empty_state_import_btn.setText.assert_called_once_with("Importing...") + tab.empty_state_add_btn.setEnabled.assert_called_once_with(False) + tab.empty_state_hint.setText.assert_called_once_with("Scanning now...") + + def test_set_empty_state_busy_restores_default_copy(self): + """Test leaving busy state restores the default onboarding text.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.empty_state_import_btn = MagicMock() + tab.empty_state_add_btn = MagicMock() + tab.empty_state_hint = MagicMock() + tab.settings_manager = MagicMock() + tab.settings_manager.get.side_effect = lambda key, default=None: { + "general.show_setup_guidance": True, + "general.auto_discovery": True, + }.get(key, default) + + tab._set_empty_state_busy(False) + + tab.empty_state_import_btn.setEnabled.assert_called_once_with(True) + tab.empty_state_import_btn.setText.assert_called_once_with("Import Windows") + tab.empty_state_add_btn.setEnabled.assert_called_once_with(True) + tab.empty_state_hint.setText.assert_called_once() + + +class TestEmptyStateProgress: + """Tests for onboarding progress copy during imports.""" + + def test_set_empty_state_progress_updates_hint(self): + """Test progress helper shows import counts and remaining work.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.empty_state_hint = MagicMock() + + tab._set_empty_state_progress(current=2, total=5, added=1, skipped=1) + + tab.empty_state_hint.setText.assert_called_once_with( + "Processing clients... Imported 1 client(s), skipped 1. 3 remaining." + ) + + +class TestImportCompletionSummary: + """Tests for temporary post-import guidance in Overview.""" + + def test_show_import_completion_summary_sets_summary_and_starts_timer(self): + """Test import completion summary is stored and timed.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab._import_summary_timer = MagicMock() + tab._update_empty_state_visibility = MagicMock() + tab._recent_import_summary = None + + tab._show_import_completion_summary(added=3, skipped=1, detected=4) + + assert tab._recent_import_summary == "Import complete: 3 added, 1 skipped, 4 detected." + tab._update_empty_state_visibility.assert_called_once() + tab._import_summary_timer.start.assert_called_once_with(12000) + + def test_clear_import_completion_summary_resets_card(self): + """Test clearing the import summary returns the card to normal state.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab._recent_import_summary = "Import complete: 1 added, 0 skipped, 1 detected." + tab._update_empty_state_visibility = MagicMock() + + tab._clear_import_completion_summary() + + assert tab._recent_import_summary is None + tab._update_empty_state_visibility.assert_called_once() + + class TestArrangementGridDragMoveEvent: """Tests for ArrangementGrid dragMoveEvent""" @@ -8041,24 +8181,21 @@ def test_one_click_import_all_already_imported(self): class TestOnWindowActivatedFailure: - """Tests for _on_window_activated when activation fails""" + """Tests for _on_window_activated forwarding behavior.""" - def test_on_window_activated_logs_warning_on_failure(self): - """Test _on_window_activated logs warning when activate_window returns False""" + def test_on_window_activated_does_not_attempt_local_activation(self): + """Test _on_window_activated only emits a focus request.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False # Disable auto-minimize - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = False # Activation fails + tab.window_focus_requested = MagicMock() tab._on_window_activated("0x123") - tab.logger.warning.assert_called_once() - assert "Failed to activate" in str(tab.logger.warning.call_args) + tab.window_focus_requested.emit.assert_called_once_with("0x123") + tab.logger.warning.assert_not_called() class TestMinimizeInactiveXdotoolFails: @@ -8439,7 +8576,9 @@ def test_setup_ui_creates_layout_and_widgets(self): tab.logger = MagicMock() tab._create_toolbar = MagicMock(return_value=MagicMock()) tab._create_layout_controls = MagicMock(return_value=MagicMock()) + tab._create_empty_state_panel = MagicMock(return_value=MagicMock()) tab._create_status_bar = MagicMock(return_value=MagicMock()) + tab._update_empty_state_visibility = MagicMock() tab.setLayout = MagicMock() mock_scroll = MagicMock() @@ -8464,7 +8603,9 @@ def test_setup_ui_creates_layout_and_widgets(self): # Toolbar, layout controls, status bar created tab._create_toolbar.assert_called_once() tab._create_layout_controls.assert_called_once() + tab._create_empty_state_panel.assert_called_once() tab._create_status_bar.assert_called_once() + tab._update_empty_state_visibility.assert_called_once() # Scroll area configured mock_scroll.setWidgetResizable.assert_called_once_with(True) @@ -8785,54 +8926,35 @@ def test_minimize_xdotool_exception_sets_none_result(self): class TestOnWindowActivatedAutoMinimizeSuccess: - """Tests for _on_window_activated successful auto-minimize of previous window""" + """Tests for _on_window_activated forwarding with no side effects.""" def test_on_window_activated_auto_minimizes_previous(self): - """Test successful xdotool minimize logs info message""" + """Test forwarding no longer minimizes windows inside MainTab.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = True # auto_minimize enabled - tab.settings_manager.get_last_activated_window.return_value = "0xOLD" - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True - - with patch("argus_overview.utils.window_utils.run_x11_subprocess") as mock_x11: - mock_x11.return_value = MagicMock(returncode=0) - tab._on_window_activated("0xNEW") + tab.window_focus_requested = MagicMock() - # Should have minimized old window - mock_x11.assert_called_once_with(["xdotool", "windowminimize", "0xOLD"], timeout=2) - # Should log success - tab.logger.info.assert_any_call("Auto-minimized previous EVE window: 0xOLD") + tab._on_window_activated("0xNEW") - # Should track new window - tab.settings_manager.set_last_activated_window.assert_called_with("0xNEW") + tab.window_focus_requested.emit.assert_called_once_with("0xNEW") + tab.logger.info.assert_not_called() def test_on_window_activated_auto_minimize_fails(self): - """Test xdotool failure logs warning, doesn't crash""" + """Test forwarding path stays simple even when prior state exists.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = True - tab.settings_manager.get_last_activated_window.return_value = "0xOLD" - tab.capture_system = MagicMock() - tab.capture_system.activate_window.return_value = True + tab.window_focus_requested = MagicMock() - with patch( - "argus_overview.utils.window_utils.run_x11_subprocess", - side_effect=OSError("xdotool failed"), - ): - tab._on_window_activated("0xNEW") + tab._on_window_activated("0xNEW") - tab.logger.warning.assert_called_once() - assert "Failed to auto-minimize" in str(tab.logger.warning.call_args) + tab.window_focus_requested.emit.assert_called_once_with("0xNEW") + tab.logger.warning.assert_not_called() # ============================================================================= @@ -8990,53 +9112,46 @@ def test_stop_capture_loop_stops_everything(self): class TestOnWindowActivatedException: - """Tests for _on_window_activated when activate_window raises an exception.""" + """Tests for _on_window_activated simple forwarding behavior.""" def test_on_window_activated_catches_runtime_error(self): - """Lines 1760-1761: RuntimeError from capture_system.activate_window is caught.""" + """Forwarding path does not depend on capture-system runtime errors.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False - tab.capture_system = MagicMock() - tab.capture_system.activate_window.side_effect = RuntimeError("X11 gone") + tab.window_focus_requested = MagicMock() tab._on_window_activated("0x123") - tab.logger.error.assert_called_once() - assert "Error activating window" in str(tab.logger.error.call_args) + tab.window_focus_requested.emit.assert_called_once_with("0x123") + tab.logger.error.assert_not_called() def test_on_window_activated_catches_os_error(self): - """Lines 1760-1761: OSError from capture_system.activate_window is caught.""" + """Forwarding path remains a pure signal emit.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False - tab.capture_system = MagicMock() - tab.capture_system.activate_window.side_effect = OSError("No display") + tab.window_focus_requested = MagicMock() tab._on_window_activated("0x123") - tab.logger.error.assert_called_once() + tab.window_focus_requested.emit.assert_called_once_with("0x123") + tab.logger.error.assert_not_called() def test_on_window_activated_catches_value_error(self): - """Lines 1760-1761: ValueError from capture_system.activate_window is caught.""" + """Forwarding path no longer validates window IDs in MainTab.""" from argus_overview.ui.main_tab import MainTab with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() - tab.settings_manager = MagicMock() - tab.settings_manager.get.return_value = False - tab.capture_system = MagicMock() - tab.capture_system.activate_window.side_effect = ValueError("bad id") + tab.window_focus_requested = MagicMock() tab._on_window_activated("0x123") - tab.logger.error.assert_called_once() + tab.window_focus_requested.emit.assert_called_once_with("0x123") + tab.logger.error.assert_not_called() diff --git a/tests/test_main_window_v21.py b/tests/test_main_window_v21.py index f155058..156cd23 100644 --- a/tests/test_main_window_v21.py +++ b/tests/test_main_window_v21.py @@ -33,6 +33,32 @@ def create_mock_window(): # Create a MagicMock that uses the real methods from MainWindowV21 window = MagicMock(spec=MainWindowV21) window.logger = MagicMock() + window._is_quitting = False + window._auto_discovery_connected = False + window._tab_indexes = {} + window._bulk_import_active = False + window._bulk_import_dirty_characters = False + window._bulk_import_dirty_groups = False + window._pending_discovery_names = [] + window.close = MagicMock() + window._connect_auto_discovery = lambda: MainWindowV21._connect_auto_discovery(window) + window._disconnect_auto_discovery = lambda: MainWindowV21._disconnect_auto_discovery(window) + window._ensure_auto_discovery_state = lambda: MainWindowV21._ensure_auto_discovery_state(window) + window._run_startup_assistant = lambda: MainWindowV21._run_startup_assistant(window) + window._begin_bulk_import = lambda: MainWindowV21._begin_bulk_import(window) + window._finish_bulk_import = lambda: MainWindowV21._finish_bulk_import(window) + window._queue_discovery_notification = lambda name: MainWindowV21._queue_discovery_notification( + window, name + ) + window._flush_discovery_notifications = lambda: MainWindowV21._flush_discovery_notifications( + window + ) + window._queue_main_tab_status_refresh = lambda: MainWindowV21._queue_main_tab_status_refresh( + window + ) + window._flush_main_tab_status_refresh = lambda: MainWindowV21._flush_main_tab_status_refresh( + window + ) # Bind the real methods to our mock window._toggle_visibility = lambda: MainWindowV21._toggle_visibility(window) @@ -61,6 +87,8 @@ def create_mock_window(): window._activate_character = lambda char: MainWindowV21._activate_character(window, char) window._on_profile_selected = lambda name: MainWindowV21._on_profile_selected(window, name) window._show_settings = lambda: MainWindowV21._show_settings(window) + window._show_roster = lambda: MainWindowV21._show_roster(window) + window._show_cycle_control = lambda: MainWindowV21._show_cycle_control(window) window._reload_config = lambda: MainWindowV21._reload_config(window) window._quit_application = lambda: MainWindowV21._quit_application(window) window._apply_setting = lambda k, v: MainWindowV21._apply_setting(window, k, v) @@ -85,6 +113,13 @@ def create_mock_window(): mock_window_mgr.is_valid_window_id.return_value = True window.capture_system = MagicMock() window.capture_system._window_mgr = mock_window_mgr + window.cycle_controller = MagicMock() + window.cycle_controller.cycle.return_value = (0, None) + window.statusBar = MagicMock(return_value=MagicMock()) + window.settings_manager = MagicMock() + window.character_manager = MagicMock() + window._discovery_notification_timer = MagicMock() + window._status_refresh_timer = MagicMock() return window @@ -218,6 +253,7 @@ def test_cycle_next_advances_index(self): """Test cycle_next advances cycling index""" window = create_mock_window() window.cycling_index = 0 + window.cycle_controller.cycle.return_value = (1, "Char2") window.settings_manager = MagicMock() window.settings_manager.get.return_value = {"Default": ["Char1", "Char2", "Char3"]} window.current_cycling_group = "Default" @@ -239,6 +275,7 @@ def test_cycle_next_wraps_around(self): """Test cycle_next wraps to beginning""" window = create_mock_window() window.cycling_index = 2 # Last position + window.cycle_controller.cycle.return_value = (0, "Char1") window.settings_manager = MagicMock() window.settings_manager.get.return_value = {"Default": ["Char1", "Char2", "Char3"]} window.current_cycling_group = "Default" @@ -260,6 +297,7 @@ def test_cycle_prev_decrements_index(self): """Test cycle_prev decrements cycling index""" window = create_mock_window() window.cycling_index = 2 + window.cycle_controller.cycle.return_value = (1, "Char2") window.settings_manager = MagicMock() window.settings_manager.get.return_value = {"Default": ["Char1", "Char2", "Char3"]} window.current_cycling_group = "Default" @@ -282,27 +320,23 @@ def test_cycle_prev_decrements_index(self): class TestActivateWindowBasic: """Tests for _activate_window method (basic)""" - def test_activate_window_calls_capture_system(self): - """Test that activate_window delegates to capture_system""" + def test_activate_window_delegates_to_cycle_controller(self): + """Test that activate_window delegates to CycleController.""" window = create_mock_window() - window.settings_manager = MagicMock() - window.settings_manager.get.return_value = False # auto_minimize off window._activate_window("0x12345") - window.capture_system.activate_window.assert_called_once_with("0x12345") + window.cycle_controller.activate_window.assert_called_once_with("0x12345") - def test_activate_window_handles_exception(self): - """Test that activate_window handles exceptions""" + def test_activate_window_handles_controller_failure(self): + """Test that activate_window still routes through controller on failure.""" window = create_mock_window() - window.settings_manager = MagicMock() - window.settings_manager.get.return_value = False - window.capture_system.activate_window.side_effect = OSError("failed") + window.cycle_controller.activate_window.return_value = False # Should not raise window._activate_window("0x12345") - window.logger.error.assert_called() + window.cycle_controller.activate_window.assert_called_once_with("0x12345") # Test minimize/restore all windows @@ -351,34 +385,26 @@ class TestActivateCharacter: """Tests for _activate_character method""" def test_activate_character_found(self): - """Test activating a found character""" + """Test activating a found character delegates to CycleController.""" window = create_mock_window() - window.settings_manager = MagicMock() - window.settings_manager.get.return_value = False # auto_minimize off - - mock_frame = MagicMock() - mock_frame.character_name = "TestPilot" - - window.main_tab = MagicMock() - window.main_tab.window_manager = MagicMock() - window.main_tab.window_manager.preview_frames = {"0x12345": mock_frame} window._activate_character("TestPilot") - # Should delegate to capture_system.activate_window via _activate_window - window.capture_system.activate_window.assert_called_once_with("0x12345") + window.cycle_controller.activate_character.assert_called_once_with( + "TestPilot", + window._get_window_id_for_character, + ) def test_activate_character_not_found(self): - """Test activating a character not found""" + """Test activating a character still delegates lookup to controller.""" window = create_mock_window() - window.main_tab = MagicMock() - window.main_tab.window_manager = MagicMock() - window.main_tab.window_manager.preview_frames = {} - window._activate_character("Unknown") - window.logger.warning.assert_called() + window.cycle_controller.activate_character.assert_called_once_with( + "Unknown", + window._get_window_id_for_character, + ) # Test profile selection @@ -411,12 +437,45 @@ def test_show_settings_switches_to_tab(self): window.show = MagicMock() window.raise_ = MagicMock() window.tabs = MagicMock() + window._tab_indexes = {"Settings": 5} window._show_settings() window.show.assert_called_once() window.raise_.assert_called_once() - window.tabs.setCurrentIndex.assert_called_with(4) + window.tabs.setCurrentIndex.assert_called_with(5) + + +class TestTabNavigation: + """Tests for overview next-step tab navigation.""" + + def test_show_roster_switches_to_roster_tab(self): + """Test _show_roster shows the window and switches to Roster.""" + window = create_mock_window() + window.show = MagicMock() + window.raise_ = MagicMock() + window.tabs = MagicMock() + window._tab_indexes = {"Roster": 2} + + window._show_roster() + + window.show.assert_called_once() + window.raise_.assert_called_once() + window.tabs.setCurrentIndex.assert_called_once_with(2) + + def test_show_cycle_control_switches_to_cycle_control_tab(self): + """Test _show_cycle_control shows the window and switches tabs.""" + window = create_mock_window() + window.show = MagicMock() + window.raise_ = MagicMock() + window.tabs = MagicMock() + window._tab_indexes = {"Cycle Control": 3} + + window._show_cycle_control() + + window.show.assert_called_once() + window.raise_.assert_called_once() + window.tabs.setCurrentIndex.assert_called_once_with(3) # Test reload config @@ -452,14 +511,14 @@ def test_reload_config_reloads_settings(self): class TestQuitApplication: """Tests for _quit_application method""" - @patch("argus_overview.ui.main_window_v21.QApplication") - def test_quit_application_calls_quit(self, mock_app): - """Test that quit_application calls QApplication.quit""" + def test_quit_application_sets_flag_and_closes(self): + """Test that quit_application marks quitting and closes the window.""" window = create_mock_window() window._quit_application() - mock_app.quit.assert_called_once() + assert window._is_quitting is True + window.close.assert_called_once() # Test apply setting @@ -481,13 +540,23 @@ class TestOnCharacterDetected: """Tests for _on_character_detected slot""" def test_on_character_detected_assigns_window(self): - """Test that character detection assigns window""" + """Test that character detection bootstraps and assigns window state.""" window = create_mock_window() window.character_manager = MagicMock() + window._add_to_default_cycling_group = MagicMock() window._on_character_detected("0x12345", "TestPilot") - window.character_manager.assign_window.assert_called_with("TestPilot", "0x12345") + window.character_manager.ensure_character.assert_called_with( + "TestPilot", + auto_save=True, + ) + window.character_manager.assign_window.assert_called_with( + "TestPilot", + "0x12345", + auto_save=True, + ) + window._add_to_default_cycling_group.assert_called_with("TestPilot", auto_save=True) # Test team selected @@ -542,6 +611,29 @@ def test_close_event_minimizes_to_tray(self): window.hide.assert_called_once() mock_event.ignore.assert_called_once() + def test_close_event_bypasses_tray_when_quitting(self): + """Test that explicit quit bypasses minimize-to-tray behavior.""" + window = create_mock_window() + window._is_quitting = True + + window.settings_manager = MagicMock() + window.settings_manager.get.return_value = True + + window.auto_discovery = MagicMock() + window.capture_system = MagicMock() + window.hotkey_manager = MagicMock() + window.system_tray = MagicMock() + window.character_manager = MagicMock() + window._disconnect_signals = MagicMock() + window._disconnect_auto_discovery = MagicMock() + + mock_event = MagicMock() + + window.closeEvent(mock_event) + + window.hide.assert_not_called() + mock_event.accept.assert_called_once() + def test_close_event_actually_closes(self): """Test that close actually closes when tray disabled""" window = create_mock_window() @@ -616,12 +708,12 @@ def test_on_new_character_discovered_adds_window(self): """Test that new character adds window to main tab""" window = create_mock_window() - # Mock main_tab - mock_frame = MagicMock() window.main_tab = MagicMock() window.main_tab.window_manager = MagicMock() window.main_tab.window_manager.preview_frames = {} # Not already there - window.main_tab.window_manager.add_window.return_value = mock_frame + window.main_tab.import_detected_window.return_value = True + window._queue_main_tab_status_refresh = MagicMock() + window._queue_discovery_notification = MagicMock() window.settings_manager = MagicMock() window.settings_manager.get.return_value = True # show_notifications @@ -630,8 +722,9 @@ def test_on_new_character_discovered_adds_window(self): window._on_new_character_discovered("NewPilot", "0x99999", "EVE - NewPilot") - window.main_tab.window_manager.add_window.assert_called_with("0x99999", "NewPilot") - window.system_tray.show_notification.assert_called() + window.main_tab.import_detected_window.assert_called_with("0x99999", "NewPilot") + window._queue_main_tab_status_refresh.assert_called_once() + window._queue_discovery_notification.assert_called_once_with("NewPilot") # Test toggle lock @@ -765,12 +858,13 @@ def test_reload_config_stops_auto_discovery_when_disabled(self): window.theme_manager = MagicMock() window.auto_discovery = MagicMock() + window._ensure_auto_discovery_state = MagicMock() window.system_tray = MagicMock() window._apply_initial_settings = MagicMock() window._reload_config() - window.auto_discovery.stop.assert_called_once() + window._ensure_auto_discovery_state.assert_called_once() def test_reload_config_updates_running_auto_discovery(self): """Test reload_config updates interval when auto-discovery running""" @@ -787,14 +881,145 @@ def test_reload_config_updates_running_auto_discovery(self): window.auto_discovery = MagicMock() window.auto_discovery.scan_timer = MagicMock() window.auto_discovery.scan_timer.isActive.return_value = True # Already running + window._ensure_auto_discovery_state = MagicMock() window.system_tray = MagicMock() window._apply_initial_settings = MagicMock() window._reload_config() - window.auto_discovery.set_interval.assert_called_with(10) - window.auto_discovery.start.assert_not_called() # Already running + window._ensure_auto_discovery_state.assert_called_once() + + +class TestStartupAssistant: + """Tests for startup onboarding automation.""" + + def test_startup_assistant_auto_imports_running_clients(self): + """Test startup assistant auto-imports when enabled and overview is empty.""" + window = create_mock_window() + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.window_manager.get_active_window_count.return_value = 0 + window.main_tab.one_click_import.return_value = (2, 0, 2) + window.system_tray = MagicMock() + + def get_side_effect(key, default=None): + values = { + "general.auto_import_on_startup": True, + "general.show_notifications": True, + } + return values.get(key, default) + + window.settings_manager = MagicMock() + window.settings_manager.get.side_effect = get_side_effect + + window._run_startup_assistant() + + window.main_tab.one_click_import.assert_called_once_with(show_dialogs=False) + window.statusBar.return_value.showMessage.assert_called_once() + window.system_tray.show_notification.assert_called_once() + + def test_startup_assistant_flushes_batched_saves_once(self): + """Test startup assistant finishes deferred persistence after bulk import.""" + window = create_mock_window() + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.window_manager.get_active_window_count.return_value = 0 + + def import_side_effect(*, show_dialogs=False): + window._bulk_import_dirty_characters = True + window._bulk_import_dirty_groups = True + return (3, 0, 3) + + window.main_tab.one_click_import.side_effect = import_side_effect + window.system_tray = MagicMock() + + def get_side_effect(key, default=None): + values = { + "general.auto_import_on_startup": True, + "general.show_notifications": False, + } + return values.get(key, default) + + window.settings_manager.get.side_effect = get_side_effect + + window._run_startup_assistant() + + window.character_manager.save_data.assert_called_once() + window.settings_manager.save_settings.assert_called_once() + + +class TestDiscoveryNotifications: + """Tests for batched auto-discovery notifications.""" + + def test_queue_discovery_notification_batches_names(self): + """Test queued names are accumulated and timer restarted.""" + window = create_mock_window() + + window._queue_discovery_notification("Pilot One") + window._queue_discovery_notification("Pilot Two") + window._queue_discovery_notification("Pilot One") + + assert window._pending_discovery_names == ["Pilot One", "Pilot Two"] + assert window._discovery_notification_timer.start.call_count == 3 + + def test_flush_discovery_notifications_shows_single_summary(self): + """Test multiple queued discoveries are shown as one summary notification.""" + window = create_mock_window() + window.system_tray = MagicMock() + window._pending_discovery_names = ["Pilot One", "Pilot Two", "Pilot Three", "Pilot Four"] + + window._flush_discovery_notifications() + + window.system_tray.show_notification.assert_called_once() + assert window._pending_discovery_names == [] + + +class TestStatusRefreshQueue: + """Tests for debounced main-tab status refreshes.""" + + def test_queue_main_tab_status_refresh_starts_timer(self): + """Test status refreshes are queued through the debounce timer.""" + window = create_mock_window() + + window._queue_main_tab_status_refresh() + + window._status_refresh_timer.start.assert_called_once() + + def test_flush_main_tab_status_refresh_updates_overview(self): + """Test flushing the queued refresh updates the overview once.""" + window = create_mock_window() + window.main_tab = MagicMock() + + window._flush_main_tab_status_refresh() + + window.main_tab._update_status.assert_called_once() + + def test_startup_assistant_shows_guidance_when_nothing_imported(self): + """Test startup assistant shows quick-start guidance when no clients are imported.""" + window = create_mock_window() + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.window_manager.get_active_window_count.return_value = 0 + window.main_tab.one_click_import.return_value = (0, 0, 0) + window.system_tray = MagicMock() + + def get_side_effect(key, default=None): + values = { + "general.auto_import_on_startup": True, + "general.show_notifications": True, + "general.show_setup_guidance": True, + } + return values.get(key, default) + + window.settings_manager = MagicMock() + window.settings_manager.get.side_effect = get_side_effect + + window._run_startup_assistant() + + window.main_tab.one_click_import.assert_called_once_with(show_dialogs=False) + window.statusBar.return_value.showMessage.assert_called_once() + window.system_tray.show_notification.assert_not_called() # Test apply setting edge cases @@ -828,6 +1053,7 @@ def test_on_character_detected_updates_characters_tab(self): """Test that character detection updates characters tab if available""" window = create_mock_window() window.character_manager = MagicMock() + window._add_to_default_cycling_group = MagicMock() window.characters_tab = MagicMock() window._on_character_detected("0x12345", "TestPilot") @@ -1080,8 +1306,7 @@ def test_on_new_character_discovered_already_exists(self): window._on_new_character_discovered("ExistingPilot", "0x99999", "EVE - ExistingPilot") - # add_window should NOT be called - window.main_tab.window_manager.add_window.assert_not_called() + window.main_tab.import_detected_window.assert_not_called() # Test new character discovered - no notification @@ -1092,12 +1317,11 @@ def test_on_new_character_discovered_no_notification(self): """Test that notification is skipped when disabled""" window = create_mock_window() - mock_frame = MagicMock() window.main_tab = MagicMock() window.main_tab.window_manager = MagicMock() window.main_tab.window_manager.preview_frames = {} - window.main_tab.window_manager.add_window.return_value = mock_frame - window.main_tab.preview_layout = MagicMock() + window.main_tab.import_detected_window.return_value = True + window._queue_main_tab_status_refresh = MagicMock() window.settings_manager = MagicMock() window.settings_manager.get.return_value = False # show_notifications disabled @@ -1106,29 +1330,30 @@ def test_on_new_character_discovered_no_notification(self): window._on_new_character_discovered("NewPilot", "0x88888", "EVE - NewPilot") - # add_window should be called - window.main_tab.window_manager.add_window.assert_called_once() + window.main_tab.import_detected_window.assert_called_once_with("0x88888", "NewPilot") + window._queue_main_tab_status_refresh.assert_called_once() # But show_notification should NOT be called window.system_tray.show_notification.assert_not_called() # Test new character discovered - frame is None class TestNewCharacterFrameNone: - """Test _on_new_character_discovered when add_window returns None""" + """Test _on_new_character_discovered when shared import returns False""" def test_on_new_character_discovered_frame_none(self): - """Test handling when add_window returns None""" + """Test handling when shared import path fails.""" window = create_mock_window() window.main_tab = MagicMock() window.main_tab.window_manager = MagicMock() window.main_tab.window_manager.preview_frames = {} - window.main_tab.window_manager.add_window.return_value = None # Failed to create + window.main_tab.import_detected_window.return_value = False + window._queue_main_tab_status_refresh = MagicMock() window._on_new_character_discovered("NewPilot", "0x77777", "EVE - NewPilot") - # Should not try to connect signals on None - window.main_tab.preview_layout.addWidget.assert_not_called() + window.main_tab.import_detected_window.assert_called_once_with("0x77777", "NewPilot") + window._queue_main_tab_status_refresh.assert_not_called() # Test minimize/restore handles no main_tab @@ -1157,13 +1382,13 @@ class TestActivateCharacterNoMainTab: """Test _activate_character when main_tab missing""" def test_activate_character_no_main_tab(self): - """Test activate_character handles missing main_tab""" + """Test activate_character still delegates even if main_tab is missing.""" window = create_mock_window() del window.main_tab window._activate_character("SomeChar") - window.logger.warning.assert_called() + window.cycle_controller.activate_character.assert_called_once() # Test _register_hotkeys @@ -1294,96 +1519,36 @@ def test_register_cycling_hotkeys_skips_empty(self): # Test _activate_window (platform abstraction layer) class TestActivateWindowPlatform: - """Tests for _activate_window method using capture_system abstraction""" + """Tests for _activate_window delegation to CycleController.""" - def _make_window(self, *, auto_minimize=False, valid_id=True): - """Helper to create a mock window with capture_system.""" + def _make_window(self): + """Helper to create a mock window with CycleController.""" from argus_overview.ui.main_window_v21 import MainWindowV21 window = MagicMock(spec=MainWindowV21) window.logger = MagicMock() - window.settings_manager = MagicMock() - window.settings_manager.get.return_value = auto_minimize - window.capture_system = MagicMock() - window.capture_system._window_mgr = MagicMock() - window.capture_system._window_mgr.is_valid_window_id.return_value = valid_id + window.cycle_controller = MagicMock() return window def test_activate_window_success(self): - """Test activating window delegates to capture_system""" + """Test activating window delegates to CycleController.""" window = self._make_window() from argus_overview.ui.main_window_v21 import MainWindowV21 MainWindowV21._activate_window(window, "0x12345") - window.capture_system.activate_window.assert_called_once_with("0x12345") + window.cycle_controller.activate_window.assert_called_once_with("0x12345") def test_activate_window_failure(self): - """Test activate window handles failure""" + """Test activate window does not swallow controller invocation.""" window = self._make_window() - window.capture_system.activate_window.side_effect = OSError("failed") - - from argus_overview.ui.main_window_v21 import MainWindowV21 - - MainWindowV21._activate_window(window, "0x12345") - - window.logger.error.assert_called() - - def test_activate_window_with_auto_minimize(self): - """Test activating window minimizes previous when auto-minimize enabled""" - window = self._make_window(auto_minimize=True) - window.settings_manager.get_last_activated_window.return_value = "0x99999" from argus_overview.ui.main_window_v21 import MainWindowV21 MainWindowV21._activate_window(window, "0x12345") - # Verify minimize was called on previous window - window.capture_system.minimize_window.assert_called_once_with("0x99999") - # Verify activate was called on new window - window.capture_system.activate_window.assert_called_once_with("0x12345") - - def test_activate_window_invalid_id_none(self): - """Test activate window rejects None window ID""" - window = self._make_window(valid_id=False) - - from argus_overview.ui.main_window_v21 import MainWindowV21 - - MainWindowV21._activate_window(window, None) - - window.logger.warning.assert_called() - window.capture_system.activate_window.assert_not_called() - - def test_activate_window_invalid_id_format(self): - """Test activate window rejects invalid window ID format""" - window = self._make_window(valid_id=False) - - from argus_overview.ui.main_window_v21 import MainWindowV21 - - # Test various invalid formats - for invalid_id in ["12345", "abc", "0xGGGG", "", "window123"]: - window.capture_system.activate_window.reset_mock() - window.logger.reset_mock() - - MainWindowV21._activate_window(window, invalid_id) - - window.logger.warning.assert_called() - window.capture_system.activate_window.assert_not_called() - - def test_activate_window_valid_id_formats(self): - """Test activate window accepts valid window ID formats""" - window = self._make_window() - - from argus_overview.ui.main_window_v21 import MainWindowV21 - - # Test various valid formats - for valid_id in ["0x12345", "0xABCDEF", "0x0", "0xFFFFFFFF"]: - window.capture_system.activate_window.reset_mock() - - MainWindowV21._activate_window(window, valid_id) - - window.capture_system.activate_window.assert_called_once_with(valid_id) + window.cycle_controller.activate_window.assert_called_once_with("0x12345") # Test _create_menu_bar diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index a52b8d2..65fe781 100644 --- a/tests/test_settings_manager.py +++ b/tests/test_settings_manager.py @@ -81,6 +81,8 @@ def test_default_settings_has_general(self): assert "start_with_system" in general assert "minimize_to_tray" in general assert "auto_discovery" in general + assert "auto_import_on_startup" in general + assert "show_setup_guidance" in general def test_default_settings_has_performance(self): """Test that DEFAULT_SETTINGS has performance section""" @@ -98,7 +100,34 @@ def test_default_settings_has_thumbnails(self): assert "thumbnails" in SettingsManager.DEFAULT_SETTINGS thumbs = SettingsManager.DEFAULT_SETTINGS["thumbnails"] assert "opacity_on_hover" in thumbs - assert "default_width" in thumbs + + +class TestEnsureCharacter: + """Tests for lightweight character bootstrapping.""" + + def test_ensure_character_creates_missing_record(self, tmp_path): + """Test ensure_character creates a minimal character entry when absent.""" + from argus_overview.core.character_manager import AUTO_CREATED_NOTE, CharacterManager + + manager = CharacterManager(config_dir=tmp_path) + + result = manager.ensure_character("New Pilot") + + assert result is True + assert "New Pilot" in manager.characters + assert manager.characters["New Pilot"].notes == AUTO_CREATED_NOTE + + def test_ensure_character_is_noop_for_existing_record(self, tmp_path): + """Test ensure_character leaves existing records intact.""" + from argus_overview.core.character_manager import Character, CharacterManager + + manager = CharacterManager(config_dir=tmp_path) + manager.add_character(Character(name="Existing Pilot")) + + result = manager.ensure_character("Existing Pilot") + + assert result is True + assert manager.characters["Existing Pilot"].name == "Existing Pilot" def test_default_settings_has_hotkeys(self): """Test that DEFAULT_SETTINGS has hotkeys section"""