diff --git a/src/argus_overview/intel/character_location.py b/src/argus_overview/intel/character_location.py new file mode 100644 index 0000000..8cd6ea4 --- /dev/null +++ b/src/argus_overview/intel/character_location.py @@ -0,0 +1,283 @@ +""" +Character Location Tracker — per-character current-system tracking from EVE logs. + +EVE Online writes a separate Local channel chat log per character session. +Each Local log file has: + + 1. A header block declaring the listener (the character) and channel: + + --------------------------------------------------------------- + Channel ID: Local + Channel Name: Local + Listener: MyCharName + Session Started: 2024.01.15 14:30:00 + --------------------------------------------------------------- + + 2. Lines of the form + [ 2024.01.15 14:30:05 ] EVE System > Channel changed to Local : Jita + whenever the character jumps into a new system. + +This tracker polls today's Local_*.txt files, parses the listener header +once per file, then tails each file for "Channel changed to Local" lines +and maintains a {character_name -> current_system} map. It emits +character_system_changed when a character's system updates. + +Files are UTF-16-LE (matches ChatLogWatcher). + +PR4 of the intel-aware UI uplift. Pairs with the chip strip + preview +borders so threat fan-out and chip system labels can become per-character. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QObject, QTimer, Signal + +# Header line: " Listener: MyCharName" +# Whitespace before label, around colon, and before/after name is variable. +_LISTENER_RE = re.compile(r"^\s*Listener:\s*(?P.+?)\s*$") + +# Channel-changed line as it appears inside a Local log: +# [ 2024.01.15 14:30:05 ] EVE System > Channel changed to Local : Jita +_CHANNEL_CHANGED_RE = re.compile( + r"\[\s*\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2}\s*\]" + r"\s*EVE\s+System\s*>\s*Channel changed to Local\s*:\s*(?P.+?)\s*$" +) + +# Local logs are written one-per-character. Filename pattern: +# Local_YYYYMMDD_HHMMSS_.txt (current EVE clients, charid optional) +_LOCAL_FILE_GLOB = "Local_*.txt" + +# How many bytes from the start of the file to scan for the Listener header. +# The header is small (~300 bytes incl. UTF-16 BOM); 4KB is generous. +_HEADER_SCAN_BYTES = 4096 + + +def find_eve_chat_log_directory() -> Path | None: + """Locate the EVE Online chat log directory. + + Mirrors ChatLogWatcher.find_log_directory but lives here so the tracker + can be used without instantiating a watcher. + """ + candidates = [ + Path.home() / ".eve" / "logs" / "Chatlogs", + Path.home() + / ".local" + / "share" + / "Steam" + / "steamapps" + / "compatdata" + / "8500" + / "pfx" + / "drive_c" + / "users" + / "steamuser" + / "Documents" + / "EVE" + / "logs" + / "Chatlogs", + Path.home() + / ".steam" + / "steam" + / "steamapps" + / "compatdata" + / "8500" + / "pfx" + / "drive_c" + / "users" + / "steamuser" + / "Documents" + / "EVE" + / "logs" + / "Chatlogs", + Path.home() / "Documents" / "EVE" / "logs" / "Chatlogs", + ] + for path in candidates: + if path.exists() and path.is_dir(): + return path + return None + + +@dataclass +class _FileState: + """Tail-cursor state for one Local log file.""" + + listener: str | None + position: int # next read offset + + +class CharacterLocationTracker(QObject): + """Per-character current-system tracker. + + Polls Local_*.txt files in the EVE chat log directory, parses the + Listener header once per file, then tails each file for + "Channel changed to Local : " lines. + + Signals: + character_system_changed(str, str): (character_name, system) + Emitted when a character moves to a new system. Also fires + once per character on initial detection. + """ + + character_system_changed = Signal(str, str) # char_name, system + + def __init__( + self, + log_directory: Path | None = None, + poll_interval_ms: int = 2000, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._explicit_log_dir = log_directory + self._log_directory: Path | None = log_directory + self.poll_interval_ms = max(250, int(poll_interval_ms)) + + # Per-file cursor state, keyed by absolute path. + self._file_state: dict[Path, _FileState] = {} + # Authoritative location map: character_name -> current_system + self._locations: dict[str, str] = {} + + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll) + self._running = False + + # ---- Public API -------------------------------------------------------- + def get_system(self, character_name: str) -> str | None: + """Return the last known system for character_name, or None.""" + return self._locations.get(character_name) + + def get_all_locations(self) -> dict[str, str]: + """Snapshot of all known {character_name: system} pairs.""" + return dict(self._locations) + + def start(self) -> None: + """Begin polling for Local log changes.""" + if self._running: + return + if self._log_directory is None: + self._log_directory = find_eve_chat_log_directory() + if self._log_directory is None: + self.logger.warning( + "CharacterLocationTracker: no EVE chat log directory found; tracker will idle." + ) + return + self._running = True + self._poll_timer.start(self.poll_interval_ms) + # Run once immediately so chips populate without a 2s wait + self._poll() + + def stop(self) -> None: + """Stop polling. Safe to call multiple times.""" + if not self._running: + return + self._running = False + self._poll_timer.stop() + + def is_running(self) -> bool: + return self._running + + # ---- Internals --------------------------------------------------------- + def _poll(self) -> None: + """Scan today's Local log files and emit any new system changes.""" + if self._log_directory is None or not self._log_directory.exists(): + return + try: + files = list(self._log_directory.glob(_LOCAL_FILE_GLOB)) + except OSError as e: + self.logger.warning(f"Failed to list log directory: {e}") + return + + for path in files: + try: + self._process_file(path) + except (OSError, UnicodeDecodeError) as e: + self.logger.debug(f"Skipping {path.name}: {e}") + continue + + def _process_file(self, path: Path) -> None: + """Read header (once) and tail the file for new channel-changed events.""" + state = self._file_state.get(path) + if state is None: + listener = self._read_listener(path) + # Start at end-of-file so we don't replay history on first open. + try: + position = path.stat().st_size + except OSError: + return + state = _FileState(listener=listener, position=position) + self._file_state[path] = state + # Optional: also seed initial system from header-adjacent lines? + # Skipped — we wait for the first explicit "Channel changed" line + # so we never claim a stale system. + + if state.listener is None: + return # Cannot map systems to a character without a listener. + + try: + file_size = path.stat().st_size + except OSError: + return + + if file_size < state.position: + # File rotated/truncated. + state.position = 0 + + if file_size == state.position: + return # No new content. + + try: + with open(path, "rb") as fb: + fb.seek(state.position) + raw = fb.read() + state.position = fb.tell() + except OSError as e: + self.logger.debug(f"Read failed for {path.name}: {e}") + return + + try: + text = raw.decode("utf-16-le", errors="ignore") + except UnicodeDecodeError: + return + + for line in text.splitlines(): + match = _CHANNEL_CHANGED_RE.search(line) + if not match: + continue + system = match.group("system").strip() + self._update_location(state.listener, system) + + def _read_listener(self, path: Path) -> str | None: + """Parse the Listener: header from the start of a Local log file.""" + try: + with open(path, "rb") as fb: + raw = fb.read(_HEADER_SCAN_BYTES) + except OSError: + return None + if not raw: + return None + try: + text = raw.decode("utf-16-le", errors="ignore") + except UnicodeDecodeError: + return None + for line in text.splitlines(): + m = _LISTENER_RE.match(line) + if m: + listener = m.group("name").strip() + if listener: + return listener + return None + + def _update_location(self, character_name: str, system: str) -> None: + prev = self._locations.get(character_name) + if prev == system: + return + self._locations[character_name] = system + self.logger.info( + f"CharacterLocationTracker: {character_name} -> {system} (was {prev or 'unknown'})" + ) + self.character_system_changed.emit(character_name, system) diff --git a/src/argus_overview/intel/threat_filter.py b/src/argus_overview/intel/threat_filter.py new file mode 100644 index 0000000..486d57c --- /dev/null +++ b/src/argus_overview/intel/threat_filter.py @@ -0,0 +1,79 @@ +""" +Threat fan-out filter — resolves whether a frame/chip should tint for an +incoming intel alert, and at what intensity. + +Used by both WindowManager.apply_threat_state and StatusDock.set_threat_state +so the per-character tinting rules stay symmetric across the preview grid +and the chip strip. + +PR6 of the intel-aware UI uplift. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argus_overview.intel.jumps import JumpCalculator + + +# Alpha falloff: same-system = 1.0, each additional jump multiplies by this +# until we hit the floor or exceed max_jumps. +_PER_JUMP_FALLOFF = 0.5 +# Absolute floor for adjacent-system alerts so they remain visible. +_MIN_ADJACENT_ALPHA = 0.4 + + +def resolve_tint( + known_system: str | None, + alert_system: str | None, + jump_calculator: JumpCalculator | None = None, + max_jumps: int = 0, +) -> tuple[bool, float]: + """ + Decide whether a frame/chip should tint for an alert, and at what alpha. + + Args: + known_system: The character's last-known current system. None if + we don't have a per-character location yet. + alert_system: The system the intel report refers to. None if intel + couldn't attribute the report to a system. + jump_calculator: Optional graph for adjacency lookups. When None, + only exact matches and the unknown-fallthrough rules apply. + max_jumps: Maximum jump distance to consider "near". Zero means + exact-match only (PR5 behavior). + + Returns: + (should_apply, alpha) + should_apply: True if the frame/chip should call set_threat_state + alpha: Initial alpha in [0.0, 1.0]; same-system = 1.0, + each jump out scales down by _PER_JUMP_FALLOFF + with a floor of _MIN_ADJACENT_ALPHA. + """ + # No known location → graceful fallback (apply at full intensity). + if known_system is None: + return True, 1.0 + + # No alert system to match against → fallback (apply at full). + if not alert_system: + return True, 1.0 + + # Same system → full intensity, no calculator needed. + if known_system.lower() == alert_system.lower(): + return True, 1.0 + + # Need a calculator + non-zero threshold to consider adjacency. + if jump_calculator is None or max_jumps <= 0: + return False, 0.0 + + try: + distance = jump_calculator.distance(known_system, alert_system) + except (AttributeError, TypeError, ValueError): + # Calculator misconfigured / corrupt — treat as unknown distance. + return False, 0.0 + if distance is None or distance > max_jumps: + return False, 0.0 + + # Adjacent within threshold — scale alpha down by distance. + alpha = max(_MIN_ADJACENT_ALPHA, _PER_JUMP_FALLOFF**distance) + return True, alpha diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 40f7735..215733d 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -48,10 +48,62 @@ ) from argus_overview.core.discovery import scan_eve_windows +from argus_overview.intel.parser import ThreatLevel from argus_overview.ui.action_registry import PrimaryHome from argus_overview.ui.menu_builder import ContextMenuBuilder, ToolbarBuilder from argus_overview.utils.screen import ScreenGeometry, get_screen_geometry +# Threat-tint config — tuned for a glanceable but non-distracting frame +THREAT_BORDER_COLORS: dict[ThreatLevel, tuple[int, int, int]] = { + ThreatLevel.CLEAR: (0, 200, 100), + ThreatLevel.INFO: (0, 180, 230), + ThreatLevel.WARNING: (255, 170, 0), + ThreatLevel.DANGER: (255, 90, 30), + ThreatLevel.CRITICAL: (255, 40, 40), +} +THREAT_LEVEL_RANK: dict[ThreatLevel, int] = { + ThreatLevel.CLEAR: 0, + ThreatLevel.INFO: 1, + ThreatLevel.WARNING: 2, + ThreatLevel.DANGER: 3, + ThreatLevel.CRITICAL: 4, +} +THREAT_DECAY_TICK_MS = 100 # decay tick rate +THREAT_DECAY_DURATION_MS = 30_000 # full fade time after last alert +THREAT_PULSE_TICK_MS = 33 # ~30 fps pulse +THREAT_PULSE_DURATION_MS = 600 # one pulse cycle + +# Replay strip (PR10) — small ring buffer of recent capture pixmaps. +# 6 cells × 800ms throttle = ~5s of recent history. +REPLAY_BUFFER_SIZE = 6 +REPLAY_THROTTLE_MS = 800 + +# Per-character accent palette (PR8). Shared by both frames + chips so +# the same character renders in the same color across the preview grid +# and the status dock. Palette is fixed-length; deterministic hash maps +# names to indices. +CHARACTER_ACCENT_COLORS: list[tuple[int, int, int]] = [ + (255, 100, 100), + (100, 255, 100), + (100, 150, 255), + (255, 200, 80), + (220, 120, 220), + (100, 220, 220), + (255, 165, 60), + (170, 130, 255), +] + + +def character_accent_color(name: str) -> QColor: + """Deterministic accent color for a character name. + + Used by both WindowPreviewWidget (frame border) and CharacterChip + (avatar fill) so visual identity is consistent across surfaces. + """ + r, g, b = CHARACTER_ACCENT_COLORS[abs(hash(name)) % len(CHARACTER_ACCENT_COLORS)] + return QColor(r, g, b) + + # Module-level constant: avoids re-creating the dict on every pil_to_qimage call _FORMAT_MAP = { "RGB": (3, QImage.Format.Format_RGB888), @@ -568,6 +620,7 @@ class WindowPreviewWidget(QWidget): window_activated = Signal(str) # window_id window_removed = Signal(str) # window_id label_changed = Signal(str, str) # window_id, new_label + focus_requested = Signal(str) # window_id — PR3 spotlight toggle def __init__( self, @@ -606,6 +659,49 @@ def __init__( self._show_session_timer = False self._load_settings() + # PR4/PR5: known current system for this frame's character. + # Pushed by WindowManager.set_character_system; consumed by + # WindowManager.apply_threat_state for smart fan-out. + self._character_system: str | None = None + + # PR8: per-character accent color, shared with the chip. + self._accent_color: QColor = character_accent_color(character_name) + + # Intel threat state (PR1: intel-aware preview borders) + self._threat_level: ThreatLevel | None = None + self._threat_system: str | None = None + # PR9: jumps from this character to the alert system. None for + # same-system / unknown; positive int renders as "+Nj" near the + # top-left of the threat border. + self._threat_distance: int | None = None + # Decay alpha drives the inset border; ranges 0.0 (off) to 1.0 (full) + self._threat_alpha: float = 0.0 + self._threat_decay_steps: int = max(1, THREAT_DECAY_DURATION_MS // THREAT_DECAY_TICK_MS) + self._threat_decay_timer = QTimer(self) + self._threat_decay_timer.timeout.connect(self._tick_threat_decay) + + # Pulse animation (oneshot on transition into danger/critical) + self._pulse_phase: float = 0.0 # 1.0 -> 0.0 over PULSE_DURATION + self._pulse_steps: int = max(1, THREAT_PULSE_DURATION_MS // THREAT_PULSE_TICK_MS) + self._pulse_timer = QTimer(self) + self._pulse_timer.timeout.connect(self._tick_pulse) + + # Legacy flash_border state (retains existing border_flash_requested wiring) + self._flash_color: QColor | None = None + self._flash_timer = QTimer(self) + self._flash_timer.setSingleShot(True) + self._flash_timer.timeout.connect(self._clear_flash) + + # PR10: replay strip — bounded ring buffer of recent frame pixmaps, + # sampled at most once per REPLAY_THROTTLE_MS so the buffer covers + # ~5 seconds of capture even at high refresh rates. + from collections import deque + + 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_view_index: int | None = None # None = live; int = buffered + # Setup UI self.setMinimumSize(200, 150) self.setMaximumSize(600, 450) @@ -644,11 +740,29 @@ def __init__( if self._show_session_timer: self.session_timer.start(60000) # Update every minute - # Opacity effect for hover + # Opacity effect for hover (and PR3 spotlight dim state) self.opacity_effect = QGraphicsOpacityEffect(self) self.opacity_effect.setOpacity(1.0) self.setGraphicsEffect(self.opacity_effect) + # PR3 — focus/spotlight mode state. + # mode is one of: None (normal), "focused" (this widget is the + # spotlight target), "dimmed" (another widget has spotlight). + self._spotlight_mode: str | None = None + # Cached size constraints so we can restore them when leaving focus. + self._normal_min_size: QSize = self.minimumSize() + self._normal_max_size: QSize = self.maximumSize() + + # PR10: restore the replay-strip toggle from settings if it was + # previously enabled for this character. + if self.settings_manager is not None: + try: + store = self.settings_manager.get("replay_strip_enabled", {}) or {} + if isinstance(store, dict) and store.get(self.character_name): + self.enable_replay_strip(True) + except (AttributeError, TypeError): + pass + def _load_settings(self): """Load settings from settings_manager""" if self.settings_manager: @@ -711,6 +825,17 @@ def update_frame(self, image: Image.Image): self.current_pixmap = QPixmap.fromImage(qimage) del qimage # Release intermediate QImage memory + # PR10: sample into the replay ring buffer at most once per + # REPLAY_THROTTLE_MS so the buffer covers ~5s regardless of + # capture rate. Only sample the unscaled pixmap; the strip + # rescales itself for display. + self._sample_replay_buffer(self.current_pixmap) + + # If the user is currently scrubbing through the strip, hold + # the buffered view — don't overwrite it with the live frame. + if getattr(self, "_replay_view_index", None) is not None: + return + # Scale to fit widget while maintaining aspect ratio scaled_pixmap = self.current_pixmap.scaled( self.image_label.size(), @@ -803,14 +928,283 @@ def leaveEvent(self, event): super().leaveEvent(event) + def set_character_system(self, system: str | None) -> None: + """Record the current system for this frame's character (PR5).""" + self._character_system = system + + def get_character_system(self) -> str | None: + return self._character_system + + def set_threat_state( + self, + level: ThreatLevel | None, + system: str | None = None, + initial_alpha: float = 1.0, + distance: int | None = None, + ) -> None: + """ + Update the intel threat state for this preview frame. + + Args: + level: Threat level. None or CLEAR clears the border. + system: System name the threat refers to (kept for tooltip + dock). + initial_alpha: Starting alpha [0.0, 1.0] for the border. Defaults + to 1.0 (full intensity for same-system alerts). Lower values + are used by WindowManager when fanning to characters in + adjacent systems via the jumps-from filter (PR6). + distance: Jumps from this character to the alert system. + None for same-system / unknown. Positive ints render as a + "+Nj" badge near the top-left of the frame (PR9). + """ + prev_level = self._threat_level + + if level is None or level == ThreatLevel.CLEAR: + self._threat_level = None + self._threat_system = None + self._threat_alpha = 0.0 + self._threat_distance = None + self._threat_decay_timer.stop() + self.update() + return + + self._threat_level = level + self._threat_system = system + self._threat_alpha = max(0.0, min(1.0, initial_alpha)) + self._threat_distance = distance if distance and distance > 0 else None + + # Pulse on upgrade into danger/critical (only at full-ish intensity — + # don't pulse for distant adjacent-system alerts). + upgraded = prev_level is None or THREAT_LEVEL_RANK.get( + prev_level, 0 + ) < THREAT_LEVEL_RANK.get(level, 0) + if ( + upgraded + and level in (ThreatLevel.DANGER, ThreatLevel.CRITICAL) + and initial_alpha >= 0.9 + ): + self._start_pulse() + + # Restart decay timer + self._threat_decay_timer.start(THREAT_DECAY_TICK_MS) + self.update() + + def _tick_threat_decay(self) -> None: + """Linear decay of the threat-border alpha.""" + if self._threat_alpha <= 0.0: + self._threat_decay_timer.stop() + return + self._threat_alpha = max(0.0, self._threat_alpha - 1.0 / self._threat_decay_steps) + if self._threat_alpha <= 0.0: + self._threat_level = None + self._threat_system = None + self._threat_distance = None + self._threat_decay_timer.stop() + self.update() + + def _start_pulse(self) -> None: + """Trigger a single pulse cycle on upgrade to danger/critical.""" + self._pulse_phase = 1.0 + self._pulse_timer.start(THREAT_PULSE_TICK_MS) + + def _tick_pulse(self) -> None: + """Decrement pulse phase toward 0; stop when done.""" + self._pulse_phase = max(0.0, self._pulse_phase - 1.0 / self._pulse_steps) + if self._pulse_phase <= 0.0: + self._pulse_timer.stop() + self.update() + + def set_spotlight(self, mode: str | None) -> None: + """ + Apply or clear focus/spotlight presentation. + + Args: + mode: 'focused' to upscale + full opacity (this widget is the + spotlight target), 'dimmed' to fade and desaturate (another + widget owns the spotlight), None to return to normal. + """ + if mode not in (None, "focused", "dimmed"): + raise ValueError(f"Invalid spotlight mode: {mode!r}") + self._spotlight_mode = mode + + if mode == "focused": + # Allow growth beyond the normal max so the focused widget + # actually uses the freed space when others are minimized/hidden. + self.setMinimumSize(QSize(360, 270)) + self.setMaximumSize(QSize(16777215, 16777215)) # QWIDGETSIZE_MAX + self.opacity_effect.setOpacity(1.0) + elif mode == "dimmed": + self.setMinimumSize(self._normal_min_size) + self.setMaximumSize(self._normal_max_size) + self.opacity_effect.setOpacity(0.25) + else: # None — restore baseline + self.setMinimumSize(self._normal_min_size) + self.setMaximumSize(self._normal_max_size) + # Hover state may want partial opacity; reset to full on exit. + self.opacity_effect.setOpacity(self._opacity_on_hover if self._is_hovered else 1.0) + self.update() + + def flash_border(self, color: str, duration_ms: int) -> None: + """ + Legacy color/duration border flash hook used by border_flash_requested. + + Kept independent of set_threat_state so callers without IntelReport + context still get visual feedback. + """ + self._flash_color = QColor(color) + self._flash_timer.start(max(0, int(duration_ms))) + self.update() + + def _clear_flash(self) -> None: + self._flash_color = None + self.update() + + # ----- PR10 replay strip ------------------------------------------------ + def _sample_replay_buffer(self, pixmap: QPixmap) -> None: + """Throttle-sample a captured pixmap into the ring buffer.""" + if pixmap is None or pixmap.isNull(): + return + # Defensive: bypassed-init test helpers may not set these. + if not hasattr(self, "_replay_buffer"): + return + now_ms = int(time.monotonic() * 1000) + if now_ms - getattr(self, "_replay_last_sample_ms", 0) < REPLAY_THROTTLE_MS: + return + # Store an immutable copy at modest size; full-res pixmaps would + # blow up memory across many widgets. 240×180 keeps it ~170KB max. + thumb = pixmap.scaled( + 240, + 180, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self._replay_buffer.append(thumb) + self._replay_last_sample_ms = now_ms + if self._replay_strip is not None: + try: + self._replay_strip.set_frames(list(self._replay_buffer)) + except RuntimeError: + self._replay_strip = None + + def is_replay_strip_enabled(self) -> bool: + return getattr(self, "_replay_strip", None) is not None + + def enable_replay_strip(self, enabled: bool) -> None: + """Show or hide the replay strip below the main image.""" + if enabled and self._replay_strip is None: + from argus_overview.ui.replay_strip import ReplayStrip + + self._replay_strip = ReplayStrip(parent=self) + self._replay_strip.frame_hovered.connect(self._on_replay_frame_hovered) + # Append below the existing image_label / info_label / timer. + self.layout().addWidget(self._replay_strip) + self._replay_strip.set_frames(list(self._replay_buffer)) + 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.layout().removeWidget(self._replay_strip) + self._replay_strip.deleteLater() + self._replay_strip = None + # Drop any held buffered view. + self._replay_view_index = None + + def _on_replay_frame_hovered(self, idx: int) -> None: + """Swap the main image label between live capture and a buffered frame.""" + if idx < 0 or idx >= len(self._replay_buffer): + self._replay_view_index = None + # Restore the live capture if we have one cached. + if self.current_pixmap is not None and not self.current_pixmap.isNull(): + scaled = self.current_pixmap.scaled( + self.image_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.image_label.setPixmap(scaled) + return + self._replay_view_index = idx + buffered = self._replay_buffer[idx] + if buffered is None or buffered.isNull(): + return + scaled = buffered.scaled( + self.image_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.image_label.setPixmap(scaled) + def paintEvent(self, event): - """Custom paint for activity indicator""" + """Custom paint: accent, threat border, focus dot, lock icon, flash.""" super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # Draw activity indicator (v2.2) + # 0. Per-character accent border (PR8) — only visible when no + # threat or legacy-flash overlay is active. Gives instant visual + # identity at small grid sizes and matches the chip avatar color. + if ( + self._threat_level is None + and self._flash_color is None + and getattr(self, "_accent_color", None) is not None + ): + pen = QPen(self._accent_color) + pen.setWidth(2) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 4, 4) + + # 1. Threat-tint border (PR1) — drawn first so dot/lock paint over it + if self._threat_level is not None and self._threat_alpha > 0.0: + r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + base_alpha = int(220 * self._threat_alpha) + # Pulse adds an extra alpha kick for the first ~600ms after upgrade + pulse_boost = int(35 * self._pulse_phase) + alpha = max(0, min(255, base_alpha + pulse_boost)) + border_color = QColor(r, g, b, alpha) + pen_width = 3 + int(2 * self._pulse_phase) # 3px → 5px during pulse + pen = QPen(border_color) + pen.setWidth(pen_width) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + inset = pen_width // 2 + 1 + painter.drawRoundedRect( + inset, + inset, + self.width() - 2 * inset, + self.height() - 2 * inset, + 4, + 4, + ) + + # PR9: distance badge — "+Nj" near the top-left corner inside + # the threat border, in the threat color. Only renders for + # adjacent-system alerts (distance > 0). + if self._threat_distance and self._threat_distance > 0: + from PySide6.QtGui import QFont + + badge_text = f"+{self._threat_distance}j" + badge_font = QFont(painter.font()) + badge_font.setPointSize(8) + badge_font.setBold(True) + painter.setFont(badge_font) + # Foreground stays in the threat color, opaque so it's + # legible even when the border itself is dim. + text_color = QColor(r, g, b, max(200, alpha)) + painter.setPen(QPen(text_color)) + # Shifted right of where the lock icon sits (x=6) so they + # don't overlap when both are visible. + painter.drawText(28, 18, badge_text) + + # 2. Legacy flash overlay (compat with border_flash_requested) + if self._flash_color is not None: + pen = QPen(self._flash_color) + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 4, 4) + + # 3. Activity indicator (v2.2) if self._show_activity_indicator: activity = self.get_activity_state() if activity == "focused": @@ -825,7 +1219,7 @@ def paintEvent(self, event): painter.setPen(QPen(Qt.PenStyle.NoPen)) painter.drawEllipse(self.width() - 14, 6, 8, 8) - # Draw lock icon if positions are locked + # 4. Lock icon if positions are locked if self._positions_locked: painter.setPen(QPen(QColor(200, 200, 200, 180))) painter.drawText(6, 14, "🔒") @@ -873,6 +1267,18 @@ def mouseReleaseEvent(self, event): self.logger.info(f"Activating window: {self.window_id}") self._drag_start_pos = None + def mouseDoubleClickEvent(self, event): + """PR3: double-click toggles spotlight focus mode for this window.""" + if event.button() == Qt.MouseButton.LeftButton: + self.focus_requested.emit(self.window_id) + event.accept() + # Cancel the pending single-click activation that the + # preceding press recorded; otherwise the window still + # activates beneath the focus toggle. + self._drag_start_pos = None + return + super().mouseDoubleClickEvent(event) + def contextMenuEvent(self, event): """Handle right-click context menu (v2.3 - uses ActionRegistry)""" # Build context menu from ActionRegistry @@ -894,8 +1300,36 @@ def contextMenuEvent(self, event): parent=self, ) + # PR10: replay-strip toggle. Added directly here rather than via + # ActionRegistry because the action is per-widget state, not a + # global tier-1 / tier-2 action — registering it as a tier-3 + # CONTEXT action would force a registry refactor for one toggle. + menu.addSeparator() + replay_label = ( + "Hide replay strip" if self.is_replay_strip_enabled() else "Show replay strip" + ) + replay_action = menu.addAction(replay_label) + replay_action.triggered.connect(self._toggle_replay_strip) + menu.exec(event.globalPos()) + def _toggle_replay_strip(self) -> None: + """Flip the replay strip on/off and persist the choice per character.""" + new_state = not self.is_replay_strip_enabled() + self.enable_replay_strip(new_state) + if self.settings_manager is not None: + try: + store = self.settings_manager.get("replay_strip_enabled", {}) or {} + if not isinstance(store, dict): + store = {} + if new_state: + store[self.character_name] = True + else: + store.pop(self.character_name, None) + self.settings_manager.set("replay_strip_enabled", store) + except (AttributeError, TypeError) as e: + self.logger.debug(f"Failed to persist replay-strip toggle: {e}") + def _show_label_dialog(self): """Show dialog to set custom label""" current = self.custom_label or "" @@ -956,6 +1390,15 @@ def __init__(self, character_manager, capture_system, settings_manager=None): # State self.preview_frames: dict[str, WindowPreviewWidget] = {} + # PR4: per-character current system, fed by CharacterLocationTracker. + # Survives window add/remove cycles. Used for chip system labels and + # (in a follow-up PR) smart per-character threat fan-out. + self._character_systems: dict[str, str] = {} + # PR6: optional jump calculator + max-jumps threshold for the + # adjacency-aware fan-out. Default max_jumps=0 preserves the PR5 + # exact-match-only filter when no calculator is wired up. + self._jump_calculator = None # type: ignore[var-annotated] + self._jump_max: int = 0 self.pending_requests: dict[ str, tuple[str, float] ] = {} # request_id -> (window_id, timestamp) @@ -1036,6 +1479,107 @@ def remove_window(self, window_id: str): self.logger.info(f"Removed window {window_id} from preview") + def set_character_system(self, character_name: str, system: str | None) -> None: + """ + Record the current system for a character. + + Stored on a per-character map that survives across window add/remove + cycles, AND pushed to every active frame for that character so the + smart fan-out (apply_threat_state) can filter by frame state alone. + """ + if system is None: + self._character_systems.pop(character_name, None) + else: + self._character_systems[character_name] = system + + # Push to active frames whose character matches. + for frame in list(self.preview_frames.values()): + try: + if getattr(frame, "character_name", None) == character_name: + frame.set_character_system(system) + except RuntimeError: + continue + + def get_character_system(self, character_name: str) -> str | None: + return self._character_systems.get(character_name) + + def set_jump_calculator(self, calculator, max_jumps: int = 1) -> None: + """ + Wire an adjacency calculator for the jumps-from threat filter (PR6). + + Args: + calculator: A JumpCalculator (or None to disable adjacency). + max_jumps: Maximum jump distance to consider "near"; 0 disables + adjacency tinting (exact-match-only). Default 1. + """ + self._jump_calculator = calculator + self._jump_max = max(0, int(max_jumps)) + + def apply_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: + """ + Fan an intel threat state out to preview frames, filtered by system. + + Filter rules: + 1. CLEAR / None level → flush every frame regardless of system. + 2. system is None / empty → fan to all (legacy fallback). + 3. Otherwise → resolve_tint() decides per-frame: same-system at + full alpha, adjacent within max_jumps at falloff alpha, + beyond threshold skipped, unknown character system tinted at + full alpha (graceful upgrade). + + Returns count of frames actually updated. + """ + from argus_overview.intel.threat_filter import resolve_tint + + # Rule 1 + 2: explicit-flush branches + if level is None or level == ThreatLevel.CLEAR or not system: + return self._fan_to_all(level, system) + + char_systems = getattr(self, "_character_systems", {}) or {} + calculator = getattr(self, "_jump_calculator", None) + max_jumps = getattr(self, "_jump_max", 0) + count = 0 + for frame in list(self.preview_frames.values()): + try: + char_name = getattr(frame, "character_name", None) + known = char_systems.get(char_name) if char_name else None + should_apply, alpha = resolve_tint( + known_system=known, + alert_system=system, + jump_calculator=calculator, + max_jumps=max_jumps, + ) + if not should_apply: + continue + # PR9: surface the jump distance for the +Nj frame badge. + # Mirrors StatusDock.set_threat_state behavior (PR7). + distance: int | None = None + if ( + alpha < 1.0 + and known + and calculator is not None + and known.lower() != system.lower() + ): + try: + distance = calculator.distance(known, system) + except (AttributeError, TypeError, ValueError): + distance = None + frame.set_threat_state(level, system, initial_alpha=alpha, distance=distance) + count += 1 + except RuntimeError: + continue + return count + + def _fan_to_all(self, level: ThreatLevel | None, system: str | None) -> int: + count = 0 + for frame in list(self.preview_frames.values()): + try: + frame.set_threat_state(level, system) + count += 1 + except RuntimeError: + continue + return count + def _capture_cycle(self): """ Capture cycle - called by timer @@ -1130,6 +1674,9 @@ def __init__(self, capture_system, character_manager, settings_manager=None, par self.character_manager = character_manager self.settings_manager = settings_manager + # PR3: focus mode state — None = normal, str = window_id holding spotlight + self._focus_window_id: str | None = None + # v2.2 State self._thumbnails_visible = True self._positions_locked = False @@ -1189,6 +1736,18 @@ def _setup_ui(self): layout_controls = self._create_layout_controls() layout.addWidget(layout_controls) + # PR2: Character status dock — chip strip with per-character system + # + threat dot. Clicking a chip focuses the matching window. + # Parent is set when the dock is added to the layout below. + from argus_overview.ui.status_dock import StatusDock + + self.status_dock = StatusDock() + self.status_dock.chip_clicked.connect(self._on_window_activated) + sm = getattr(self, "settings_manager", None) + show_dock = sm.get("thumbnails.show_status_dock", True) if sm else True + self.status_dock.setVisible(show_dock) + layout.addWidget(self.status_dock) + # Scroll area for preview frames scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -1207,6 +1766,23 @@ def _setup_ui(self): status_bar = self._create_status_bar() layout.addWidget(status_bar) + def _sync_status_dock(self) -> None: + """Mirror window_manager.preview_frames into the status dock.""" + if not hasattr(self, "status_dock") or self.status_dock is None: + return + desired: dict[str, str] = { + wid: getattr(frame, "character_name", wid) + for wid, frame in self.window_manager.preview_frames.items() + } + self.status_dock.sync_from_window_ids(desired) + + # PR4: seed chip system labels from cached per-character state so + # newly-added chips populate without waiting for the next location + # change event. + char_systems = getattr(self.window_manager, "_character_systems", {}) + for char_name, system in char_systems.items(): + self.status_dock.set_character_system(char_name, system) + def _create_toolbar(self) -> QWidget: """Create toolbar using ActionRegistry (v2.3)""" toolbar = QWidget() @@ -1572,6 +2148,9 @@ def one_click_import(self): frame.window_removed.connect( self._on_window_removed, Qt.ConnectionType.UniqueConnection ) + frame.focus_requested.connect( + self._on_focus_requested, Qt.ConnectionType.UniqueConnection + ) # Add to layout self.preview_layout.addWidget(frame) @@ -1592,6 +2171,7 @@ def one_click_import(self): self.status_label.setText("No new EVE windows found") self._update_status() + self._sync_status_dock() def _toggle_lock(self): """Toggle thumbnail position lock""" @@ -1686,7 +2266,11 @@ def _add_window_to_preview(self, window_id: str, window_title: str) -> bool: frame.window_removed.connect( self._on_window_removed, Qt.ConnectionType.UniqueConnection ) + frame.focus_requested.connect( + self._on_focus_requested, Qt.ConnectionType.UniqueConnection + ) self.preview_layout.addWidget(frame) + self._sync_status_dock() return True return False @@ -1744,6 +2328,46 @@ def show_add_window_dialog(self): self.logger.info(f"Added {added} windows to preview") self._update_status() + # ----- PR3 focus mode controller -------------------------------------- + def _on_focus_requested(self, window_id: str) -> None: + """Toggle spotlight focus for the given window.""" + if self._focus_window_id == window_id: + self.exit_focus_mode() + else: + self.enter_focus_mode(window_id) + + def enter_focus_mode(self, window_id: str) -> None: + """Spotlight one window: scale up, dim others. Idempotent.""" + if window_id not in self.window_manager.preview_frames: + self.logger.debug(f"Cannot enter focus mode — unknown window {window_id}") + return + self._focus_window_id = window_id + self._apply_focus_state() + + def exit_focus_mode(self) -> None: + """Restore normal grid presentation.""" + if self._focus_window_id is None: + return + self._focus_window_id = None + self._apply_focus_state() + + def is_focus_mode_active(self) -> bool: + return self._focus_window_id is not None + + def _apply_focus_state(self) -> None: + """Push current focus mode to all preview frames.""" + focus_id = self._focus_window_id + for wid, frame in list(self.window_manager.preview_frames.items()): + try: + if focus_id is None: + frame.set_spotlight(None) + elif wid == focus_id: + frame.set_spotlight("focused") + else: + frame.set_spotlight("dimmed") + except RuntimeError: + continue + 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 @@ -1791,10 +2415,23 @@ def _on_window_removed(self, window_id: str): frame.window_removed.disconnect(self._on_window_removed) except (RuntimeError, TypeError): pass + try: + frame.focus_requested.disconnect(self._on_focus_requested) + except (RuntimeError, TypeError): + pass # Stop per-frame timers frame.session_timer.stop() + # If we just removed the spotlight target, drop focus state. + # getattr keeps this safe when called via test helpers that bypass __init__. + if getattr(self, "_focus_window_id", None) == window_id: + self._focus_window_id = None self.window_manager.remove_window(window_id) self._update_status() + if hasattr(self, "status_dock"): + self._sync_status_dock() + if hasattr(self, "_focus_window_id"): + # Re-apply (covers both: focus cleared, or other tile removed mid-focus) + self._apply_focus_state() def _remove_all_windows(self): """Remove all windows from preview""" @@ -1815,6 +2452,7 @@ def _remove_all_windows(self): self.window_manager.remove_window(window_id) self._update_status() + self._sync_status_dock() def minimize_inactive_windows(self): """Toggle auto-minimize mode - when enabled, cycling minimizes previous window""" @@ -1953,6 +2591,12 @@ def keyPressEvent(self, event: QKeyEvent): """Handle keyboard shortcuts for window navigation""" key = event.key() + # PR3: Escape exits focus mode (only consume the key if active) + if key == Qt.Key.Key_Escape and self.is_focus_mode_active(): + self.exit_focus_mode() + event.accept() + return + # Number keys 1-9 to activate window by index if Qt.Key.Key_1 <= key <= Qt.Key.Key_9: index = key - Qt.Key.Key_1 # 0-8 diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index 7451d39..d8dd4ae 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -161,8 +161,58 @@ def __init__(self): self.auto_discovery.character_gone.connect(self._on_character_gone) self.auto_discovery.start() + # PR4: per-character location tracker (Local channel logs) + self._init_location_tracker() + self.logger.info("Main window v2.2 initialized successfully") + def _init_location_tracker(self) -> None: + """Start the per-character location tracker if enabled.""" + from argus_overview.intel.character_location import CharacterLocationTracker + + enabled = self.settings_manager.get("intel.track_character_locations", True) + if not enabled: + self.location_tracker = None + return + self.location_tracker = CharacterLocationTracker(parent=self) + self.location_tracker.character_system_changed.connect(self._on_character_system_changed) + self.location_tracker.start() + + # PR6: wire one shared JumpCalculator into both threat fan-out paths + # so adjacent-system alerts also tint at reduced intensity. max_jumps + # is gated on intel.threat_jumps_threshold (default 1). + self._init_threat_jump_filter() + + def _init_threat_jump_filter(self) -> None: + """Wire a shared JumpCalculator into the manager + dock fan-out.""" + from argus_overview.intel.jumps import JumpCalculator + + max_jumps = int(self.settings_manager.get("intel.threat_jumps_threshold", 1)) + if max_jumps <= 0: + self.jump_calculator = None + return + self.jump_calculator = JumpCalculator() + if not hasattr(self, "main_tab"): + return + wm = getattr(self.main_tab, "window_manager", None) + if wm is not None and hasattr(wm, "set_jump_calculator"): + wm.set_jump_calculator(self.jump_calculator, max_jumps=max_jumps) + dock = getattr(self.main_tab, "status_dock", None) + if dock is not None and hasattr(dock, "set_jump_calculator"): + dock.set_jump_calculator(self.jump_calculator, max_jumps=max_jumps) + + @Slot(str, str) + def _on_character_system_changed(self, character_name: str, system: str) -> None: + """Forward per-character system updates to the dock + window manager.""" + if not hasattr(self, "main_tab"): + return + wm = getattr(self.main_tab, "window_manager", None) + if wm is not None and hasattr(wm, "set_character_system"): + wm.set_character_system(character_name, system) + dock = getattr(self.main_tab, "status_dock", None) + if dock is not None and hasattr(dock, "set_character_system"): + dock.set_character_system(character_name, system) + def _create_system_tray(self): """Create system tray icon (v2.4 - uses ActionRegistry)""" self.system_tray = SystemTray(self) @@ -653,11 +703,23 @@ def _flash_preview_borders(self, color: str, duration_ms: int): @Slot(object, object) def _on_intel_alert(self, report, alert_type): """Handle intel alert from intel tab.""" + from argus_overview.intel.alerts import AlertType from argus_overview.intel.parser import IntelReport if not isinstance(report, IntelReport): return + # Fan out threat state to preview frames + status dock once per report + # (filter on VISUAL_BORDER so we only trigger on a single AlertType + # emission per report, not on every type the dispatcher fires). + if alert_type == AlertType.VISUAL_BORDER and hasattr(self, "main_tab"): + window_manager = getattr(self.main_tab, "window_manager", None) + if window_manager is not None and hasattr(window_manager, "apply_threat_state"): + window_manager.apply_threat_state(report.threat_level, report.system) + status_dock = getattr(self.main_tab, "status_dock", None) + if status_dock is not None and hasattr(status_dock, "set_threat_state"): + status_dock.set_threat_state(report.threat_level, report.system) + # Show tray notification for critical alerts if report.threat_level.value == "critical": if hasattr(self, "system_tray"): @@ -1004,6 +1066,15 @@ def closeEvent(self, event: QCloseEvent): if hasattr(self, "auto_discovery"): self.auto_discovery.stop() + if getattr(self, "location_tracker", None) is not None: + try: + self.location_tracker.character_system_changed.disconnect( + self._on_character_system_changed + ) + except (RuntimeError, TypeError): + pass + self.location_tracker.stop() + if hasattr(self, "capture_system"): self.capture_system.stop() diff --git a/src/argus_overview/ui/replay_strip.py b/src/argus_overview/ui/replay_strip.py new file mode 100644 index 0000000..e5f8eb8 --- /dev/null +++ b/src/argus_overview/ui/replay_strip.py @@ -0,0 +1,147 @@ +""" +Replay strip — small horizontal row of recent capture frames. + +Docked at the bottom of a WindowPreviewWidget when the user toggles +"Show replay strip" from the context menu. Hovering a cell emits +frame_hovered(idx) so the parent widget can swap its main image to the +buffered frame; leaving the strip emits -1 so the parent restores live +capture. + +PR10 of the intel-aware UI uplift. Memory cost is bounded: the parent +holds the QPixmaps; the strip just paints references. +""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen, QPixmap +from PySide6.QtWidgets import QWidget + + +class ReplayStrip(QWidget): + """ + Horizontal row of recent frame thumbnails. + + Stateless beyond its frame list + hover index — the parent widget + owns the ring buffer and the live/buffered display swap. + + Signals: + frame_hovered(int): The cell index under the mouse, or -1 when + no cell is hovered (mouse left the strip). + """ + + frame_hovered = Signal(int) # cell index, or -1 when not hovering + + STRIP_HEIGHT = 32 + CELL_PADDING = 2 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._frames: list[QPixmap] = [] + self._hover_index: int = -1 + + self.setFixedHeight(self.STRIP_HEIGHT) + self.setMouseTracking(True) + self.setStyleSheet( + """ + ReplayStrip { + background-color: rgba(20, 20, 20, 220); + border-top: 1px solid #3a3a3a; + } + """ + ) + + # ----- Public API ------------------------------------------------------- + def set_frames(self, frames: list[QPixmap]) -> None: + """Update the strip with a new ordered list (oldest → newest).""" + self._frames = list(frames) + # Re-clamp hover if it now points past the end. + if self._hover_index >= len(self._frames): + self._hover_index = -1 + self.update() + + def frame_count(self) -> int: + return len(self._frames) + + def hover_index(self) -> int: + return self._hover_index + + # ----- Internals -------------------------------------------------------- + def _cell_width(self) -> int: + if not self._frames: + return 0 + usable = max(0, self.width() - self.CELL_PADDING * 2) + return usable // max(1, len(self._frames)) + + def _index_at(self, x: int) -> int: + cell_w = self._cell_width() + if cell_w <= 0: + return -1 + idx = (x - self.CELL_PADDING) // cell_w + if idx < 0 or idx >= len(self._frames): + return -1 + return int(idx) + + def sizeHint(self) -> QSize: + return QSize(200, self.STRIP_HEIGHT) + + # ----- Events ----------------------------------------------------------- + def paintEvent(self, event): + super().paintEvent(event) + if not self._frames: + return + + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + cell_w = self._cell_width() + if cell_w <= 0: + return + inner_h = self.height() - self.CELL_PADDING * 2 + for i, pixmap in enumerate(self._frames): + x = self.CELL_PADDING + i * cell_w + y = self.CELL_PADDING + # Draw thumbnail clipped to cell rect, preserving aspect. + if pixmap is not None and not pixmap.isNull(): + scaled = pixmap.scaled( + cell_w - 2, + inner_h, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + # Center in cell + cx = x + (cell_w - scaled.width()) // 2 + cy = y + (inner_h - scaled.height()) // 2 + painter.drawPixmap(cx, cy, scaled) + # Cell border + painter.setPen(QPen(QColor(60, 60, 60), 1)) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRect(x, y, cell_w - 1, inner_h - 1) + + # Highlight the hovered cell. + if 0 <= self._hover_index < len(self._frames): + hx = self.CELL_PADDING + self._hover_index * cell_w + hy = self.CELL_PADDING + painter.setPen(QPen(QColor(120, 200, 255, 230), 2)) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRect(hx, hy, cell_w - 1, inner_h - 1) + finally: + painter.end() + + def mouseMoveEvent(self, event): + idx = self._index_at(int(event.position().x())) + if idx != self._hover_index: + self._hover_index = idx + self.frame_hovered.emit(idx) + self.update() + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + if self._hover_index != -1: + self._hover_index = -1 + self.frame_hovered.emit(-1) + self.update() + super().leaveEvent(event) diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py new file mode 100644 index 0000000..d60c03f --- /dev/null +++ b/src/argus_overview/ui/status_dock.py @@ -0,0 +1,428 @@ +""" +Character Status Dock — horizontal strip of character chips. + +Each chip surfaces per-character state that EVE-O Preview cannot: +character avatar (initials in an accent color), system name, and a +threat-tint dot driven by the same intel pipeline that tints preview +borders. Click a chip to focus the matching window. + +PR2 of the intel-aware UI uplift. Pairs with WindowPreviewWidget's +threat-tint border (PR1) so glanceable threat state is visible whether +you're looking at a thumbnail grid or the dock. +""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from argus_overview.intel.parser import ThreatLevel +from argus_overview.ui.main_tab import ( + CHARACTER_ACCENT_COLORS, + THREAT_BORDER_COLORS, + character_accent_color, +) + +# Backward-compatible aliases — PR8 promoted these to main_tab.py so +# frames + chips share one palette + helper. Public names preserved. +CHIP_ACCENT_COLORS = CHARACTER_ACCENT_COLORS +accent_for = character_accent_color + + +def _initials(name: str) -> str: + parts = [p for p in name.replace("_", " ").split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][0] + parts[-1][0]).upper() + + +class CharacterChip(QFrame): + """ + A single chip in the StatusDock. + + Layout (left to right): + [ avatar 28x28 ] [ name ] [ system pill ] [ threat dot ] + """ + + clicked = Signal(str) # window_id + + AVATAR_SIZE = 28 + DOT_SIZE = 10 + + def __init__( + self, + window_id: str, + character_name: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.window_id = window_id + self.character_name = character_name + self._accent: QColor = accent_for(character_name) + self._system: str | None = None + self._threat_level: ThreatLevel | None = None + self._threat_alpha: float = 0.0 + # PR7: jumps from this chip's character to the alert system. None when + # same-system or unknown; positive int for adjacent. Renders as +Nj. + self._threat_distance: int | None = None + + self.setFixedHeight(40) + self.setMinimumWidth(160) + self.setMaximumWidth(260) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._apply_base_style() + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 4, 8, 4) + layout.setSpacing(8) + + # Avatar — a fixed-size frame painted with initials in the accent color + self._avatar = QLabel(_initials(character_name)) + self._avatar.setFixedSize(self.AVATAR_SIZE, self.AVATAR_SIZE) + self._avatar.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._update_avatar_style() + layout.addWidget(self._avatar) + + # Name label (primary, bold) + self._name_label = QLabel(character_name) + self._name_label.setStyleSheet("font-weight: bold; font-size: 10pt;") + self._name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + layout.addWidget(self._name_label) + + # System label (secondary) + self._system_label = QLabel("—") + self._system_label.setStyleSheet("color: #aaa; font-size: 9pt;") + self._system_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(self._system_label) + + # Threat dot is painted in paintEvent (no widget needed — keeps chip compact) + + self.setToolTip(self._tooltip_text()) + + # ----- styling helpers -------------------------------------------------- + def _apply_base_style(self) -> None: + self.setStyleSheet( + """ + CharacterChip { + background-color: #2b2b2b; + border: 1px solid #444; + border-radius: 6px; + } + CharacterChip:hover { + background-color: #353535; + border-color: #6a6a6a; + } + """ + ) + + def _update_avatar_style(self) -> None: + a = self._accent + darker = a.darker(160) + # luminance-based contrast for the initials text + luminance = (a.red() * 299 + a.green() * 587 + a.blue() * 114) / 1000 + text_color = "#0f0f0f" if luminance > 140 else "#f5f5f5" + self._avatar.setStyleSheet( + f""" + background-color: {a.name()}; + border: 1px solid {darker.name()}; + border-radius: {self.AVATAR_SIZE // 2}px; + color: {text_color}; + font-weight: bold; + font-size: 10pt; + """ + ) + + def _tooltip_text(self) -> str: + parts = [self.character_name] + if self._system: + parts.append(f"System: {self._system}") + if self._threat_level is not None: + line = f"Threat: {self._threat_level.value}" + if self._threat_distance and self._threat_distance > 0: + line += f" ({self._threat_distance}j away)" + parts.append(line) + parts.append("Click to focus window") + return "\n".join(parts) + + # ----- public API ------------------------------------------------------- + def set_system(self, system: str | None) -> None: + self._system = system + self._system_label.setText(system or "—") + self.setToolTip(self._tooltip_text()) + + def set_threat_state( + self, + level: ThreatLevel | None, + system: str | None = None, + alpha: float = 1.0, + distance: int | None = None, + ) -> None: + """ + Update threat state for this chip. + + Args: + level: Threat level. None or CLEAR clears state. + system: System the alert refers to. + alpha: Initial alpha [0, 1] for the threat dot. PR6 falloff for + adjacent-system alerts uses < 1.0. + distance: Jumps from this chip's character to the alert system. + None for same-system or unknown. Positive ints render as + "+Nj" badge next to the threat dot (PR7). + """ + if level is None or level == ThreatLevel.CLEAR: + self._threat_level = None + self._threat_alpha = 0.0 + self._threat_distance = None + else: + self._threat_level = level + self._threat_alpha = max(0.0, min(1.0, alpha)) + self._threat_distance = distance if distance and distance > 0 else None + if system is not None: + self.set_system(system) + else: + self.setToolTip(self._tooltip_text()) + self.update() + + # ----- events ----------------------------------------------------------- + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.window_id) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + if self._threat_level is None or self._threat_alpha <= 0.0: + return + + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + alpha = max(0, min(255, int(230 * self._threat_alpha))) + color = QColor(r, g, b, alpha) + painter.setPen(QPen(color.darker(140), 1)) + painter.setBrush(QBrush(color)) + # Draw threat dot vertically centered, left of the right edge + dot_x = self.width() - self.DOT_SIZE - 8 + dot_y = (self.height() - self.DOT_SIZE) // 2 + painter.drawEllipse(dot_x, dot_y, self.DOT_SIZE, self.DOT_SIZE) + + # PR7: distance badge for adjacent-system alerts. + # Renders as "+Nj" just left of the threat dot in the same color. + if self._threat_distance and self._threat_distance > 0: + from PySide6.QtGui import QFont + + badge_text = f"+{self._threat_distance}j" + font = painter.font() + badge_font = QFont(font) + badge_font.setPointSize(7) + badge_font.setBold(True) + painter.setFont(badge_font) + # Foreground stays in the threat color but bumped opaque so + # it stays legible even when the dot itself is dim. + text_color = QColor(r, g, b, max(180, alpha)) + painter.setPen(QPen(text_color)) + metrics = painter.fontMetrics() + text_w = metrics.horizontalAdvance(badge_text) + # Place to the left of the dot, vertically centered. + text_x = dot_x - text_w - 3 + text_y = (self.height() + metrics.ascent() - metrics.descent()) // 2 + painter.drawText(text_x, text_y, badge_text) + finally: + painter.end() + + +class StatusDock(QWidget): + """ + Horizontal strip of CharacterChip widgets. + + Mounts above the preview grid in MainTab. Designed to mirror the set + of active preview windows: one chip per window_id. Chips emit + chip_clicked when activated; the dock re-emits to the parent. + """ + + chip_clicked = Signal(str) # window_id + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._chips: dict[str, CharacterChip] = {} + # PR6 jumps-from filter — set via set_jump_calculator. Default + # max_jumps=0 keeps the PR5 exact-match-only behavior. + self._jump_calculator = None + self._jump_max: int = 0 + + self.setFixedHeight(56) + outer = QVBoxLayout(self) + outer.setContentsMargins(4, 2, 4, 2) + outer.setSpacing(0) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + outer.addWidget(self._scroll) + + self._strip = QWidget() + self._strip_layout = QHBoxLayout(self._strip) + self._strip_layout.setContentsMargins(4, 2, 4, 2) + self._strip_layout.setSpacing(6) + self._strip_layout.addStretch() # push chips left, fill empty space right + self._scroll.setWidget(self._strip) + + self.setStyleSheet( + """ + StatusDock { + background-color: #1f1f1f; + border-bottom: 1px solid #3a3a3a; + } + """ + ) + + # ----- public API ------------------------------------------------------- + def chip_count(self) -> int: + return len(self._chips) + + def has_chip(self, window_id: str) -> bool: + return window_id in self._chips + + def add_chip(self, window_id: str, character_name: str) -> CharacterChip | None: + if window_id in self._chips: + return None + chip = CharacterChip(window_id, character_name, parent=self._strip) + chip.clicked.connect(self.chip_clicked.emit) + # Insert before the trailing stretch (last item) + insert_at = max(0, self._strip_layout.count() - 1) + self._strip_layout.insertWidget(insert_at, chip) + self._chips[window_id] = chip + return chip + + def remove_chip(self, window_id: str) -> bool: + chip = self._chips.pop(window_id, None) + if chip is None: + return False + self._strip_layout.removeWidget(chip) + chip.deleteLater() + return True + + def clear(self) -> None: + for window_id in list(self._chips.keys()): + self.remove_chip(window_id) + + def set_jump_calculator(self, calculator, max_jumps: int = 1) -> None: + """Wire an adjacency calculator for the jumps-from filter (PR6).""" + self._jump_calculator = calculator + self._jump_max = max(0, int(max_jumps)) + + def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: + """ + Fan a threat state out to chips, filtered by system. + + Filter rules (mirror WindowManager.apply_threat_state for symmetry): + 1. CLEAR / None level → flush every chip. + 2. system is None / empty → fan to all (legacy fallback). + 3. Otherwise → resolve_tint() per chip. Same-system at full alpha, + adjacent within max_jumps at falloff alpha, beyond skipped, + unknown chip-system tinted at full alpha (graceful upgrade). + + Returns count of chips updated. + """ + from argus_overview.intel.threat_filter import resolve_tint + + flush = level is None or level == ThreatLevel.CLEAR or not system + count = 0 + calculator = getattr(self, "_jump_calculator", None) + max_jumps = getattr(self, "_jump_max", 0) + for chip in list(self._chips.values()): + try: + if flush: + chip.set_threat_state(level, system) + count += 1 + continue + chip_system = getattr(chip, "_system", None) + should_apply, alpha = resolve_tint( + known_system=chip_system, + alert_system=system, + jump_calculator=calculator, + max_jumps=max_jumps, + ) + if not should_apply: + continue + # PR7: surface the jump distance for the +Nj badge. + distance: int | None = None + if ( + alpha < 1.0 + and chip_system + and calculator is not None + and chip_system.lower() != system.lower() + ): + try: + distance = calculator.distance(chip_system, system) + except (AttributeError, TypeError, ValueError): + distance = None + chip.set_threat_state(level, system, alpha=alpha, distance=distance) + count += 1 + except RuntimeError: + continue + return count + + def set_chip_system(self, window_id: str, system: str | None) -> bool: + chip = self._chips.get(window_id) + if chip is None: + return False + chip.set_system(system) + return True + + def set_character_system(self, character_name: str, system: str | None) -> int: + """ + Update every chip for a given character. Returns count updated. + + Multi-boxers can run the same character in multiple windows, so + chips are matched by character_name, not window_id. Source of the + update is typically CharacterLocationTracker. + """ + count = 0 + for chip in list(self._chips.values()): + try: + if chip.character_name == character_name: + chip.set_system(system) + count += 1 + except RuntimeError: + continue + return count + + def sync_from_window_ids(self, desired: dict[str, str]) -> tuple[list[str], list[str]]: + """ + Bulk diff: ensure chips match `desired` mapping of window_id -> name. + + Returns (added_ids, removed_ids). + """ + existing = set(self._chips.keys()) + target = set(desired.keys()) + added = [] + removed = [] + for window_id in existing - target: + if self.remove_chip(window_id): + removed.append(window_id) + for window_id in target - existing: + name = desired[window_id] + if self.add_chip(window_id, name) is not None: + added.append(window_id) + return added, removed diff --git a/tests/test_character_location.py b/tests/test_character_location.py new file mode 100644 index 0000000..3428710 --- /dev/null +++ b/tests/test_character_location.py @@ -0,0 +1,475 @@ +"""Tests for CharacterLocationTracker (PR4: per-character system tracking).""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from argus_overview.intel.character_location import ( + CharacterLocationTracker, + find_eve_chat_log_directory, +) + +# Header block as EVE writes it, plus an initial Channel-changed line. +_HEADER_TEMPLATE = ( + "" # BOM stripped by utf-16-le decoding errors=ignore is fine + "---------------------------------------------------------------\r\n" + " Channel ID: Local\r\n" + " Channel Name: Local\r\n" + " Listener: {listener}\r\n" + " Session Started: 2024.01.15 14:30:00\r\n" + "---------------------------------------------------------------\r\n" +) + + +def _write_local_log( + directory: Path, + listener: str, + lines: list[str] | None = None, + suffix: str = "20240115_143000", +) -> Path: + """Write a UTF-16-LE Local log file with header + provided lines.""" + content = _HEADER_TEMPLATE.format(listener=listener) + if lines: + content += "\r\n".join(lines) + "\r\n" + path = directory / f"Local_{suffix}.txt" + # Use 'utf-16' (with BOM) to match real EVE files; tracker uses utf-16-le + # with errors='ignore' which handles either. + path.write_bytes(content.encode("utf-16-le")) + return path + + +def _append_local_log(path: Path, lines: list[str]) -> None: + """Append UTF-16-LE-encoded lines to an existing log file.""" + addition = ("\r\n".join(lines) + "\r\n").encode("utf-16-le") + with open(path, "ab") as f: + f.write(addition) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +class TestFindEveChatLogDirectory: + def test_returns_first_existing(self, tmp_path): + # Manually patch Path.home to a directory layout where a candidate exists. + fake_home = tmp_path / "home" + chatlogs = fake_home / "Documents" / "EVE" / "logs" / "Chatlogs" + chatlogs.mkdir(parents=True) + with patch("argus_overview.intel.character_location.Path.home", return_value=fake_home): + result = find_eve_chat_log_directory() + assert result == chatlogs + + def test_returns_none_when_no_candidates_exist(self, tmp_path): + fake_home = tmp_path / "no-eve" + fake_home.mkdir() + with patch("argus_overview.intel.character_location.Path.home", return_value=fake_home): + result = find_eve_chat_log_directory() + assert result is None + + +# ============================================================================= +# Tracker — initial state +# ============================================================================= + + +class TestCharacterLocationTrackerInit: + def test_initial_state_empty(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker.get_all_locations() == {} + assert tracker.get_system("Anyone") is None + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_poll_interval_clamped(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path, poll_interval_ms=10) + try: + assert tracker.poll_interval_ms == 250 + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — header + line parsing +# ============================================================================= + + +class TestCharacterLocationTrackerParsing: + def test_listener_extracted_from_header(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "TestPilot") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + listener = tracker._read_listener(path) + assert listener == "TestPilot" + finally: + tracker.deleteLater() + + def test_listener_returns_none_for_missing_header(self, qapp, tmp_path): + path = tmp_path / "Local_20240115_140000.txt" + path.write_bytes("just chat data\r\n".encode("utf-16-le")) + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker._read_listener(path) is None + finally: + tracker.deleteLater() + + def test_listener_with_special_characters(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Pilot O'Connor-Smith") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker._read_listener(path) == "Pilot O'Connor-Smith" + finally: + tracker.deleteLater() + + def test_channel_changed_emits_signal(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + # First scan establishes the file at end-of-file: no events. + tracker._poll() + assert received == [] + + _append_local_log( + path, + ["[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita"], + ) + tracker._poll() + assert received == [("Alice", "Jita")] + finally: + tracker.deleteLater() + + def test_channel_changed_dedupes_same_system(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + [ + "[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita", + "[ 2024.01.15 14:32:00 ] EVE System > Channel changed to Local : Jita", + ], + ) + tracker._poll() + assert received == [("Alice", "Jita")] + finally: + tracker.deleteLater() + + def test_channel_changed_emits_on_real_change(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita"], + ) + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 14:33:00 ] EVE System > Channel changed to Local : Amarr"], + ) + tracker._poll() + assert received == [("Alice", "Jita"), ("Alice", "Amarr")] + assert tracker.get_system("Alice") == "Amarr" + finally: + tracker.deleteLater() + + def test_unrelated_lines_ignored(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + [ + "[ 2024.01.15 14:31:00 ] Bob > o7", + "[ 2024.01.15 14:31:05 ] Carol > anyone home?", + ], + ) + tracker._poll() + assert received == [] + finally: + tracker.deleteLater() + + def test_handles_file_truncation(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() # Start at end-of-file + # Truncate the file (simulates rotation/recreation) + path.write_bytes(_HEADER_TEMPLATE.format(listener="Alice").encode("utf-16-le")) + # File is now smaller — tracker should reset cursor + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 15:00:00 ] EVE System > Channel changed to Local : Dodixie"], + ) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + tracker._poll() + assert received == [("Alice", "Dodixie")] + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — multi-character +# ============================================================================= + + +class TestCharacterLocationTrackerMultiChar: + def test_two_characters_tracked_independently(self, qapp, tmp_path): + a = _write_local_log(tmp_path, "Alice", suffix="20240115_140000") + b = _write_local_log(tmp_path, "Bob", suffix="20240115_140100") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() # establish cursors + _append_local_log( + a, + ["[ 2024.01.15 14:30:00 ] EVE System > Channel changed to Local : Jita"], + ) + _append_local_log( + b, + ["[ 2024.01.15 14:30:01 ] EVE System > Channel changed to Local : Amarr"], + ) + tracker._poll() + assert tracker.get_system("Alice") == "Jita" + assert tracker.get_system("Bob") == "Amarr" + assert tracker.get_all_locations() == {"Alice": "Jita", "Bob": "Amarr"} + finally: + tracker.deleteLater() + + def test_files_without_listener_skipped(self, qapp, tmp_path): + # File without a Listener line — tracker should not crash and should + # not emit anything for it. + bad = tmp_path / "Local_20240115_140200.txt" + bad.write_bytes("garbage data\r\n".encode("utf-16-le")) + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() + tracker._poll() + assert tracker.get_all_locations() == {} + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — lifecycle +# ============================================================================= + + +class TestCharacterLocationTrackerLifecycle: + def test_start_no_directory_idles(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=None) + try: + with patch( + "argus_overview.intel.character_location.find_eve_chat_log_directory", + return_value=None, + ): + tracker.start() + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_start_with_directory_runs(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path, poll_interval_ms=500) + try: + tracker.start() + assert tracker.is_running() is True + tracker.stop() + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_start_idempotent(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker.start() + tracker.start() # should not raise or restart + assert tracker.is_running() is True + tracker.stop() + finally: + tracker.deleteLater() + + def test_stop_idempotent(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker.stop() # never started + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + +# ============================================================================= +# StatusDock.set_character_system +# ============================================================================= + + +class TestStatusDockSetCharacterSystem: + def test_updates_only_chips_for_named_character(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.add_chip("0x3", "Alice") # multibox: Alice in two windows + + count = dock.set_character_system("Alice", "Jita") + + assert count == 2 + assert dock._chips["0x1"]._system == "Jita" + assert dock._chips["0x3"]._system == "Jita" + assert dock._chips["0x2"]._system is None + finally: + dock.deleteLater() + + def test_unknown_character_returns_zero(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + count = dock.set_character_system("Nobody", "Jita") + assert count == 0 + finally: + dock.deleteLater() + + +# ============================================================================= +# WindowManager.set_character_system +# ============================================================================= + + +class TestWindowManagerSetCharacterSystem: + def _make_manager(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = {} + return manager + + def test_set_then_get(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + assert manager.get_character_system("Alice") == "Jita" + + def test_overwrite(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + manager.set_character_system("Alice", "Amarr") + assert manager.get_character_system("Alice") == "Amarr" + + def test_set_none_clears(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + manager.set_character_system("Alice", None) + assert manager.get_character_system("Alice") is None + + def test_unknown_character_returns_none(self): + manager = self._make_manager() + assert manager.get_character_system("Nobody") is None + + +# ============================================================================= +# MainWindowV21 wiring +# ============================================================================= + + +class TestMainWindowV21CharacterLocationWiring: + def test_on_character_system_changed_forwards_to_dock_and_manager(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + + window._on_character_system_changed("Alice", "Jita") + + window.main_tab.window_manager.set_character_system.assert_called_once_with("Alice", "Jita") + window.main_tab.status_dock.set_character_system.assert_called_once_with("Alice", "Jita") + + def test_on_character_system_changed_no_main_tab_safe(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + # No main_tab set — should not raise + window._on_character_system_changed("Alice", "Jita") + + def test_on_character_system_changed_dock_optional(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock(spec=["window_manager"]) + window.main_tab.window_manager = MagicMock() + + window._on_character_system_changed("Alice", "Jita") + + window.main_tab.window_manager.set_character_system.assert_called_once() + + +# ============================================================================= +# MainTab._sync_status_dock seeding +# ============================================================================= + + +class TestMainTabSyncStatusDockSeedsSystem: + def test_sync_seeds_known_systems_into_chips(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + tab.window_manager._character_systems = {"Alice": "Jita", "Bob": "Amarr"} + tab.status_dock = MagicMock() + + tab._sync_status_dock() + + # Verify both characters were pushed into the dock for seeding + calls = tab.status_dock.set_character_system.call_args_list + seeded = {(c.args[0], c.args[1]) for c in calls} + assert seeded == {("Alice", "Jita"), ("Bob", "Amarr")} + + +# ============================================================================= +# Additional safety: malformed lines +# ============================================================================= + + +@pytest.mark.parametrize( + "line", + [ + "", + "garbage", + "[ bad timestamp ] EVE System > Channel changed to Local : Jita", + "[ 2024.01.15 14:31:00 ] EVE System > Some other message", + "[ 2024.01.15 14:31:00 ] Player > Channel changed to Local : Jita", + ], +) +def test_channel_changed_regex_rejects_garbage(qapp, tmp_path, line): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log(path, [line]) + tracker._poll() + assert received == [] + finally: + tracker.deleteLater() diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index fa3c32e..fab376b 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -8446,6 +8446,7 @@ def test_setup_ui_creates_layout_and_widgets(self): mock_container = MagicMock() mock_flow_layout = MagicMock() + mock_status_dock = MagicMock() with patch("argus_overview.ui.main_tab.QVBoxLayout") as mock_vbox: with patch("argus_overview.ui.main_tab.QScrollArea", return_value=mock_scroll): with patch("argus_overview.ui.main_tab.QWidget", return_value=mock_container): @@ -8453,29 +8454,33 @@ def test_setup_ui_creates_layout_and_widgets(self): "argus_overview.ui.main_tab.FlowLayout", return_value=mock_flow_layout, ): - mock_layout = MagicMock() - mock_vbox.return_value = mock_layout + with patch( + "argus_overview.ui.status_dock.StatusDock", + return_value=mock_status_dock, + ): + mock_layout = MagicMock() + mock_vbox.return_value = mock_layout - tab._setup_ui() + tab._setup_ui() - # Layout created and set - tab.setLayout.assert_called_once_with(mock_layout) + # Layout created and set + tab.setLayout.assert_called_once_with(mock_layout) - # Toolbar, layout controls, status bar created - tab._create_toolbar.assert_called_once() - tab._create_layout_controls.assert_called_once() - tab._create_status_bar.assert_called_once() + # Toolbar, layout controls, status bar created + tab._create_toolbar.assert_called_once() + tab._create_layout_controls.assert_called_once() + tab._create_status_bar.assert_called_once() - # Scroll area configured - mock_scroll.setWidgetResizable.assert_called_once_with(True) - mock_scroll.setWidget.assert_called_once_with(mock_container) + # Scroll area configured + mock_scroll.setWidgetResizable.assert_called_once_with(True) + mock_scroll.setWidget.assert_called_once_with(mock_container) - # Preview container and layout stored - assert tab.preview_container is mock_container - assert tab.preview_layout is mock_flow_layout + # Preview container and layout stored + assert tab.preview_container is mock_container + assert tab.preview_layout is mock_flow_layout - # All widgets added to layout - assert mock_layout.addWidget.call_count == 4 + # 5 widgets added to layout (PR2 added status dock) + assert mock_layout.addWidget.call_count == 5 # ============================================================================= @@ -9040,3 +9045,1668 @@ def test_on_window_activated_catches_value_error(self): tab._on_window_activated("0x123") tab.logger.error.assert_called_once() + + +# ============================================================================= +# Threat-Tint Border Tests (PR1: intel-aware preview borders) +# ============================================================================= + + +def _make_widget_for_threat_tests(): + """Bypass __init__ and seed only the threat-related state.""" + from argus_overview.ui.main_tab import ( + THREAT_DECAY_DURATION_MS, + THREAT_DECAY_TICK_MS, + THREAT_PULSE_DURATION_MS, + THREAT_PULSE_TICK_MS, + WindowPreviewWidget, + ) + + widget = WindowPreviewWidget.__new__(WindowPreviewWidget) + widget._threat_level = None + widget._threat_system = None + widget._threat_alpha = 0.0 + widget._threat_decay_steps = max(1, THREAT_DECAY_DURATION_MS // THREAT_DECAY_TICK_MS) + widget._threat_decay_timer = MagicMock() + widget._pulse_phase = 0.0 + widget._pulse_steps = max(1, THREAT_PULSE_DURATION_MS // THREAT_PULSE_TICK_MS) + widget._pulse_timer = MagicMock() + widget._flash_color = None + widget._flash_timer = MagicMock() + widget.update = MagicMock() + return widget + + +class TestWindowPreviewWidgetThreatState: + """Tests for set_threat_state, decay, pulse, and flash_border.""" + + def test_set_threat_state_stores_level_and_system(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP") + + assert widget._threat_level == ThreatLevel.WARNING + assert widget._threat_system == "HED-GP" + assert widget._threat_alpha == 1.0 + widget._threat_decay_timer.start.assert_called_once() + widget.update.assert_called() + + def test_set_threat_state_clear_resets(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.DANGER + widget._threat_system = "HED-GP" + widget._threat_alpha = 0.7 + + widget.set_threat_state(ThreatLevel.CLEAR) + + assert widget._threat_level is None + assert widget._threat_system is None + assert widget._threat_alpha == 0.0 + widget._threat_decay_timer.stop.assert_called_once() + + def test_set_threat_state_none_resets(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.DANGER + widget._threat_alpha = 0.5 + + widget.set_threat_state(None) + + assert widget._threat_level is None + assert widget._threat_alpha == 0.0 + + def test_pulse_triggers_on_upgrade_to_danger(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP") + widget._pulse_timer.start.reset_mock() + + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert widget._pulse_phase == 1.0 + widget._pulse_timer.start.assert_called_once() + + def test_pulse_triggers_on_first_critical(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.CRITICAL, "Jita") + + assert widget._pulse_phase == 1.0 + widget._pulse_timer.start.assert_called_once() + + def test_pulse_does_not_trigger_on_warning(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "Jita") + + assert widget._pulse_phase == 0.0 + widget._pulse_timer.start.assert_not_called() + + def test_pulse_does_not_re_trigger_on_same_level(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget._pulse_timer.start.reset_mock() + widget._pulse_phase = 0.0 # pulse already finished + + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + widget._pulse_timer.start.assert_not_called() + + def test_decay_tick_reduces_alpha(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.WARNING + widget._threat_alpha = 1.0 + + widget._tick_threat_decay() + + assert widget._threat_alpha < 1.0 + assert widget._threat_alpha > 0.0 + widget.update.assert_called() + + def test_decay_clears_state_at_zero(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.WARNING + widget._threat_system = "HED-GP" + widget._threat_decay_steps = 2 # tighten so 2 ticks fully drains + widget._threat_alpha = 1.0 / widget._threat_decay_steps # near zero + + widget._tick_threat_decay() + + assert widget._threat_alpha == 0.0 + assert widget._threat_level is None + assert widget._threat_system is None + widget._threat_decay_timer.stop.assert_called() + + def test_decay_tick_at_zero_stops_timer(self): + widget = _make_widget_for_threat_tests() + widget._threat_alpha = 0.0 + + widget._tick_threat_decay() + + widget._threat_decay_timer.stop.assert_called_once() + + def test_pulse_tick_decrements_phase(self): + widget = _make_widget_for_threat_tests() + widget._pulse_phase = 1.0 + + widget._tick_pulse() + + assert widget._pulse_phase < 1.0 + widget.update.assert_called() + + def test_pulse_tick_at_zero_stops_timer(self): + widget = _make_widget_for_threat_tests() + widget._pulse_steps = 2 + widget._pulse_phase = 1.0 / widget._pulse_steps # near zero + + widget._tick_pulse() + + assert widget._pulse_phase == 0.0 + widget._pulse_timer.stop.assert_called_once() + + def test_flash_border_sets_color_and_starts_timer(self): + from PySide6.QtGui import QColor + + widget = _make_widget_for_threat_tests() + widget.flash_border("#FF0000", 2000) + + assert isinstance(widget._flash_color, QColor) + widget._flash_timer.start.assert_called_once_with(2000) + widget.update.assert_called() + + def test_flash_border_with_negative_duration_clamped_to_zero(self): + widget = _make_widget_for_threat_tests() + widget.flash_border("#FF0000", -100) + + widget._flash_timer.start.assert_called_once_with(0) + + def test_clear_flash_resets_color(self): + from PySide6.QtGui import QColor + + widget = _make_widget_for_threat_tests() + widget._flash_color = QColor("#FF0000") + + widget._clear_flash() + + assert widget._flash_color is None + widget.update.assert_called() + + +class TestWindowManagerApplyThreatState: + """Tests for WindowManager.apply_threat_state fan-out.""" + + def test_apply_threat_state_fans_out_to_all_frames(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + f1, f2, f3 = MagicMock(), MagicMock(), MagicMock() + manager.preview_frames = {"w1": f1, "w2": f2, "w3": f3} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 3 + # PR6: smart-filter branch passes initial_alpha=1.0 explicitly when + # the per-character system is unknown (graceful fallback). + # PR9: also passes distance=None for the +Nj badge surface. + f1.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + f2.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + f3.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + + def test_apply_threat_state_empty_frames(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "Jita") + + assert count == 0 + + def test_apply_threat_state_skips_deleted_widgets(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + ok = MagicMock() + dead = MagicMock() + dead.set_threat_state.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": ok, "dead": dead} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "Jita") + + assert count == 1 + ok.set_threat_state.assert_called_once() + + def test_apply_threat_state_clear_propagates(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + f1 = MagicMock() + manager.preview_frames = {"w1": f1} + + manager.apply_threat_state(ThreatLevel.CLEAR, None) + + f1.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, None) + + +class TestWindowPreviewWidgetPaintWithThreat: + """Smoke tests that paintEvent runs without crashing across threat states.""" + + def _build_real_widget(self): + from argus_overview.ui.main_tab import WindowPreviewWidget + + capture_system = MagicMock() + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=capture_system, + ) + widget.resize(200, 150) + return widget + + def test_paint_event_no_threat_does_not_crash(self): + widget = self._build_real_widget() + try: + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_event_with_threat_does_not_crash(self): + from argus_overview.intel.parser import ThreatLevel + + widget = self._build_real_widget() + try: + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_event_with_flash_does_not_crash(self): + widget = self._build_real_widget() + try: + widget.flash_border("#FF0000", 1000) + widget.repaint() + finally: + widget.deleteLater() + + +class TestMainWindowV21IntelAlertThreatFanout: + """Wiring test: _on_intel_alert routes VISUAL_BORDER alerts to fan-out.""" + + def test_visual_border_alert_calls_apply_threat_state(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() # avoid critical-tray branch firing extra calls + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=3, + ship_types=["sabre"], + player_names=[], + raw_message="hostiles HED-GP +3", + ) + + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + + window.main_tab.window_manager.apply_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP" + ) + + def test_non_border_alert_does_not_fan_out(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.WARNING, + hostile_count=1, + ship_types=[], + player_names=[], + raw_message="neut HED-GP", + ) + + window._on_intel_alert(report, AlertType.AUDIO) + + window.main_tab.window_manager.apply_threat_state.assert_not_called() + + def test_non_intel_report_ignored(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + + window._on_intel_alert("not a report", AlertType.VISUAL_BORDER) + + window.main_tab.window_manager.apply_threat_state.assert_not_called() + + +# ============================================================================= +# Spotlight / Focus Mode Tests (PR3) +# ============================================================================= + + +class TestWindowPreviewWidgetSpotlight: + """Tests for set_spotlight() state transitions.""" + + def _make(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + return widget + + def test_set_spotlight_focused_grows(self, qapp): + widget = self._make(qapp) + try: + widget.set_spotlight("focused") + assert widget._spotlight_mode == "focused" + # Focused widget gets a larger min size than the default 200x150 + assert widget.minimumWidth() >= 360 + assert widget.opacity_effect.opacity() == 1.0 + finally: + widget.deleteLater() + + def test_set_spotlight_dimmed_drops_opacity(self, qapp): + widget = self._make(qapp) + try: + widget.set_spotlight("dimmed") + assert widget._spotlight_mode == "dimmed" + assert widget.opacity_effect.opacity() == 0.25 + finally: + widget.deleteLater() + + def test_set_spotlight_none_restores(self, qapp): + widget = self._make(qapp) + try: + normal_min = widget._normal_min_size + widget.set_spotlight("focused") + widget.set_spotlight(None) + assert widget._spotlight_mode is None + assert widget.minimumSize() == normal_min + assert widget.opacity_effect.opacity() == 1.0 + finally: + widget.deleteLater() + + def test_set_spotlight_invalid_raises(self, qapp): + import pytest + + widget = self._make(qapp) + try: + with pytest.raises(ValueError): + widget.set_spotlight("bogus") + finally: + widget.deleteLater() + + def test_double_click_emits_focus_requested(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + widget = self._make(qapp) + try: + received: list[str] = [] + widget.focus_requested.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonDblClick, + QPoint(50, 50), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + widget.mouseDoubleClickEvent(event) + assert received == ["0xABC"] + finally: + widget.deleteLater() + + +class TestMainTabFocusMode: + """Tests for MainTab focus mode controller.""" + + def _make_tab(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = None + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + return tab + + def test_enter_focus_mode_unknown_window_noop(self): + tab = self._make_tab() + tab.enter_focus_mode("missing") + assert tab._focus_window_id is None + + def test_enter_focus_mode_sets_state_and_fans_out(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + + tab.enter_focus_mode("w1") + + assert tab._focus_window_id == "w1" + f1.set_spotlight.assert_called_once_with("focused") + f2.set_spotlight.assert_called_once_with("dimmed") + + def test_exit_focus_mode_clears_all(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + tab._focus_window_id = "w1" + + tab.exit_focus_mode() + + assert tab._focus_window_id is None + f1.set_spotlight.assert_called_once_with(None) + f2.set_spotlight.assert_called_once_with(None) + + def test_exit_focus_mode_when_inactive_noop(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + + tab.exit_focus_mode() + + f1.set_spotlight.assert_not_called() + + def test_on_focus_requested_toggles_on(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + + tab._on_focus_requested("w1") + + assert tab._focus_window_id == "w1" + + def test_on_focus_requested_toggles_off_when_same_window(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + tab._focus_window_id = "w1" + + tab._on_focus_requested("w1") + + assert tab._focus_window_id is None + + def test_on_focus_requested_swaps_to_different_window(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + tab._focus_window_id = "w1" + + tab._on_focus_requested("w2") + + assert tab._focus_window_id == "w2" + # f1 should be dimmed now (used to be focused), f2 focused + f1.set_spotlight.assert_called_with("dimmed") + f2.set_spotlight.assert_called_with("focused") + + def test_apply_focus_state_skips_deleted_widgets(self): + tab = self._make_tab() + ok = MagicMock() + dead = MagicMock() + dead.set_spotlight.side_effect = RuntimeError("widget deleted") + tab.window_manager.preview_frames = {"alive": ok, "dead": dead} + tab._focus_window_id = "alive" + + tab._apply_focus_state() + + ok.set_spotlight.assert_called_with("focused") + + def test_is_focus_mode_active(self): + tab = self._make_tab() + assert tab.is_focus_mode_active() is False + tab._focus_window_id = "w1" + assert tab.is_focus_mode_active() is True + + def test_remove_focused_window_clears_focus(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = "w1" + tab.status_dock = MagicMock() + tab._update_status = MagicMock() + + # Build a frame mock with the disconnect surface and session_timer + frame = MagicMock() + wm = MagicMock() + wm.preview_frames = {"w1": frame} + tab.window_manager = wm + + tab._on_window_removed("w1") + + assert tab._focus_window_id is None + wm.remove_window.assert_called_once_with("w1") + # Disconnect calls happened + frame.window_activated.disconnect.assert_called() + frame.focus_requested.disconnect.assert_called() + + +class TestMainTabFocusEscapeKey: + """Tests that Escape exits focus mode without swallowing other keys.""" + + def test_escape_when_focused_exits(self): + from PySide6.QtCore import Qt + from PySide6.QtGui import QKeyEvent + + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = "w1" + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {"w1": MagicMock()} + + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier, + ) + tab.keyPressEvent(event) + + assert tab._focus_window_id is None + + def test_escape_when_not_focused_passes_through(self): + from PySide6.QtCore import Qt + from PySide6.QtGui import QKeyEvent + + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = None + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier, + ) + # Should not raise even though super().keyPressEvent reaches QWidget + # bypassed init — bypass keyPressEvent dispatch by mocking out super + # via a real QWidget call would crash, so we just assert the focus + # path didn't fire. + try: + tab.keyPressEvent(event) + except RuntimeError: + pass # Expected: super().keyPressEvent hits bypassed-init widget + assert tab._focus_window_id is None + + +# ============================================================================= +# Smart per-character threat fan-out (PR5) +# ============================================================================= + + +def _frame_mock(character_name: str): + """Mock preview frame exposing the character_name + set_threat_state surface.""" + f = MagicMock() + f.character_name = character_name + return f + + +class TestWindowPreviewWidgetCharacterSystem: + """Tests for the per-frame _character_system setter.""" + + def test_set_and_get(self): + from argus_overview.ui.main_tab import WindowPreviewWidget + + with patch.object(WindowPreviewWidget, "__init__", return_value=None): + widget = WindowPreviewWidget.__new__(WindowPreviewWidget) + widget._character_system = None + + widget.set_character_system("Jita") + assert widget.get_character_system() == "Jita" + + widget.set_character_system(None) + assert widget.get_character_system() is None + + +class TestWindowManagerSetCharacterSystemPushesToFrames: + """set_character_system updates the map AND pushes to matching frames.""" + + def _make_manager(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = {} + return manager + + def test_pushes_to_matching_frame_only(self): + manager = self._make_manager() + alice_frame = _frame_mock("Alice") + bob_frame = _frame_mock("Bob") + manager.preview_frames = {"w1": alice_frame, "w2": bob_frame} + + manager.set_character_system("Alice", "Jita") + + alice_frame.set_character_system.assert_called_once_with("Jita") + bob_frame.set_character_system.assert_not_called() + + def test_pushes_to_multiple_frames_for_same_character(self): + manager = self._make_manager() + f1 = _frame_mock("Alice") + f2 = _frame_mock("Alice") + manager.preview_frames = {"w1": f1, "w2": f2} + + manager.set_character_system("Alice", "Jita") + + f1.set_character_system.assert_called_once_with("Jita") + f2.set_character_system.assert_called_once_with("Jita") + + def test_clearing_character_pushes_none_to_frames(self): + manager = self._make_manager() + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + manager._character_systems = {"Alice": "Jita"} + + manager.set_character_system("Alice", None) + + alice.set_character_system.assert_called_once_with(None) + assert "Alice" not in manager._character_systems + + def test_skips_deleted_widgets(self): + manager = self._make_manager() + alive = _frame_mock("Alice") + dead = _frame_mock("Alice") + dead.set_character_system.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": alive, "dead": dead} + + # Should not raise even though one frame is gone. + manager.set_character_system("Alice", "Jita") + + alive.set_character_system.assert_called_once_with("Jita") + + +class TestWindowManagerApplyThreatStateSmartFanout: + """PR5 smart fan-out filter rules.""" + + def _make_manager(self, char_systems: dict[str, str] | None = None): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems or {}) + return manager + + def test_clear_flushes_all_regardless_of_system(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita", "Bob": "Amarr"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.CLEAR, "HED-GP") + + assert count == 2 + alice.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, "HED-GP") + bob.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, "HED-GP") + + def test_none_level_flushes_all(self): + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(None, "HED-GP") + + assert count == 1 + alice.set_threat_state.assert_called_once() + + def test_no_alert_system_falls_through_to_all(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.WARNING, None) + + assert count == 2 + + def test_empty_alert_system_falls_through_to_all(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "") + + assert count == 1 + + def test_only_matching_character_tints(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP", "Bob": "Amarr"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + bob.set_threat_state.assert_not_called() + + def test_unknown_character_falls_through_to_apply(self): + """Graceful upgrade: untracked characters still tint.""" + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") # Bob has no known system + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + alice.set_threat_state.assert_called_once() + bob.set_threat_state.assert_called_once() + + def test_no_per_char_data_fans_to_all_legacy_behavior(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={}) + f1 = _frame_mock("Alice") + f2 = _frame_mock("Bob") + manager.preview_frames = {"w1": f1, "w2": f2} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "HED-GP") + + assert count == 2 + + def test_skips_deleted_widgets(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP"}) + alive = _frame_mock("Alice") + dead = _frame_mock("Alice") + dead.set_threat_state.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": alive, "dead": dead} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + + +class TestStatusDockSmartFanout: + """PR5 smart fan-out applied to chips via their _system attribute.""" + + def test_only_matching_chips_tint(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "HED-GP") + dock.set_character_system("Bob", "Amarr") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + assert dock._chips["0x1"]._threat_level == ThreatLevel.DANGER + assert dock._chips["0x2"]._threat_level is None + finally: + dock.deleteLater() + + def test_unknown_chip_falls_through(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") # Bob has no system + dock.set_character_system("Alice", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + # Both tint: Alice matches, Bob unknown → graceful fallback. + assert count == 2 + finally: + dock.deleteLater() + + def test_clear_bypasses_filter(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "HED-GP") + dock.set_character_system("Bob", "Amarr") + # Tint both first + dock._chips["0x1"].set_threat_state(ThreatLevel.DANGER, "HED-GP") + dock._chips["0x2"].set_threat_state(ThreatLevel.WARNING, "Amarr") + + count = dock.set_threat_state(ThreatLevel.CLEAR, "HED-GP") + + assert count == 2 + for chip in dock._chips.values(): + assert chip._threat_level is None + finally: + dock.deleteLater() + + def test_no_system_fans_to_all(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "Amarr") + + count = dock.set_threat_state(ThreatLevel.WARNING, None) + assert count == 2 + finally: + dock.deleteLater() + + +# ============================================================================= +# Jumps-from threat fan-out (PR6) +# ============================================================================= + + +class TestWindowPreviewWidgetInitialAlpha: + """set_threat_state honors the initial_alpha param.""" + + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_default_alpha_is_one(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita") + assert widget._threat_alpha == 1.0 + finally: + widget.deleteLater() + + def test_explicit_alpha_applied(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=0.5) + assert widget._threat_alpha == 0.5 + finally: + widget.deleteLater() + + def test_alpha_clamped_to_unit_range(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=2.5) + assert widget._threat_alpha == 1.0 + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=-0.5) + assert widget._threat_alpha == 0.0 + finally: + widget.deleteLater() + + def test_pulse_skipped_for_low_alpha_alerts(self, qapp): + """Adjacent-system alerts (low alpha) should NOT pulse.""" + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "Jita", initial_alpha=0.5) + assert widget._pulse_phase == 0.0 + finally: + widget.deleteLater() + + def test_pulse_fires_for_full_alpha_danger(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "Jita", initial_alpha=1.0) + assert widget._pulse_phase == 1.0 + finally: + widget.deleteLater() + + +def _calc_mock(distances: dict[tuple[str, str], int | None]): + """Build a MagicMock JumpCalculator from a (from, to) -> distance map.""" + calc = MagicMock() + + def _distance(a, b): + return distances.get((a, b)) or distances.get((b, a)) + + calc.distance.side_effect = _distance + return calc + + +class TestWindowManagerJumpsFromFanout: + """WindowManager.apply_threat_state with jumps-from filter.""" + + def _make_manager( + self, + char_systems: dict[str, str], + max_jumps: int = 0, + distances: dict[tuple[str, str], int | None] | None = None, + ): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems) + manager._jump_calculator = _calc_mock(distances or {}) if distances else None + manager._jump_max = max_jumps + return manager + + def test_set_jump_calculator_stores_calculator_and_max(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + calc = MagicMock() + + manager.set_jump_calculator(calc, max_jumps=3) + + assert manager._jump_calculator is calc + assert manager._jump_max == 3 + + def test_set_jump_calculator_clamps_negative_max(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.set_jump_calculator(MagicMock(), max_jumps=-5) + + assert manager._jump_max == 0 + + def test_adjacent_character_tinted_with_falloff_alpha(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager( + char_systems={"Alice": "Jita", "Bob": "HED-GP"}, + max_jumps=2, + distances={("Jita", "HED-GP"): 1, ("HED-GP", "HED-GP"): 0}, + ) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + bob.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=1 + ) + + def test_beyond_threshold_skipped(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager( + char_systems={"Alice": "Jita"}, + max_jumps=1, + distances={("Jita", "HED-GP"): 4}, + ) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 0 + alice.set_threat_state.assert_not_called() + + def test_no_calculator_keeps_pr5_exact_match_only(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita", "Bob": "HED-GP"}, max_jumps=0) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + bob.set_threat_state.assert_called_once() + alice.set_threat_state.assert_not_called() + + +class TestStatusDockJumpsFromFanout: + """StatusDock.set_threat_state with jumps-from filter.""" + + def test_set_jump_calculator_stores_calculator_and_max(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = MagicMock() + dock.set_jump_calculator(calc, max_jumps=2) + assert dock._jump_calculator is calc + assert dock._jump_max == 2 + finally: + dock.deleteLater() + + def test_adjacent_chip_tinted_with_falloff_alpha(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = _calc_mock({("Jita", "HED-GP"): 1}) + dock.set_jump_calculator(calc, max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + assert dock._chips["0x2"]._threat_alpha == 1.0 + assert dock._chips["0x1"]._threat_alpha == 0.5 + finally: + dock.deleteLater() + + def test_beyond_threshold_chip_skipped(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = _calc_mock({("Jita", "HED-GP"): 5}) + dock.set_jump_calculator(calc, max_jumps=1) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 0 + assert dock._chips["0x1"]._threat_level is None + finally: + dock.deleteLater() + + def test_no_calculator_keeps_pr5_behavior(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + assert dock._chips["0x2"]._threat_level is not None + assert dock._chips["0x1"]._threat_level is None + finally: + dock.deleteLater() + + +# ============================================================================= +# Per-character accent border (PR8) +# ============================================================================= + + +class TestCharacterAccentColor: + """Shared accent palette helper used by frames + chips.""" + + def test_deterministic(self): + from argus_overview.ui.main_tab import character_accent_color + + assert character_accent_color("Pilot1").rgb() == character_accent_color("Pilot1").rgb() + + def test_returns_qcolor(self): + from PySide6.QtGui import QColor + + from argus_overview.ui.main_tab import character_accent_color + + assert isinstance(character_accent_color("X"), QColor) + + def test_palette_has_eight_entries(self): + from argus_overview.ui.main_tab import CHARACTER_ACCENT_COLORS + + assert len(CHARACTER_ACCENT_COLORS) == 8 + + +class TestCharacterAccentChipFrameMatch: + """Same character → same color across the frame and the chip.""" + + def test_frame_and_chip_share_accent(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + from argus_overview.ui.status_dock import CharacterChip + + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + chip = CharacterChip("0xABC", "TestPilot") + try: + assert widget._accent_color.rgb() == chip._accent.rgb() + finally: + widget.deleteLater() + chip.deleteLater() + + def test_legacy_chip_aliases_resolve_to_main_tab_helpers(self): + from argus_overview.ui.main_tab import ( + CHARACTER_ACCENT_COLORS, + character_accent_color, + ) + from argus_overview.ui.status_dock import CHIP_ACCENT_COLORS, accent_for + + assert CHIP_ACCENT_COLORS is CHARACTER_ACCENT_COLORS + assert accent_for is character_accent_color + + +class TestWindowPreviewAccentBorder: + """Frame paints the accent border only when no threat is active.""" + + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_accent_color_set_on_init(self, qapp): + from PySide6.QtGui import QColor + + widget = self._make_widget(qapp) + try: + assert isinstance(widget._accent_color, QColor) + finally: + widget.deleteLater() + + def test_paint_clear_state_does_not_crash(self, qapp): + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + # No threat, no flash → accent border should paint + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_with_threat_does_not_crash(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_with_flash_does_not_crash(self, qapp): + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.flash_border("#FF0000", 1000) + widget.repaint() + finally: + widget.deleteLater() + + +# ============================================================================= +# Frame distance badge (PR9 — symmetrize PR7 across grid + dock) +# ============================================================================= + + +class TestWindowPreviewWidgetDistanceBadge: + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_default_distance_is_none(self, qapp): + widget = self._make_widget(qapp) + try: + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_distance_stored_when_positive(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + assert widget._threat_distance == 2 + finally: + widget.deleteLater() + + def test_zero_distance_treated_as_none(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=0) + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_clear_resets_distance(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + widget.set_threat_state(ThreatLevel.CLEAR) + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_decay_to_zero_clears_distance(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + # Tighten decay so a single tick fully drains. + widget._threat_decay_steps = 1 + widget._threat_alpha = 0.5 # ensure > 0 so first branch runs + widget._tick_threat_decay() + assert widget._threat_distance is None + assert widget._threat_level is None + finally: + widget.deleteLater() + + def test_paint_with_distance_does_not_crash(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=1) + widget.repaint() + finally: + widget.deleteLater() + + +def _frame_mock_v2(character_name: str): + """Mock frame for WindowManager tests — preserves character_name.""" + f = MagicMock() + f.character_name = character_name + return f + + +class TestWindowManagerPassesDistanceToFrame: + def _make_calc(self, distance: int | None): + calc = MagicMock() + calc.distance.return_value = distance + return calc + + def _make_manager(self, char_systems=None, max_jumps=0, calc=None): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems or {}) + manager._jump_calculator = calc + manager._jump_max = max_jumps + return manager + + def test_adjacent_frame_receives_distance(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=1) + manager = self._make_manager(char_systems={"Alice": "Jita"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=1 + ) + + def test_same_system_frame_distance_is_none(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=0) + manager = self._make_manager(char_systems={"Alice": "HED-GP"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + calc.distance.assert_not_called() + + def test_unknown_character_distance_is_none(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=99) + manager = self._make_manager(char_systems={}, max_jumps=2, calc=calc) + anon = _frame_mock_v2("Bob") + manager.preview_frames = {"w1": anon} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + anon.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + + def test_calculator_error_swallowed(self): + from argus_overview.intel.parser import ThreatLevel + + calc = MagicMock() + # First call (inside resolve_tint) returns a usable distance, second + # call (PR9 explicit query for the badge) raises. + calc.distance.side_effect = [1, ValueError("graph corrupt")] + manager = self._make_manager(char_systems={"Alice": "Jita"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=None + ) + + +# ============================================================================= +# Replay strip integration on WindowPreviewWidget (PR10) +# ============================================================================= + + +def _replay_widget(qapp, settings_manager=None): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="ReplayPilot", + capture_system=MagicMock(), + settings_manager=settings_manager, + ) + + +def _fake_pixmap(color=Qt.GlobalColor.red): + from PySide6.QtGui import QPixmap + + pm = QPixmap(200, 150) + pm.fill(color) + return pm + + +class TestWindowPreviewReplayBuffer: + def test_buffer_starts_empty(self, qapp): + widget = _replay_widget(qapp) + try: + assert len(widget._replay_buffer) == 0 + finally: + widget.deleteLater() + + def test_sample_appends_one_frame(self, qapp): + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == 1 + finally: + widget.deleteLater() + + def test_sample_throttled(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(_fake_pixmap()) + # Pretend no time has passed by leaving _replay_last_sample_ms as set + widget._sample_replay_buffer(_fake_pixmap()) + # Throttled — still one + assert len(widget._replay_buffer) == 1 + + # Advance past the throttle window + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == 2 + finally: + widget.deleteLater() + + def test_buffer_respects_maxlen(self, qapp): + from argus_overview.ui.main_tab import REPLAY_BUFFER_SIZE, REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(REPLAY_BUFFER_SIZE * 2): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == REPLAY_BUFFER_SIZE + finally: + widget.deleteLater() + + def test_null_pixmap_skipped(self, qapp): + from PySide6.QtGui import QPixmap + + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(QPixmap()) # null + assert len(widget._replay_buffer) == 0 + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayStripToggle: + def test_disabled_by_default(self, qapp): + widget = _replay_widget(qapp) + try: + assert widget.is_replay_strip_enabled() is False + assert widget._replay_strip is None + finally: + widget.deleteLater() + + def test_enable_creates_strip(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + assert widget.is_replay_strip_enabled() is True + assert widget._replay_strip is not None + finally: + widget.deleteLater() + + def test_disable_destroys_strip(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget.enable_replay_strip(False) + assert widget.is_replay_strip_enabled() is False + assert widget._replay_strip is None + finally: + widget.deleteLater() + + def test_enable_idempotent(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + strip_obj = widget._replay_strip + widget.enable_replay_strip(True) + # Same instance — second call is a no-op. + assert widget._replay_strip is strip_obj + finally: + widget.deleteLater() + + def test_disable_clears_held_view_index(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget._replay_view_index = 2 + widget.enable_replay_strip(False) + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + def test_enable_pushes_existing_buffer_to_strip(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(3): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.enable_replay_strip(True) + assert widget._replay_strip.frame_count() == 3 + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayHoverSwap: + def test_hover_index_swaps_image_label(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(3): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.enable_replay_strip(True) + widget._on_replay_frame_hovered(1) + + assert widget._replay_view_index == 1 + assert widget.image_label.pixmap() is not None + finally: + widget.deleteLater() + + def test_minus_one_restores_live(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(2): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.current_pixmap = _fake_pixmap(color=Qt.GlobalColor.green) + widget.enable_replay_strip(True) + widget._on_replay_frame_hovered(0) + widget._on_replay_frame_hovered(-1) + + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + def test_out_of_range_index_treated_as_minus_one(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget._replay_view_index = 0 + widget._on_replay_frame_hovered(99) + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayPersistence: + def _settings_mock(self, replay_store): + """SettingsManager mock that returns differentiated values per key.""" + sm = MagicMock() + sm.get.side_effect = lambda key, default=None: ( + replay_store if key == "replay_strip_enabled" else default + ) + return sm + + def test_toggle_persists_to_settings(self, qapp): + sm = self._settings_mock({}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + widget._toggle_replay_strip() # off → on + # set was called with the toggled-on dict + calls = sm.set.call_args_list + last = calls[-1] + assert last.args[0] == "replay_strip_enabled" + assert last.args[1] == {"ReplayPilot": True} + finally: + widget.deleteLater() + + def test_toggle_off_removes_entry(self, qapp): + sm = self._settings_mock({"ReplayPilot": True}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + # Init turned strip on from settings; toggle should turn it off + assert widget.is_replay_strip_enabled() is True + widget._toggle_replay_strip() + calls = sm.set.call_args_list + last = calls[-1] + assert last.args[1] == {} # entry removed + finally: + widget.deleteLater() + + def test_init_restores_strip_from_settings(self, qapp): + sm = self._settings_mock({"ReplayPilot": True}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + assert widget.is_replay_strip_enabled() is True + finally: + widget.deleteLater() diff --git a/tests/test_main_window_v21.py b/tests/test_main_window_v21.py index f155058..b311903 100644 --- a/tests/test_main_window_v21.py +++ b/tests/test_main_window_v21.py @@ -2335,6 +2335,7 @@ def test_init_creates_core_modules(self): 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")) diff --git a/tests/test_replay_strip.py b/tests/test_replay_strip.py new file mode 100644 index 0000000..74e5e2a --- /dev/null +++ b/tests/test_replay_strip.py @@ -0,0 +1,184 @@ +"""Tests for ReplayStrip child widget (PR10).""" + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QMouseEvent, QPixmap + +from argus_overview.ui.replay_strip import ReplayStrip + + +def _pixmap(w: int = 50, h: int = 30, color: str = "#ff0000") -> QPixmap: + pm = QPixmap(w, h) + pm.fill(Qt.GlobalColor.red) + return pm + + +def _move_event(x: int, y: int) -> QMouseEvent: + return QMouseEvent( + QMouseEvent.Type.MouseMove, + QPointF(float(x), float(y)), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + +class TestReplayStripInit: + def test_default_state_empty(self, qapp): + strip = ReplayStrip() + try: + assert strip.frame_count() == 0 + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + def test_fixed_height(self, qapp): + strip = ReplayStrip() + try: + assert strip.height() == ReplayStrip.STRIP_HEIGHT + finally: + strip.deleteLater() + + +class TestReplayStripSetFrames: + def test_set_frames_stores_count(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + assert strip.frame_count() == 3 + finally: + strip.deleteLater() + + def test_set_frames_replaces_previous(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.set_frames([_pixmap()]) + assert strip.frame_count() == 1 + finally: + strip.deleteLater() + + def test_set_frames_clamps_stale_hover(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + # Force hover_index to position 2 by mouse-moving there. + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + x = ReplayStrip.CELL_PADDING + 2 * cell_w + 1 + strip.mouseMoveEvent(_move_event(x, 10)) + assert strip.hover_index() == 2 + + # Shrink the frame list — the stale hover should clamp to -1. + strip.set_frames([_pixmap()]) + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + +class TestReplayStripHover: + def test_mouse_move_emits_index(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + # Hover middle cell + x = ReplayStrip.CELL_PADDING + cell_w + cell_w // 2 + strip.mouseMoveEvent(_move_event(x, 10)) + + assert received == [1] + assert strip.hover_index() == 1 + finally: + strip.deleteLater() + + def test_mouse_move_outside_after_inside_emits_minus_one(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + + # First land on cell 0 so the hover index actually changes. + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + assert strip.hover_index() == 0 + + # Now connect listener and move past the right edge. + received: list[int] = [] + strip.frame_hovered.connect(received.append) + strip.mouseMoveEvent(_move_event(500, 10)) + + assert received == [-1] + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + def test_mouse_move_dedupes_same_index(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + x1 = ReplayStrip.CELL_PADDING + cell_w // 2 + x2 = ReplayStrip.CELL_PADDING + cell_w // 4 + strip.mouseMoveEvent(_move_event(x1, 10)) + strip.mouseMoveEvent(_move_event(x2, 10)) + + # Both x are inside cell 0 → only one emission + assert received == [0] + finally: + strip.deleteLater() + + def test_leave_event_emits_minus_one(self, qapp): + from PySide6.QtCore import QEvent + + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + strip.leaveEvent(QEvent(QEvent.Type.Leave)) + + assert received == [-1] + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + +class TestReplayStripPaint: + def test_paint_with_frames_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + strip.repaint() + finally: + strip.deleteLater() + + def test_paint_empty_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + strip.repaint() + finally: + strip.deleteLater() + + def test_paint_with_hover_highlight_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + strip.repaint() + finally: + strip.deleteLater() diff --git a/tests/test_status_dock.py b/tests/test_status_dock.py new file mode 100644 index 0000000..9e3cb70 --- /dev/null +++ b/tests/test_status_dock.py @@ -0,0 +1,565 @@ +"""Tests for the character status dock (PR2).""" + +from unittest.mock import MagicMock + +from argus_overview.intel.parser import ThreatLevel +from argus_overview.ui.status_dock import ( + CharacterChip, + StatusDock, + _initials, + accent_for, +) + +# ============================================================================= +# Helpers +# ============================================================================= + + +class TestInitials: + def test_single_word(self): + assert _initials("Solo") == "SO" + + def test_two_words(self): + assert _initials("Foo Bar") == "FB" + + def test_underscores(self): + assert _initials("foo_bar") == "FB" + + def test_empty(self): + assert _initials("") == "?" + + def test_three_words_uses_first_and_last(self): + assert _initials("Alice Beta Gamma") == "AG" + + +class TestAccentFor: + def test_deterministic(self): + assert accent_for("Pilot1").rgb() == accent_for("Pilot1").rgb() + + def test_different_names_likely_differ(self): + # Not guaranteed but extremely probable across the small palette + names = ["A", "B", "C", "D", "E", "F", "G", "H"] + seen = {accent_for(n).rgb() for n in names} + # At least 3 different accents in 8 names is a generous lower bound + assert len(seen) >= 3 + + +# ============================================================================= +# CharacterChip +# ============================================================================= + + +class TestCharacterChip: + def test_init_sets_basic_state(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + assert chip.window_id == "0xABC" + assert chip.character_name == "TestPilot" + assert chip._system is None + assert chip._threat_level is None + finally: + chip.deleteLater() + + def test_set_system_updates_label(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + assert chip._system == "HED-GP" + assert chip._system_label.text() == "HED-GP" + finally: + chip.deleteLater() + + def test_set_system_none_uses_dash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_system(None) + assert chip._system_label.text() == "—" + finally: + chip.deleteLater() + + def test_set_threat_state_stores_level(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP") + assert chip._threat_level == ThreatLevel.WARNING + assert chip._threat_alpha == 1.0 + assert chip._system == "HED-GP" + finally: + chip.deleteLater() + + def test_set_threat_state_clear_resets(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.DANGER, "Jita") + chip.set_threat_state(ThreatLevel.CLEAR) + assert chip._threat_level is None + assert chip._threat_alpha == 0.0 + finally: + chip.deleteLater() + + def test_set_threat_state_none_resets(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.DANGER, "Jita") + chip.set_threat_state(None) + assert chip._threat_level is None + finally: + chip.deleteLater() + + def test_set_threat_state_alpha_clamped(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=2.5) + assert chip._threat_alpha == 1.0 + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=-0.5) + assert chip._threat_alpha == 0.0 + finally: + chip.deleteLater() + + def test_click_emits_window_id(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + chip = CharacterChip("0xABC", "TestPilot") + try: + received: list[str] = [] + chip.clicked.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + QPoint(5, 5), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + chip.mousePressEvent(event) + assert received == ["0xABC"] + finally: + chip.deleteLater() + + def test_right_click_does_not_emit(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + chip = CharacterChip("0xABC", "TestPilot") + try: + received: list[str] = [] + chip.clicked.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + QPoint(5, 5), + Qt.MouseButton.RightButton, + Qt.MouseButton.RightButton, + Qt.KeyboardModifier.NoModifier, + ) + chip.mousePressEvent(event) + assert received == [] + finally: + chip.deleteLater() + + def test_paint_event_no_threat_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.repaint() + finally: + chip.deleteLater() + + def test_paint_event_with_threat_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.CRITICAL, "Jita") + chip.repaint() + finally: + chip.deleteLater() + + def test_tooltip_includes_system_and_threat(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP") + tip = chip.toolTip() + assert "TestPilot" in tip + assert "HED-GP" in tip + assert "warning" in tip + finally: + chip.deleteLater() + + +# ============================================================================= +# StatusDock +# ============================================================================= + + +class TestStatusDock: + def test_init_empty(self, qapp): + dock = StatusDock() + try: + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + def test_add_chip(self, qapp): + dock = StatusDock() + try: + chip = dock.add_chip("0x1", "Alice") + assert chip is not None + assert dock.chip_count() == 1 + assert dock.has_chip("0x1") + finally: + dock.deleteLater() + + def test_add_chip_duplicate_returns_none(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dup = dock.add_chip("0x1", "Alice") + assert dup is None + assert dock.chip_count() == 1 + finally: + dock.deleteLater() + + def test_remove_chip(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + assert dock.remove_chip("0x1") is True + assert dock.chip_count() == 1 + assert not dock.has_chip("0x1") + assert dock.has_chip("0x2") + finally: + dock.deleteLater() + + def test_remove_chip_missing_returns_false(self, qapp): + dock = StatusDock() + try: + assert dock.remove_chip("nope") is False + finally: + dock.deleteLater() + + def test_clear_removes_all(self, qapp): + dock = StatusDock() + try: + for i in range(5): + dock.add_chip(f"0x{i}", f"Pilot{i}") + dock.clear() + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + def test_set_threat_state_fans_to_all_chips(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + assert count == 2 + for chip in dock._chips.values(): + assert chip._threat_level == ThreatLevel.DANGER + assert chip._system == "HED-GP" + finally: + dock.deleteLater() + + def test_set_threat_state_clear(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.set_threat_state(ThreatLevel.DANGER, "Jita") + dock.set_threat_state(ThreatLevel.CLEAR) + for chip in dock._chips.values(): + assert chip._threat_level is None + finally: + dock.deleteLater() + + def test_set_chip_system_updates_one_chip(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + assert dock.set_chip_system("0x1", "HED-GP") is True + assert dock._chips["0x1"]._system == "HED-GP" + assert dock._chips["0x2"]._system is None + finally: + dock.deleteLater() + + def test_set_chip_system_unknown_returns_false(self, qapp): + dock = StatusDock() + try: + assert dock.set_chip_system("nope", "Jita") is False + finally: + dock.deleteLater() + + def test_chip_clicked_signal_propagates(self, qapp): + dock = StatusDock() + try: + received: list[str] = [] + dock.chip_clicked.connect(received.append) + chip = dock.add_chip("0xABC", "Alice") + chip.clicked.emit("0xABC") + assert received == ["0xABC"] + finally: + dock.deleteLater() + + def test_sync_from_window_ids_adds_and_removes(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + added, removed = dock.sync_from_window_ids({"0x2": "Bob", "0x3": "Carol"}) + assert added == ["0x3"] + assert removed == ["0x1"] + assert dock.has_chip("0x2") and dock.has_chip("0x3") + assert not dock.has_chip("0x1") + finally: + dock.deleteLater() + + def test_sync_from_window_ids_empty_clears(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + added, removed = dock.sync_from_window_ids({}) + assert added == [] + assert removed == ["0x1"] + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + +# ============================================================================= +# MainWindowV21 wiring — dock receives threat fan-out +# ============================================================================= + + +class TestMainWindowV21StatusDockFanout: + def test_visual_border_alert_calls_dock_set_threat_state(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=2, + ship_types=[], + player_names=[], + raw_message="hostiles HED-GP", + ) + + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + + window.main_tab.status_dock.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP" + ) + + def test_audio_alert_does_not_touch_dock(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.WARNING, + hostile_count=1, + ship_types=[], + player_names=[], + raw_message="neut", + ) + + window._on_intel_alert(report, AlertType.AUDIO) + + window.main_tab.status_dock.set_threat_state.assert_not_called() + + def test_dock_optional_does_not_crash_when_missing(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock(spec=["window_manager"]) + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=2, + ship_types=[], + player_names=[], + raw_message="hostiles", + ) + + # Should not raise even though main_tab has no status_dock attr + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + window.main_tab.window_manager.apply_threat_state.assert_called_once() + + +# ============================================================================= +# Distance badge (PR7) +# ============================================================================= + + +class TestCharacterChipDistanceBadge: + """+Nj badge state on the chip.""" + + def test_default_distance_is_none(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_distance_stored_when_positive(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + assert chip._threat_distance == 2 + finally: + chip.deleteLater() + + def test_zero_distance_treated_as_none(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + # Same-system case: caller passes distance=0; chip stores None + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0, distance=0) + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_clear_resets_distance(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + chip.set_threat_state(ThreatLevel.CLEAR) + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_paint_with_distance_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=1) + chip.repaint() + finally: + chip.deleteLater() + + def test_paint_with_no_distance_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0) + chip.repaint() + finally: + chip.deleteLater() + + def test_tooltip_includes_distance(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("Jita") + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + tip = chip.toolTip() + assert "2j away" in tip + finally: + chip.deleteLater() + + def test_tooltip_omits_distance_for_same_system(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0) + tip = chip.toolTip() + assert "j away" not in tip + finally: + chip.deleteLater() + + +class TestStatusDockPassesDistanceToChip: + """Dock fan-out queries calculator.distance and passes it to chips.""" + + def _make_calc(self, distance: int | None): + calc = MagicMock() + calc.distance.return_value = distance + return calc + + def test_adjacent_chip_receives_distance(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=1), max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance == 1 + finally: + dock.deleteLater() + + def test_same_system_chip_has_no_distance(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=0), max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "HED-GP") + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance is None + finally: + dock.deleteLater() + + def test_unknown_chip_system_has_no_distance(self, qapp): + """Graceful fallback: unknown chip tints at full alpha, no badge.""" + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=99), max_jumps=2) + dock.add_chip("0x1", "Alice") + # No set_character_system → chip._system stays None + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance is None + # Calculator should not have been queried at all (alpha=1.0 path) + finally: + dock.deleteLater() + + def test_calculator_error_swallowed(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = MagicMock() + calc.distance.side_effect = ValueError("graph corrupt") + dock.set_jump_calculator(calc, max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + # Force smart-filter branch by stubbing resolve_tint? No — just + # rely on real resolve_tint: it would call calculator.distance + # too. Either way, the dock should not raise. + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + # Chip threat state may or may not be set depending on which + # call raised, but the dock must not have crashed. + assert "0x1" in dock._chips + finally: + dock.deleteLater() diff --git a/tests/test_threat_filter.py b/tests/test_threat_filter.py new file mode 100644 index 0000000..9329b9e --- /dev/null +++ b/tests/test_threat_filter.py @@ -0,0 +1,111 @@ +"""Tests for intel/threat_filter.resolve_tint helper (PR6).""" + +from unittest.mock import MagicMock + +from argus_overview.intel.threat_filter import resolve_tint + + +class TestResolveTintExplicitFlush: + """Cases where the helper falls through to apply at full alpha.""" + + def test_unknown_character_falls_through_full(self): + should, alpha = resolve_tint(known_system=None, alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + def test_no_alert_system_falls_through_full(self): + should, alpha = resolve_tint(known_system="Jita", alert_system=None) + assert should is True + assert alpha == 1.0 + + def test_empty_alert_system_falls_through_full(self): + should, alpha = resolve_tint(known_system="Jita", alert_system="") + assert should is True + assert alpha == 1.0 + + +class TestResolveTintExactMatch: + def test_same_system_full_alpha(self): + should, alpha = resolve_tint(known_system="HED-GP", alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + def test_case_insensitive_match(self): + should, alpha = resolve_tint(known_system="hed-gp", alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + +class TestResolveTintAdjacency: + """Cases that need a JumpCalculator + non-zero max_jumps.""" + + def test_no_calculator_skips_mismatch(self): + should, alpha = resolve_tint( + known_system="Amarr", alert_system="HED-GP", jump_calculator=None + ) + assert should is False + assert alpha == 0.0 + + def test_zero_max_jumps_skips_mismatch_even_with_calculator(self): + calc = MagicMock() + calc.distance.return_value = 1 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=0, + ) + assert should is False + # Calculator should NOT have been queried — max_jumps=0 short-circuits + calc.distance.assert_not_called() + + def test_one_jump_within_threshold_uses_falloff_alpha(self): + calc = MagicMock() + calc.distance.return_value = 1 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is True + # 0.5 ** 1 = 0.5 (above floor 0.4) + assert alpha == 0.5 + + def test_two_jumps_within_threshold_uses_floor_alpha(self): + calc = MagicMock() + calc.distance.return_value = 2 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is True + # 0.5 ** 2 = 0.25 → clamped to 0.4 floor + assert alpha == 0.4 + + def test_distance_beyond_threshold_skipped(self): + calc = MagicMock() + calc.distance.return_value = 3 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is False + assert alpha == 0.0 + + def test_unknown_distance_skipped(self): + """JumpCalculator returns None when graph lookup fails.""" + calc = MagicMock() + calc.distance.return_value = None + should, alpha = resolve_tint( + known_system="Unknown", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=5, + ) + assert should is False + assert alpha == 0.0