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..0c2a1b9 --- /dev/null +++ b/.github/workflows/ai-quality-gate.yml @@ -0,0 +1,125 @@ +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: "9.0.3" + PYTEST_ASYNCIO_VERSION: "1.4.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 system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon0 libxcb-cursor0 xvfb + + - 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 e9fdf44..3df246d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,13 @@ concurrency: permissions: contents: read +env: + RUFF_VERSION: "0.15.11" + MYPY_VERSION: "1.19.1" + PYTEST_VERSION: "9.0.3" + PYTEST_ASYNCIO_VERSION: "1.4.0" + PYTEST_COV_VERSION: "7.1.0" + jobs: lint: name: Lint & Format @@ -39,7 +46,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 @@ -64,11 +71,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 + run: mypy src/argus_overview --ignore-missing-imports --no-error-summary test: name: Test (Python ${{ matrix.python-version }}) @@ -96,7 +102,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 7217b6f..cb4fca6 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 dd68517..0e288fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -> **STATUS: FROZEN as of 2026-06-22** -> This repository is no longer actively developed. See [AreteDriver/notes/decisions/2026-04-21-portfolio-triage.md](https://github.com/AreteDriver/notes/blob/main/decisions/2026-04-21-portfolio-triage.md) for context. -> Archived for reference. No new deploys or feature work. +> **STATUS: ACTIVELY MAINTAINED** +> Version 3.3.0 is an unreleased release candidate. Runtime validation and +> platform-hardening work remain tracked in [ROADMAP.md](ROADMAP.md). > > --- > @@ -33,10 +33,20 @@ tar -xzf Argus-Overview-*-Linux.tar.gz && cd argus-overview-linux **Windows:** Download the `.exe` from [Releases](https://github.com/AreteDriver/Argus_Overview/releases) and run it. -**1,875 tests · 96% coverage · 2,500+ downloads** +**2,580+ tests · 96% coverage · 2,500+ downloads** > 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/ROADMAP.md b/ROADMAP.md index 75e032b..9a74cad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,15 +2,16 @@ **Project**: Argus Overview — EVE Online desktop multi-boxing / window-preview tool **Classification**: Flagship -**Version**: 3.2.0 -**Last updated**: 2026-07-26 +**Version**: 3.3.0 (release candidate) +**Last updated**: 2026-08-18 **Next review**: 2026-08-26 --- ## Current State -- Shipping v3.2.0 with 2,495 tests, 96%+ coverage +- Actively maintaining v3.2.0 while preparing the v3.3.0 release candidate +- 2,580+ tests with 96%+ coverage - Mature PySide6 codebase with Windows, Linux (X11/Wayland) support - Full `intel/` subsystem for chat-log parsing and threat detection - v6 aspirational artifacts **archived** (no longer misleading) @@ -63,7 +64,8 @@ ## Blockers -- None. Docs-only work. +- Release automation is green; final confidence depends on manual validation + with real EVE clients on Linux/Wayland and Windows. ## Definition of Done (Phase 1) — ✅ COMPLETE diff --git a/benchmarks/benchmark_core.py b/benchmarks/benchmark_core.py index 4b8e431..ae5d81d 100644 --- a/benchmarks/benchmark_core.py +++ b/benchmarks/benchmark_core.py @@ -14,8 +14,8 @@ import statistics import sys import time +from collections.abc import Callable from pathlib import Path -from typing import Callable, List from unittest.mock import MagicMock, patch # Add src to path @@ -32,7 +32,7 @@ def benchmark(func: Callable, iterations: int = 1000, warmup: int = 10) -> dict: gc.collect() # Actual benchmark - times: List[float] = [] + times: list[float] = [] for _ in range(iterations): start = time.perf_counter_ns() func() 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 6452431..a3360b2 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==9.0.3", + "pytest-asyncio==1.4.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/__init__.py b/src/argus_overview/__init__.py index c5893cc..408aba6 100644 --- a/src/argus_overview/__init__.py +++ b/src/argus_overview/__init__.py @@ -1,3 +1,3 @@ -"""Argus Overview v3.2 - Intel-Aware Edition""" +"""Argus Overview v3.3 release candidate.""" -__version__ = "3.2.0" +__version__ = "3.3.0" 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/intel/log_watcher.py b/src/argus_overview/intel/log_watcher.py index 8face73..2953eb9 100644 --- a/src/argus_overview/intel/log_watcher.py +++ b/src/argus_overview/intel/log_watcher.py @@ -224,7 +224,7 @@ def tail_file(self, filepath: Path) -> list[ChatMessage]: Returns: List of new ChatMessages """ - messages = [] + messages: list[ChatMessage] = [] if not filepath.exists(): return messages diff --git a/src/argus_overview/platform/windows.py b/src/argus_overview/platform/windows.py index f4fadd5..b6c996a 100644 --- a/src/argus_overview/platform/windows.py +++ b/src/argus_overview/platform/windows.py @@ -27,7 +27,7 @@ # Check for Win32 availability try: - from ctypes import windll + from ctypes import windll # type: ignore[attr-defined] import pywintypes import win32api @@ -366,7 +366,7 @@ def capture_window_sync(self, window_id: str, scale: float = 1.0) -> Image.Image if scale < 1.0: new_width = int(width * scale) new_height = int(height * scale) - image = image.resize((new_width, new_height), Image.LANCZOS) + image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) return image diff --git a/src/argus_overview/ui/characters_teams_tab.py b/src/argus_overview/ui/characters_teams_tab.py index ecd240a..90cb1e1 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,75 @@ 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( + f"Review imported characters: {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 +831,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 +857,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): @@ -783,6 +881,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): @@ -817,6 +916,7 @@ def _scan_eve_folder(self): # Refresh table self.character_table.populate_table() + self._refresh_setup_summary() # Show results QMessageBox.information( @@ -854,3 +954,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/command/fleet_rail.py b/src/argus_overview/ui/command/fleet_rail.py index ec0028a..e3a7d7a 100644 --- a/src/argus_overview/ui/command/fleet_rail.py +++ b/src/argus_overview/ui/command/fleet_rail.py @@ -334,11 +334,15 @@ def keyPressEvent(self, event) -> None: event.accept() return if event.key() == Qt.Key.Key_Right: - self.parentWidget().focusNextChild() + parent = self.parentWidget() + if parent is not None: + parent.focusNextChild() event.accept() return if event.key() == Qt.Key.Key_Left: - self.parentWidget().focusPreviousChild() + parent = self.parentWidget() + if parent is not None: + parent.focusPreviousChild() event.accept() return super().keyPressEvent(event) diff --git a/src/argus_overview/ui/command/header.py b/src/argus_overview/ui/command/header.py index d0bdbe2..a581ab4 100644 --- a/src/argus_overview/ui/command/header.py +++ b/src/argus_overview/ui/command/header.py @@ -327,7 +327,7 @@ def paintEvent(event): # noqa: ARG001 finally: p.end() - self.paintEvent = paintEvent + self.paintEvent = paintEvent # type: ignore[method-assign] def update_state( self, fleet_count: int, alert_count: int = 0, intel_health: str = "live" diff --git a/src/argus_overview/ui/command/integration.py b/src/argus_overview/ui/command/integration.py index 7eea9c9..4c87c4e 100644 --- a/src/argus_overview/ui/command/integration.py +++ b/src/argus_overview/ui/command/integration.py @@ -179,6 +179,10 @@ def _handler(): ) # Theme switching for theme in ("dark", "light", "eve", "high_contrast"): + + def apply_theme(theme_name: str = theme) -> None: + self._apply_theme(theme_name) + entries.append( PaletteEntry( id=f"theme::{theme}", @@ -186,7 +190,7 @@ def _handler(): subtitle="Switch Argus appearance theme", category="theme", keywords=("theme", "appearance", "color", theme), - handler=lambda t=theme: self._apply_theme(t), + handler=apply_theme, ) ) self._palette.set_entries(entries) diff --git a/src/argus_overview/ui/command/operational_truth.py b/src/argus_overview/ui/command/operational_truth.py index 17d9a66..7c185f5 100644 --- a/src/argus_overview/ui/command/operational_truth.py +++ b/src/argus_overview/ui/command/operational_truth.py @@ -167,7 +167,7 @@ def _paint(ev): # noqa: ARG001 finally: pp.end() - self.paintEvent = _paint + self.paintEvent = _paint # type: ignore[method-assign] # ---- properties -------------------------------------------------------- def _get_pulse(self) -> float: diff --git a/src/argus_overview/ui/command/palette.py b/src/argus_overview/ui/command/palette.py index 67893cf..ff18dae 100644 --- a/src/argus_overview/ui/command/palette.py +++ b/src/argus_overview/ui/command/palette.py @@ -13,8 +13,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Callable +from typing import cast from PySide6.QtCore import ( QEasingCurve, @@ -54,7 +55,7 @@ class PaletteEntry: subtitle: str = "" category: str = "action" # pilot | layout | theme | system | action keywords: tuple[str, ...] = field(default_factory=tuple) - handler: Callable[[], None] | None = None + handler: Callable[[], object] | None = None enabled: bool = True @@ -351,8 +352,9 @@ def keyPressEvent(self, event: QKeyEvent) -> None: def eventFilter(self, obj, event: QEvent) -> bool: if obj is self._input and event.type() == QEvent.Type.KeyPress: - if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up): + key_event = cast(QKeyEvent, event) + if key_event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up): self._list.setFocus() - self._list.keyPressEvent(event) + self._list.keyPressEvent(key_event) return True return super().eventFilter(obj, event) diff --git a/src/argus_overview/ui/command/shell.py b/src/argus_overview/ui/command/shell.py index 6fff303..2abb359 100644 --- a/src/argus_overview/ui/command/shell.py +++ b/src/argus_overview/ui/command/shell.py @@ -138,7 +138,9 @@ def ops_timeline(self) -> OpsTimeline: def truth(self) -> OperationalTruthBar: return self._truth - def palette(self, entries: list[PaletteEntry] | None = None) -> CommandPalette: + def palette( # type: ignore[override] + self, entries: list[PaletteEntry] | None = None + ) -> CommandPalette: pal = CommandPalette(self.window()) if entries is not None: pal.set_entries(entries) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index a7814c3..ed283e6 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -11,7 +11,9 @@ import subprocess import threading import time +from collections.abc import Callable from datetime import datetime +from typing import Any from PIL import Image from PySide6.QtCore import ( @@ -754,7 +756,7 @@ def __init__( self._replay_buffer: deque = deque(maxlen=REPLAY_BUFFER_SIZE) self._replay_last_sample_ms: int = 0 - self._replay_strip = None # type: ignore[var-annotated] + self._replay_strip: ReplayStrip | None = None self._replay_view_index: int | None = None # None = live; int = buffered from argus_overview.ui.design_system import metrics as dm @@ -1322,16 +1324,21 @@ def enable_replay_strip(self, enabled: bool) -> None: height and the flow grid never shifts when the strip is toggled. """ if enabled and self._replay_strip is None: - self._replay_strip = ReplayStrip(parent=self._replay_container) - self._replay_strip.frame_hovered.connect(self._on_replay_frame_hovered) - self._replay_container.layout().addWidget(self._replay_strip) - self._replay_strip.set_frames(list(self._replay_buffer)) + replay_strip = ReplayStrip(parent=self._replay_container) + replay_strip.frame_hovered.connect(self._on_replay_frame_hovered) + container_layout = self._replay_container.layout() + if container_layout is not None: + container_layout.addWidget(replay_strip) + replay_strip.set_frames(list(self._replay_buffer)) + self._replay_strip = replay_strip elif not enabled and self._replay_strip is not None: try: self._replay_strip.frame_hovered.disconnect(self._on_replay_frame_hovered) except (RuntimeError, TypeError): pass - self._replay_container.layout().removeWidget(self._replay_strip) + container_layout = self._replay_container.layout() + if container_layout is not None: + container_layout.removeWidget(self._replay_strip) self._replay_strip.deleteLater() self._replay_strip = None # Drop any held buffered view. @@ -1449,7 +1456,12 @@ def _paint_border_layer(self, painter: QPainter, health: str) -> None: and self._threat_alpha > 0.0 and self._flash_color is None ): - r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + age_threat_level = self._threat_level + r, g, b = ( + THREAT_BORDER_COLORS.get(age_threat_level, (255, 255, 255)) + if age_threat_level is not None + else (255, 255, 255) + ) base_alpha = int(220 * self._threat_alpha) pulse_boost = int(35 * self._pulse_phase) alpha = max(0, min(255, base_alpha + pulse_boost)) @@ -1586,7 +1598,12 @@ def _paint_badge_layer(self, painter: QPainter, health: str) -> None: and not health.startswith("STALE") and health not in ("ERROR", "DISCONNECTED") ): - r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + threat_level = self._threat_level + r, g, b = ( + THREAT_BORDER_COLORS.get(threat_level, (255, 255, 255)) + if threat_level is not None + else (255, 255, 255) + ) pill_parts = [self._threat_system] if self._threat_distance and self._threat_distance > 0: pill_parts.append(f"+{self._threat_distance}j") @@ -1609,7 +1626,12 @@ def _paint_badge_layer(self, painter: QPainter, health: str) -> None: if self._threat_alpha < 0.9 and self._threat_set_at > 0.0: age_secs = int(time.monotonic() - self._threat_set_at) age_text = f"{age_secs}s ago" - r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + age_threat_level = self._threat_level + r, g, b = ( + THREAT_BORDER_COLORS.get(age_threat_level, (255, 255, 255)) + if age_threat_level is not None + else (255, 255, 255) + ) alpha = max(0, min(255, int(220 * self._threat_alpha))) pill_rect = draw_pill( painter, @@ -1759,7 +1781,7 @@ def contextMenuEvent(self, event): # Handler map for context actions. toggle_replay_strip was added # to the registry as a tier-3 WINDOW_CONTEXT action; it joins the # other handlers here. - handlers = { + handlers: dict[str, Callable] = { "focus_window": lambda: self.window_activated.emit(self.window_id), "minimize_window": self._minimize_window, "close_window": self._close_window, @@ -2144,6 +2166,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, @@ -2173,6 +2198,14 @@ def __init__( 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() @@ -2244,6 +2277,8 @@ def _setup_ui(self): self.preview_container = QWidget() self.preview_layout = FlowLayout(margin=15, spacing=15) # Grid-style flow layout self.preview_container.setLayout(self.preview_layout) + self.empty_state_panel = self._create_empty_state_panel() + self.preview_layout.addWidget(self.empty_state_panel) scroll.setWidget(self.preview_container) layout.addWidget(scroll) @@ -2255,6 +2290,159 @@ 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 + + recent_summary = getattr(self, "_recent_import_summary", None) + count = self.window_manager.get_active_window_count() + panel.setVisible(count == 0 or bool(recent_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(recent_summary)) + if recent_summary: + summary.setText(recent_summary) + + roster_btn = getattr(self, "empty_state_roster_btn", None) + if roster_btn is not None: + roster_btn.setVisible(bool(recent_summary)) + + cycle_btn = getattr(self, "empty_state_cycle_btn", None) + if cycle_btn is not None: + cycle_btn.setVisible(bool(recent_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() + timer = getattr(self, "_import_summary_timer", None) + if timer is not None: + 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 _sync_status_dock(self) -> None: """Mirror window_manager.preview_frames into the status dock.""" @@ -2282,7 +2470,7 @@ def _create_toolbar(self) -> QWidget: # Build toolbar buttons from ActionRegistry toolbar_builder = ToolbarBuilder() - handlers = { + handlers: dict[str, Callable[..., Any]] = { "import_windows": self.one_click_import, "remove_all_windows": self._remove_all_windows, "lock_positions": self._toggle_lock, @@ -2644,71 +2832,122 @@ 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, + _checked: bool = False, + *, + show_dialogs: bool = True, + ) -> tuple[int, int, int]: """ v2.2 One-Click Import: Scan and import all EVE windows automatically + + ``_checked`` absorbs the boolean emitted by ``QPushButton.clicked`` so + user-triggered imports retain the default dialog behavior. Startup + automation controls dialogs explicitly through the keyword-only flag. """ 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, ) - frame.focus_requested.connect( - self._on_focus_requested, Qt.ConnectionType.UniqueConnection + + # 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), ) - frame.retry_requested.connect( - self._on_retry_requested, Qt.ConnectionType.UniqueConnection + 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") - # 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") - - self._update_status() - self._sync_status_dock() + return added_count, skipped_count, len(eve_windows) + finally: + self._set_empty_state_busy(False) + self._update_status() + self._sync_status_dock() def _toggle_lock(self): """Toggle thumbnail position lock""" @@ -2898,6 +3137,41 @@ 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, + ) + frame.focus_requested.connect( + self._on_focus_requested, + Qt.ConnectionType.UniqueConnection, + ) + frame.retry_requested.connect( + self._on_retry_requested, + 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 @@ -2911,25 +3185,9 @@ 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 - ) - frame.focus_requested.connect( - self._on_focus_requested, Qt.ConnectionType.UniqueConnection - ) - frame.retry_requested.connect( - self._on_retry_requested, Qt.ConnectionType.UniqueConnection - ) - self.preview_layout.addWidget(frame) + if self.import_detected_window(window_id, char_name): self._sync_status_dock() return True return False @@ -3033,39 +3291,8 @@ def _on_retry_requested(self, window_id: str) -> None: self.window_manager.retry_window_capture(window_id) 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""" @@ -3219,6 +3446,11 @@ def _update_status(self): """Update status bar and dependent UI state.""" 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 'Import All' to start") @@ -3291,12 +3523,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 30ce853..6f1099e 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -48,7 +48,6 @@ from PySide6.QtCore import Qt, QTimer, Slot from PySide6.QtGui import QCloseEvent, QIcon from PySide6.QtWidgets import ( - QApplication, QDialog, QDialogButtonBox, QLabel, @@ -64,6 +63,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 @@ -98,6 +98,20 @@ def __init__(self): self.logger = logging.getLogger(__name__) self.setWindowTitle(f"Argus Overview v{__version__}") self.setMinimumSize(960, 600) + self._is_quitting = False + self._auto_discovery_connected = False + 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() @@ -113,6 +127,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( @@ -205,16 +220,60 @@ 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) # PR4: per-character location tracker (Local channel logs) self._init_location_tracker() self.logger.info("Main window v2.2 initialized successfully") + def _connect_auto_discovery(self): + """Connect auto-discovery signals 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 _init_location_tracker(self) -> None: """Start the per-character location tracker if enabled.""" from argus_overview.intel.character_location import CharacterLocationTracker @@ -312,8 +371,9 @@ def _create_system_status_bar(self) -> None: @Slot(str, str) def _on_hotkey_health_changed(self, status: str, detail: str) -> None: """PR3: update hotkeys indicator when HotkeyManager reports health change.""" - if getattr(self, "system_status_bar", None) is not None: - self.system_status_bar.set_status("hotkeys", status, detail) + status_bar = getattr(self, "system_status_bar", None) + if status_bar is not None: + status_bar.set_status("hotkeys", status, detail) def _register_hotkeys(self): """Register global hotkeys (v2.2)""" @@ -416,20 +476,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"): @@ -445,26 +559,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): @@ -496,37 +596,8 @@ def activate_window(self, window_id: str) -> None: self._activate_window(window_id) 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): @@ -550,6 +621,20 @@ def _show_settings(self): self.raise_() self.tabs.setCurrentIndex(self._TAB_LABELS.index("System")) + @Slot() + def _show_roster(self): + """Show the FLEET tab, which contains the roster.""" + self.show() + self.raise_() + self.tabs.setCurrentIndex(self._TAB_LABELS.index("Fleet")) + + @Slot() + def _show_cycle_control(self): + """Show the LAYOUTS tab, which contains cycle controls.""" + self.show() + self.raise_() + self.tabs.setCurrentIndex(self._TAB_LABELS.index("Layouts")) + @Slot() def _reload_config(self): """Reload configuration (v2.2 hot reload)""" @@ -561,15 +646,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") @@ -578,7 +655,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 @@ -612,14 +724,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): @@ -629,27 +734,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 App menu and Help menu (v2.4 - uses ActionRegistry)""" @@ -842,6 +932,18 @@ def _create_main_tab(self): # 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 the inner CharactersTeamsTab used by the FLEET container. @@ -1034,6 +1136,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 @@ -1076,15 +1181,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, @@ -1242,8 +1338,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( @@ -1268,6 +1371,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( @@ -1276,7 +1380,9 @@ def _on_character_gone(self, char_name: str, window_id: str): self.characters_tab.update_character_status(char_name, None) # Drop stale system from location tracker so chips clear on logoff - self.location_tracker.on_character_gone(char_name, window_id) + location_tracker = getattr(self, "location_tracker", None) + if location_tracker is not None: + location_tracker.on_character_gone(char_name, window_id) @Slot(object) def _on_team_selected(self, team): @@ -1332,7 +1438,7 @@ def show_layout_chooser(self) -> None: item = QListWidgetItem(preset.name) if preset.description: item.setToolTip(preset.description) - item.setData(Qt.UserRole, preset.name) + item.setData(Qt.ItemDataRole.UserRole, preset.name) list_widget.addItem(item) if presets: list_widget.setCurrentRow(0) @@ -1348,7 +1454,7 @@ def _apply_selected() -> None: current = list_widget.currentItem() if current is None: return - preset_name = current.data(Qt.UserRole) + preset_name = current.data(Qt.ItemDataRole.UserRole) try: preset = self.layout_manager.get_preset(preset_name) if preset is not None: @@ -1380,7 +1486,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() @@ -1393,6 +1499,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() @@ -1409,14 +1521,15 @@ def closeEvent(self, event: QCloseEvent): if hasattr(self, "auto_discovery"): self.auto_discovery.stop() - if getattr(self, "location_tracker", None) is not None: + location_tracker = getattr(self, "location_tracker", None) + if location_tracker is not None: try: - self.location_tracker.character_system_changed.disconnect( + location_tracker.character_system_changed.disconnect( self._on_character_system_changed ) except (RuntimeError, TypeError): pass - self.location_tracker.stop() + location_tracker.stop() if hasattr(self, "capture_system"): self.capture_system.stop() diff --git a/src/argus_overview/ui/settings_manager.py b/src/argus_overview/ui/settings_manager.py index 3d250ae..7eebc87 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 c711d19..d15a8a9 100644 --- a/src/argus_overview/ui/settings_tab.py +++ b/src/argus_overview/ui/settings_tab.py @@ -187,6 +187,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) @@ -827,7 +857,9 @@ def _create_category_tree(self) -> QWidget: item = QTreeWidgetItem([category]) self.category_tree.addTopLevelItem(item) - self.category_tree.setCurrentItem(self.category_tree.topLevelItem(0)) + first_category = self.category_tree.topLevelItem(0) + if first_category is not None: + self.category_tree.setCurrentItem(first_category) self.category_tree.currentItemChanged.connect(self._on_category_changed) # Style nav tree with design-system tokens diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index 5b3d670..2dfba5b 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -291,11 +291,15 @@ def keyPressEvent(self, event) -> None: event.accept() return if event.key() == Qt.Key.Key_Right: - self.parentWidget().focusNextChild() + parent = self.parentWidget() + if parent is not None: + parent.focusNextChild() event.accept() return if event.key() == Qt.Key.Key_Left: - self.parentWidget().focusPreviousChild() + parent = self.parentWidget() + if parent is not None: + parent.focusPreviousChild() event.accept() return super().keyPressEvent(event) @@ -501,6 +505,7 @@ def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) chip.set_threat_state(level, system) count += 1 continue + assert system is not None chip_system = getattr(chip, "_system", None) should_apply, alpha = resolve_tint( known_system=chip_system, 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..c898e67 100644 --- a/tests/test_characters_teams_tab.py +++ b/tests/test_characters_teams_tab.py @@ -321,6 +321,7 @@ class TestCharacterTableMethods: def test_populate_table_with_characters(self): """Test populate_table with character list""" + from argus_overview.core.character_manager import AUTO_CREATED_NOTE from argus_overview.ui.characters_teams_tab import CharacterTable with patch.object(CharacterTable, "__init__", return_value=None): @@ -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,44 @@ 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.core.character_manager import AUTO_CREATED_NOTE + from argus_overview.ui.characters_teams_tab import CharacterTable + + 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 +472,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 +1311,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 +1328,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 +1374,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 +1394,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 +1421,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 +1434,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 +1499,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 +1509,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 +1592,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""" @@ -1574,11 +1738,10 @@ def test_setup_ui_creates_splitter(self): tab._create_left_panel = MagicMock(return_value=mock_left_panel) tab._create_right_panel = MagicMock(return_value=mock_right_panel) - with patch( - "argus_overview.ui.characters_teams_tab.QHBoxLayout" - ) as mock_layout_cls, patch( - "argus_overview.ui.characters_teams_tab.QSplitter" - ) as mock_splitter_cls: + with ( + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout") as mock_layout_cls, + patch("argus_overview.ui.characters_teams_tab.QSplitter") as mock_splitter_cls, + ): mock_layout = MagicMock() mock_layout_cls.return_value = mock_layout @@ -1609,8 +1772,9 @@ def test_setup_ui_calls_panel_creators(self): tab._create_right_panel = MagicMock(return_value=MagicMock()) tab.setLayout = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), patch( - "argus_overview.ui.characters_teams_tab.QSplitter" + with ( + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.QSplitter"), ): tab._setup_ui() @@ -1629,15 +1793,13 @@ def test_create_left_panel_creates_toolbar(self): tab._edit_character = MagicMock() tab._delete_character = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, patch( - "argus_overview.ui.characters_teams_tab.QVBoxLayout" - ) as mock_vlayout_cls, patch( - "argus_overview.ui.characters_teams_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch( - "argus_overview.ui.characters_teams_tab.ToolbarBuilder" - ) as mock_builder_cls, patch( - "argus_overview.ui.characters_teams_tab.CharacterTable" - ) as mock_table_cls: + with ( + patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, + patch("argus_overview.ui.characters_teams_tab.QVBoxLayout") as mock_vlayout_cls, + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.characters_teams_tab.ToolbarBuilder") as mock_builder_cls, + patch("argus_overview.ui.characters_teams_tab.CharacterTable") as mock_table_cls, + ): mock_panel = MagicMock() mock_widget_cls.return_value = mock_panel @@ -1679,11 +1841,13 @@ def test_create_left_panel_with_settings_sync(self): tab._delete_character = MagicMock() tab._scan_eve_folder = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QWidget"), patch( - "argus_overview.ui.characters_teams_tab.QVBoxLayout" - ), patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), patch( - "argus_overview.ui.characters_teams_tab.ToolbarBuilder" - ) as mock_builder_cls, patch("argus_overview.ui.characters_teams_tab.CharacterTable"): + with ( + patch("argus_overview.ui.characters_teams_tab.QWidget"), + patch("argus_overview.ui.characters_teams_tab.QVBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.ToolbarBuilder") as mock_builder_cls, + patch("argus_overview.ui.characters_teams_tab.CharacterTable"), + ): mock_builder = MagicMock() mock_builder.create_button.return_value = MagicMock() mock_builder_cls.return_value = mock_builder @@ -1706,13 +1870,13 @@ def test_create_left_panel_handles_none_buttons(self): tab._edit_character = MagicMock() tab._delete_character = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, patch( - "argus_overview.ui.characters_teams_tab.QVBoxLayout" - ), patch( - "argus_overview.ui.characters_teams_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch( - "argus_overview.ui.characters_teams_tab.ToolbarBuilder" - ) as mock_builder_cls, patch("argus_overview.ui.characters_teams_tab.CharacterTable"): + with ( + patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, + patch("argus_overview.ui.characters_teams_tab.QVBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.characters_teams_tab.ToolbarBuilder") as mock_builder_cls, + patch("argus_overview.ui.characters_teams_tab.CharacterTable"), + ): mock_panel = MagicMock() mock_widget_cls.return_value = mock_panel @@ -1743,15 +1907,14 @@ def test_create_right_panel_creates_team_selector(self): tab._on_team_modified = MagicMock() tab._refresh_teams = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, patch( - "argus_overview.ui.characters_teams_tab.QVBoxLayout" - ) as mock_vlayout_cls, patch( - "argus_overview.ui.characters_teams_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch("argus_overview.ui.characters_teams_tab.QLabel"), patch( - "argus_overview.ui.characters_teams_tab.QComboBox" - ) as mock_combo_cls, patch( - "argus_overview.ui.characters_teams_tab.TeamBuilder" - ) as mock_builder_cls: + with ( + patch("argus_overview.ui.characters_teams_tab.QWidget") as mock_widget_cls, + patch("argus_overview.ui.characters_teams_tab.QVBoxLayout") as mock_vlayout_cls, + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.characters_teams_tab.QLabel"), + patch("argus_overview.ui.characters_teams_tab.QComboBox") as mock_combo_cls, + patch("argus_overview.ui.characters_teams_tab.TeamBuilder") as mock_builder_cls, + ): mock_panel = MagicMock() mock_widget_cls.return_value = mock_panel @@ -1794,13 +1957,14 @@ def test_create_right_panel_connects_character_table(self): tab._on_team_modified = MagicMock() tab._refresh_teams = MagicMock() - with patch("argus_overview.ui.characters_teams_tab.QWidget"), patch( - "argus_overview.ui.characters_teams_tab.QVBoxLayout" - ), patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), patch( - "argus_overview.ui.characters_teams_tab.QLabel" - ), patch("argus_overview.ui.characters_teams_tab.QComboBox"), patch( - "argus_overview.ui.characters_teams_tab.TeamBuilder" - ) as mock_builder_cls: + with ( + patch("argus_overview.ui.characters_teams_tab.QWidget"), + patch("argus_overview.ui.characters_teams_tab.QVBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.QHBoxLayout"), + patch("argus_overview.ui.characters_teams_tab.QLabel"), + patch("argus_overview.ui.characters_teams_tab.QComboBox"), + patch("argus_overview.ui.characters_teams_tab.TeamBuilder") as mock_builder_cls, + ): mock_team_builder = MagicMock() mock_builder_cls.return_value = mock_team_builder 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_layouts_tab.py b/tests/test_layouts_tab.py index 3c85387..99e03f5 100644 --- a/tests/test_layouts_tab.py +++ b/tests/test_layouts_tab.py @@ -1669,18 +1669,19 @@ def test_create_top_section_creates_group_selector(self): tab._auto_arrange = MagicMock() tab._update_grid_size = MagicMock() - with patch("argus_overview.ui.layouts_tab.QGroupBox") as mock_groupbox_cls, patch( - "argus_overview.ui.layouts_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch("argus_overview.ui.layouts_tab.QVBoxLayout"), patch( - "argus_overview.ui.layouts_tab.QLabel" - ), patch("argus_overview.ui.layouts_tab.QComboBox") as mock_combo_cls, patch( - "argus_overview.ui.layouts_tab.QPushButton" - ) as mock_btn_cls, patch( - "argus_overview.ui.layouts_tab.QSpinBox" - ) as mock_spin_cls, patch( - "argus_overview.ui.layouts_tab.QCheckBox" - ) as mock_checkbox_cls, patch("argus_overview.ui.layouts_tab.QWidget"), patch( - "argus_overview.ui.layouts_tab.get_all_patterns", return_value=["2x2", "3x1"] + with ( + patch("argus_overview.ui.layouts_tab.QGroupBox") as mock_groupbox_cls, + patch("argus_overview.ui.layouts_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.layouts_tab.QVBoxLayout"), + patch("argus_overview.ui.layouts_tab.QLabel"), + patch("argus_overview.ui.layouts_tab.QComboBox") as mock_combo_cls, + patch("argus_overview.ui.layouts_tab.QPushButton") as mock_btn_cls, + patch("argus_overview.ui.layouts_tab.QSpinBox") as mock_spin_cls, + patch("argus_overview.ui.layouts_tab.QCheckBox") as mock_checkbox_cls, + patch("argus_overview.ui.layouts_tab.QWidget"), + patch( + "argus_overview.ui.layouts_tab.get_all_patterns", return_value=["2x2", "3x1"] + ), ): mock_section = MagicMock() mock_groupbox_cls.return_value = mock_section @@ -1728,16 +1729,17 @@ def test_create_top_section_creates_grid_size_controls(self): tab._auto_arrange = MagicMock() tab._update_grid_size = MagicMock() - with patch("argus_overview.ui.layouts_tab.QGroupBox"), patch( - "argus_overview.ui.layouts_tab.QHBoxLayout" - ), patch("argus_overview.ui.layouts_tab.QVBoxLayout"), patch( - "argus_overview.ui.layouts_tab.QLabel" - ), patch("argus_overview.ui.layouts_tab.QComboBox"), patch( - "argus_overview.ui.layouts_tab.QPushButton" - ), patch("argus_overview.ui.layouts_tab.QSpinBox") as mock_spin_cls, patch( - "argus_overview.ui.layouts_tab.QCheckBox" - ), patch("argus_overview.ui.layouts_tab.QWidget"), patch( - "argus_overview.ui.layouts_tab.get_all_patterns", return_value=[] + with ( + patch("argus_overview.ui.layouts_tab.QGroupBox"), + patch("argus_overview.ui.layouts_tab.QHBoxLayout"), + patch("argus_overview.ui.layouts_tab.QVBoxLayout"), + patch("argus_overview.ui.layouts_tab.QLabel"), + patch("argus_overview.ui.layouts_tab.QComboBox"), + patch("argus_overview.ui.layouts_tab.QPushButton"), + patch("argus_overview.ui.layouts_tab.QSpinBox") as mock_spin_cls, + patch("argus_overview.ui.layouts_tab.QCheckBox"), + patch("argus_overview.ui.layouts_tab.QWidget"), + patch("argus_overview.ui.layouts_tab.get_all_patterns", return_value=[]), ): mock_spin = MagicMock() mock_spin_cls.return_value = mock_spin @@ -1760,13 +1762,13 @@ def test_create_grid_section_creates_scroll_area(self): with patch.object(LayoutsTab, "__init__", return_value=None): tab = LayoutsTab.__new__(LayoutsTab) - with patch("argus_overview.ui.layouts_tab.QGroupBox") as mock_groupbox_cls, patch( - "argus_overview.ui.layouts_tab.QVBoxLayout" - ) as mock_vlayout_cls, patch("argus_overview.ui.layouts_tab.QLabel"), patch( - "argus_overview.ui.layouts_tab.QScrollArea" - ) as mock_scroll_cls, patch( - "argus_overview.ui.layouts_tab.ArrangementGrid" - ) as mock_grid_cls: + with ( + patch("argus_overview.ui.layouts_tab.QGroupBox") as mock_groupbox_cls, + patch("argus_overview.ui.layouts_tab.QVBoxLayout") as mock_vlayout_cls, + patch("argus_overview.ui.layouts_tab.QLabel"), + patch("argus_overview.ui.layouts_tab.QScrollArea") as mock_scroll_cls, + patch("argus_overview.ui.layouts_tab.ArrangementGrid") as mock_grid_cls, + ): mock_section = MagicMock() mock_groupbox_cls.return_value = mock_section @@ -1800,11 +1802,13 @@ def test_create_grid_section_creates_instructions_label(self): with patch.object(LayoutsTab, "__init__", return_value=None): tab = LayoutsTab.__new__(LayoutsTab) - with patch("argus_overview.ui.layouts_tab.QGroupBox"), patch( - "argus_overview.ui.layouts_tab.QVBoxLayout" - ), patch("argus_overview.ui.layouts_tab.QLabel") as mock_label_cls, patch( - "argus_overview.ui.layouts_tab.QScrollArea" - ), patch("argus_overview.ui.layouts_tab.ArrangementGrid"): + with ( + patch("argus_overview.ui.layouts_tab.QGroupBox"), + patch("argus_overview.ui.layouts_tab.QVBoxLayout"), + patch("argus_overview.ui.layouts_tab.QLabel") as mock_label_cls, + patch("argus_overview.ui.layouts_tab.QScrollArea"), + patch("argus_overview.ui.layouts_tab.ArrangementGrid"), + ): mock_label = MagicMock() mock_label_cls.return_value = mock_label @@ -1826,11 +1830,12 @@ def test_create_bottom_section_creates_apply_button(self): tab = LayoutsTab.__new__(LayoutsTab) tab._apply_to_active_windows = MagicMock() - with patch("argus_overview.ui.layouts_tab.QWidget") as mock_widget_cls, patch( - "argus_overview.ui.layouts_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch( - "argus_overview.ui.layouts_tab.QLabel" - ) as mock_label_cls, patch("argus_overview.ui.layouts_tab.QPushButton") as mock_btn_cls: + with ( + patch("argus_overview.ui.layouts_tab.QWidget") as mock_widget_cls, + patch("argus_overview.ui.layouts_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.layouts_tab.QLabel") as mock_label_cls, + patch("argus_overview.ui.layouts_tab.QPushButton") as mock_btn_cls, + ): mock_widget = MagicMock() mock_widget_cls.return_value = mock_widget @@ -1864,11 +1869,12 @@ def test_create_bottom_section_creates_info_label(self): tab = LayoutsTab.__new__(LayoutsTab) tab._apply_to_active_windows = MagicMock() - with patch("argus_overview.ui.layouts_tab.QWidget"), patch( - "argus_overview.ui.layouts_tab.QHBoxLayout" - ) as mock_hlayout_cls, patch( - "argus_overview.ui.layouts_tab.QLabel" - ) as mock_label_cls, patch("argus_overview.ui.layouts_tab.QPushButton"): + with ( + patch("argus_overview.ui.layouts_tab.QWidget"), + patch("argus_overview.ui.layouts_tab.QHBoxLayout") as mock_hlayout_cls, + patch("argus_overview.ui.layouts_tab.QLabel") as mock_label_cls, + patch("argus_overview.ui.layouts_tab.QPushButton"), + ): mock_layout = MagicMock() mock_hlayout_cls.return_value = mock_layout diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 30e3599..138bde1 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -480,34 +480,32 @@ 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""" @@ -1424,104 +1422,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") # ============================================================================= @@ -1922,6 +1883,8 @@ def test_one_click_import_no_windows(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() + tab._update_status = MagicMock() + tab._sync_status_dock = MagicMock() with patch("argus_overview.ui.main_tab.scan_eve_windows", return_value=[]): with patch("PySide6.QtWidgets.QMessageBox.information") as mock_msg: @@ -2497,7 +2460,7 @@ def test_close_window_confirmed(self): mock_msgbox.StandardButton.No = 0 mock_msgbox.question.return_value = 1 # Yes - with patch("subprocess.run") as mock_run: + with patch("argus_overview.utils.window_utils.run_x11_subprocess") as mock_run: mock_run.return_value = MagicMock(returncode=0) widget._close_window() @@ -4068,20 +4031,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""" @@ -4717,6 +4677,8 @@ def test_one_click_import_no_windows(self): with patch.object(MainTab, "__init__", return_value=None): tab = MainTab.__new__(MainTab) tab.logger = MagicMock() + tab._update_status = MagicMock() + tab._sync_status_dock = MagicMock() with patch("argus_overview.ui.main_tab.scan_eve_windows", return_value=[]): with patch("argus_overview.ui.main_tab.QMessageBox") as mock_msgbox: @@ -4724,6 +4686,22 @@ def test_one_click_import_no_windows(self): mock_msgbox.information.assert_called_once() + def test_button_checked_argument_does_not_disable_dialogs(self): + """Qt's clicked(bool) payload must not override show_dialogs.""" + from argus_overview.ui.main_tab import MainTab + + with patch.object(MainTab, "__init__", return_value=None): + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._update_status = MagicMock() + tab._sync_status_dock = MagicMock() + + with patch("argus_overview.ui.main_tab.scan_eve_windows", return_value=[]): + with patch("argus_overview.ui.main_tab.QMessageBox") as mock_msgbox: + tab.one_click_import(False) + + mock_msgbox.information.assert_called_once() + def test_one_click_import_with_windows(self): """Test one_click_import with EVE windows found""" from argus_overview.ui.main_tab import MainTab @@ -4838,6 +4816,13 @@ 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.preset_combo = MagicMock() tab.remove_all_btn = MagicMock() @@ -7161,10 +7146,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() @@ -7176,8 +7160,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""" @@ -7189,10 +7172,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" @@ -7204,7 +7186,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""" @@ -7216,8 +7198,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" @@ -7229,7 +7211,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""" @@ -7238,8 +7220,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" @@ -7247,7 +7229,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: @@ -7260,10 +7242,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" @@ -7271,10 +7252,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""" @@ -7283,24 +7263,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" @@ -7308,7 +7288,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""" @@ -7317,10 +7297,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" @@ -7338,7 +7317,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""" @@ -7347,10 +7326,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 = {} @@ -7363,7 +7341,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") # ============================================================================= @@ -7648,6 +7626,55 @@ 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") + mock_frame.focus_requested.connect.assert_called_once() + mock_frame.retry_requested.connect.assert_called_once() + 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 # ============================================================================= @@ -7670,6 +7697,7 @@ def test_add_window_success(self): mock_frame = MagicMock() tab.window_manager.add_window.return_value = mock_frame tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() result = tab._add_window_to_preview("0x123", "EVE - TestChar") @@ -7690,6 +7718,7 @@ def test_add_window_extracts_char_name(self): mock_frame = MagicMock() tab.window_manager.add_window.return_value = mock_frame tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() tab._add_window_to_preview("0x123", "EVE - My Character") @@ -7708,6 +7737,7 @@ def test_add_window_eve_online_prefix(self): mock_frame = MagicMock() tab.window_manager.add_window.return_value = mock_frame tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() tab._add_window_to_preview("0x123", "EVE Online - Another Char") @@ -7726,6 +7756,7 @@ def test_add_window_empty_name_uses_unknown(self): mock_frame = MagicMock() tab.window_manager.add_window.return_value = mock_frame tab.preview_layout = MagicMock() + tab.character_detected = MagicMock() tab._add_window_to_preview("0x123", "EVE -") @@ -7819,10 +7850,11 @@ def test_create_status_bar_returns_widget(self): mock_layout = MagicMock() mock_label = MagicMock() - with patch("argus_overview.ui.main_tab.QTimer", return_value=mock_timer), patch( - "argus_overview.ui.main_tab.QWidget", return_value=mock_widget - ), patch("argus_overview.ui.main_tab.QHBoxLayout", return_value=mock_layout), patch( - "argus_overview.ui.main_tab.QLabel", return_value=mock_label + with ( + patch("argus_overview.ui.main_tab.QTimer", return_value=mock_timer), + patch("argus_overview.ui.main_tab.QWidget", return_value=mock_widget), + patch("argus_overview.ui.main_tab.QHBoxLayout", return_value=mock_layout), + patch("argus_overview.ui.main_tab.QLabel", return_value=mock_label), ): result = tab._create_status_bar() @@ -7841,16 +7873,154 @@ def test_create_status_bar_timer_connects_update_status(self): mock_timer = MagicMock() - with patch("argus_overview.ui.main_tab.QTimer", return_value=mock_timer), patch( - "argus_overview.ui.main_tab.QWidget" - ), patch("argus_overview.ui.main_tab.QHBoxLayout"), patch( - "argus_overview.ui.main_tab.QLabel" + with ( + patch("argus_overview.ui.main_tab.QTimer", return_value=mock_timer), + patch("argus_overview.ui.main_tab.QWidget"), + patch("argus_overview.ui.main_tab.QHBoxLayout"), + patch("argus_overview.ui.main_tab.QLabel"), ): tab._create_status_bar() 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""" @@ -8081,24 +8251,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: @@ -8121,10 +8288,13 @@ def test_minimize_inactive_xdotool_fails(self): tab.status_label = MagicMock() tab._update_minimize_button_style = MagicMock() - # Mock subprocess.run to fail + # Mock the platform abstraction to report xdotool failure. mock_result = MagicMock() mock_result.returncode = 1 # Non-zero = failure - with patch("subprocess.run", return_value=mock_result): + with patch( + "argus_overview.utils.window_utils.run_x11_subprocess", + return_value=mock_result, + ): tab.minimize_inactive_windows() # Should set status to "Auto-minimize ON" without count @@ -8479,7 +8649,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() @@ -8517,6 +8689,10 @@ def test_setup_ui_creates_layout_and_widgets(self): # Preview container and layout stored assert tab.preview_container is mock_container + tab._create_empty_state_panel.assert_called_once() + mock_flow_layout.addWidget.assert_called_once_with( + tab.empty_state_panel + ) assert tab.preview_layout is mock_flow_layout # 5 widgets added to layout (PR2 added status dock) @@ -8842,54 +9018,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() # ============================================================================= @@ -8935,7 +9092,8 @@ def test_init_full_sequence(self): assert tab._windows_minimized is False # Timer configured - mock_timer.setSingleShot.assert_called_once_with(True) + assert mock_timer.setSingleShot.call_count == 3 + assert all(call.args == (True,) for call in mock_timer.setSingleShot.call_args_list) mock_timer.setInterval.assert_called_once_with(150) # Window manager created and capture loop started @@ -9047,56 +9205,49 @@ 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 emits focus intent without local validation.""" 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 ceddd3c..6871788 100644 --- a/tests/test_main_window_v21.py +++ b/tests/test_main_window_v21.py @@ -33,6 +33,33 @@ 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.cycling_index = 0 + 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 +88,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) @@ -86,6 +115,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() # Pin _TAB_LABELS to the real class constant so test_show_settings # exercises the same lookup the production code uses (was 4; becomes @@ -224,6 +260,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" @@ -245,6 +282,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" @@ -266,6 +304,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" @@ -288,27 +327,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 @@ -357,34 +392,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 @@ -419,6 +446,7 @@ 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() @@ -496,10 +524,11 @@ def test_four_ia_containers_register_tabs(self) -> None: window = MagicMock() window.tabs = MagicMock() - with patch("argus_overview.ui.tabs.command_tab.CommandTab"), patch( - "argus_overview.ui.tabs.fleet_tab.FleetTab" - ), patch("argus_overview.ui.tabs.layouts_tab.LayoutsContainer"), patch( - "argus_overview.ui.tabs.system_tab.SystemTab" + with ( + patch("argus_overview.ui.tabs.command_tab.CommandTab"), + patch("argus_overview.ui.tabs.fleet_tab.FleetTab"), + patch("argus_overview.ui.tabs.layouts_tab.LayoutsContainer"), + patch("argus_overview.ui.tabs.system_tab.SystemTab"), ): MainWindowV21._create_command_tab(window) MainWindowV21._create_fleet_tab(window) @@ -526,49 +555,25 @@ def test_create_layouts_tab_passes_main_tab_to_main_slot(self) -> None: Post-Phase-4: the inner widget is exposed as ``window.presets_panel`` (the container owns ``window.layouts_tab``). """ - from contextlib import ExitStack from unittest.mock import MagicMock, patch from argus_overview.ui.main_window_v21 import MainWindowV21 - mod = "argus_overview.ui.main_window_v21" - with ExitStack() as stack: - stack.enter_context(patch("PySide6.QtWidgets.QMainWindow.__init__", return_value=None)) - stack.enter_context(patch.object(MainWindowV21, "setWindowTitle")) - stack.enter_context(patch.object(MainWindowV21, "setMinimumSize")) - stack.enter_context(patch.object(MainWindowV21, "setCentralWidget")) - stack.enter_context(patch.object(MainWindowV21, "_set_window_icon")) - stack.enter_context(patch.object(MainWindowV21, "_apply_initial_settings")) - stack.enter_context(patch.object(MainWindowV21, "_create_menu_bar")) - stack.enter_context(patch.object(MainWindowV21, "_create_command_tab")) - stack.enter_context(patch.object(MainWindowV21, "_create_fleet_tab")) - stack.enter_context(patch.object(MainWindowV21, "_create_layouts_container")) - stack.enter_context(patch.object(MainWindowV21, "_create_system_tab")) - stack.enter_context(patch.object(MainWindowV21, "_connect_signals")) - stack.enter_context(patch.object(MainWindowV21, "_create_system_tray")) - stack.enter_context(patch.object(MainWindowV21, "_register_hotkeys")) - stack.enter_context(patch.object(MainWindowV21, "_init_location_tracker")) - stack.enter_context(patch(f"{mod}.QTabWidget")) - stack.enter_context(patch(f"{mod}.QVBoxLayout")) - stack.enter_context(patch(f"{mod}.QWidget")) - stack.enter_context(patch(f"{mod}.QTimer")) - - window = MainWindowV21() - - # Manually invoke the real factory now that the surrounding - # init dance is patched out — this is what crashed in v1. - window.main_tab = MagicMock(name="main_tab") - window.layout_manager = MagicMock(name="layout_manager") - window.settings_manager = MagicMock(name="settings_manager") - window.character_manager = MagicMock(name="character_manager") - - window._create_layouts_tab() - - # Contract: LayoutsTab.main_tab is the same object as - # MainWindowV21.main_tab, not the character_manager. - # Post-Phase-4: the inner widget is ``presets_panel``. - assert window.presets_panel.main_tab is window.main_tab - assert window.presets_panel.main_tab is not window.character_manager + window = MagicMock(spec=MainWindowV21) + window.main_tab = MagicMock(name="main_tab") + window.layout_manager = MagicMock(name="layout_manager") + window.settings_manager = MagicMock(name="settings_manager") + window.character_manager = MagicMock(name="character_manager") + + with patch("argus_overview.ui.layouts_tab.LayoutsTab") as layouts_tab: + MainWindowV21._create_layouts_tab(window) + + layouts_tab.assert_called_once_with( + window.layout_manager, + window.main_tab, + settings_manager=window.settings_manager, + character_manager=window.character_manager, + ) # Test reload config @@ -604,14 +609,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 @@ -633,13 +638,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 @@ -694,6 +709,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() @@ -768,12 +806,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 @@ -782,8 +820,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 @@ -856,7 +895,7 @@ class TestCycleEdgeCases: """Edge case tests for cycling methods""" def test_cycle_next_empty_group(self): - """Test cycle_next with empty group""" + """Test next-cycle delegates an empty group to CycleController.""" window = create_mock_window() window.settings_manager = MagicMock() window.settings_manager.get.return_value = {} @@ -867,10 +906,10 @@ def test_cycle_next_empty_group(self): window._cycle_next() - window.logger.warning.assert_called() + window.cycle_controller.cycle.assert_called_once() def test_cycle_prev_empty_group(self): - """Test cycle_prev with empty group""" + """Test previous-cycle delegates an empty group to CycleController.""" window = create_mock_window() window.settings_manager = MagicMock() window.settings_manager.get.return_value = {} @@ -881,7 +920,7 @@ def test_cycle_prev_empty_group(self): window._cycle_prev() - window.logger.warning.assert_called() + window.cycle_controller.cycle.assert_called_once() # Test handle hotkey @@ -917,12 +956,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""" @@ -939,14 +979,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 @@ -980,6 +1151,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") @@ -1051,7 +1223,7 @@ class TestCyclingRecursion: """Tests for cycling recursion when character not found""" def test_cycle_next_recursion_on_not_found(self): - """Test _cycle_next recursively tries next when not found""" + """Test next-cycle delegates selection to CycleController.""" window = create_mock_window() window.cycling_index = 0 window.settings_manager = MagicMock() @@ -1069,11 +1241,12 @@ def test_cycle_next_recursion_on_not_found(self): } window._activate_window = MagicMock() + window.cycle_controller.cycle.return_value = (1, "FoundChar") window._cycle_next() - # Should have advanced to index 1 (FoundChar) after not finding NotFound - assert window.cycling_index == 1 or window._activate_window.called + assert window.cycling_index == 1 + window.cycle_controller.cycle.assert_called_once() def test_cycle_prev_recursion_on_not_found(self): """Test _cycle_prev recursively tries prev when not found""" @@ -1232,8 +1405,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 @@ -1244,12 +1416,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 @@ -1258,29 +1429,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 @@ -1309,13 +1481,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 @@ -1446,96 +1618,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 @@ -1572,7 +1684,7 @@ class TestCyclingCharNotFound: """Tests for cycling when character not found - covers recursive branches""" def test_cycle_next_char_not_found_logs_warning(self): - """Test _cycle_next logs warning when character not found and no recursion""" + """Test next-cycle delegates an inactive group to CycleController.""" window = create_mock_window() window.cycling_index = 0 window.settings_manager = MagicMock() @@ -1597,11 +1709,10 @@ def cycle_next_once(): window._cycle_next() - # Should log warning about character not found - window.logger.warning.assert_called() + window.cycle_controller.cycle.assert_called_once() def test_cycle_prev_char_not_found_logs_warning(self): - """Test _cycle_prev logs warning when character not found and no recursion""" + """Test previous-cycle delegates an inactive group to CycleController.""" window = create_mock_window() window.cycling_index = 0 window.settings_manager = MagicMock() @@ -1626,8 +1737,7 @@ def cycle_prev_once(): window._cycle_prev() - # Should log warning about character not found - window.logger.warning.assert_called() + window.cycle_controller.cycle.assert_called_once() # Test _create_system_tray @@ -2420,6 +2530,9 @@ def test_close_event_stops_main_tab_capture_loop(self, qapp): window.hotkey_manager = MagicMock() window.system_tray = MagicMock() window._disconnect_signals = MagicMock() + window._disconnect_auto_discovery = MagicMock() + window._is_quitting = False + window._pending_discovery_names = [] window._already_closing = False # Call closeEvent @@ -2449,6 +2562,9 @@ def test_close_event_stops_intel_tab(self, qapp): window.hotkey_manager = MagicMock() window.system_tray = MagicMock() window._disconnect_signals = MagicMock() + window._disconnect_auto_discovery = MagicMock() + window._is_quitting = False + window._pending_discovery_names = [] window._already_closing = False # Call closeEvent diff --git a/tests/test_platform.py b/tests/test_platform.py index b4e3ee9..63d11d6 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -840,12 +840,15 @@ def test_activate_window_failure(self): wm = WindowManagerLinux() - with patch( - "argus_overview.platform.linux.run_x11_subprocess", - side_effect=subprocess.TimeoutExpired("cmd", 2), - ), patch( - "argus_overview.platform.linux.HAS_XLIB", - False, + with ( + patch( + "argus_overview.platform.linux.run_x11_subprocess", + side_effect=subprocess.TimeoutExpired("cmd", 2), + ), + patch( + "argus_overview.platform.linux.HAS_XLIB", + False, + ), ): result = wm.activate_window("0x12345") diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py index d1d8236..f53e1e6 100644 --- a/tests/test_platform_windows.py +++ b/tests/test_platform_windows.py @@ -844,11 +844,11 @@ def test_capture_sync_with_scale(self, mock_win32): with patch(f"{MODULE}.Image") as mock_image_mod: mock_image_mod.frombuffer.return_value = fake_img - mock_image_mod.LANCZOS = Image.LANCZOS + mock_image_mod.Resampling.LANCZOS = Image.Resampling.LANCZOS result = cap.capture_window_sync("0x1", scale=0.5) assert result is resized_img - fake_img.resize.assert_called_once_with((100, 50), Image.LANCZOS) + fake_img.resize.assert_called_once_with((100, 50), Image.Resampling.LANCZOS) def test_capture_sync_scale_1_no_resize(self, mock_win32): """Scale = 1.0 does not resize.""" diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index 7082097..bb8f22b 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""" diff --git a/tests/test_system_status_bar.py b/tests/test_system_status_bar.py index b0564c3..795c7de 100644 --- a/tests/test_system_status_bar.py +++ b/tests/test_system_status_bar.py @@ -90,9 +90,10 @@ def test_painter_called_in_paint_event(self, qapp): bar = SystemStatusBar() indicator = bar._indicators["capture"] - with patch.object(QPainter, "drawText") as mock_draw_text, patch.object( - QPainter, "drawEllipse" - ) as mock_draw_ellipse: + with ( + patch.object(QPainter, "drawText") as mock_draw_text, + patch.object(QPainter, "drawEllipse") as mock_draw_ellipse, + ): # Fake a paint event event = QPaintEvent(indicator.rect()) indicator.paintEvent(event)