Files
javis_bot/tests/test_face_widget.py
javis-bot c4abf63f38
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Transform isair/jarvis into a Discord-controlled voice assistant running on
the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact.

- bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral),
  voice channel join + voice receive/playback, pluggable VNC screen broadcast
  (selfbot live / noVNC / screenshot)
- bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS
  behind a thin localhost HTTP API
- .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite,
  docs/language-comparison.md and docs/vnc-xfce-setup.md

Language decision: hybrid (Python brain + Node/bun Discord layer) because
Discord blocks bot video; native screen broadcast only works via a Node
selfbot library.
2026-06-09 14:51:05 +09:00

398 lines
14 KiB
Python

"""
Tests for the FaceWindow positioning logic.
"""
import os
from unittest.mock import patch, MagicMock
import pytest
class TestFaceWindowPositioning:
"""Tests for FaceWindow positioning on the right side of screen."""
def test_positions_on_right_side_of_screen(self):
"""FaceWindow should position itself on the right side of the screen."""
# Mock screen geometry
mock_screen = MagicMock()
mock_screen.availableGeometry.return_value = MagicMock(
right=lambda: 1920,
top=lambda: 0,
height=lambda: 1080,
)
# Mock QApplication.primaryScreen
with patch(
"desktop_app.face_widget.QApplication.primaryScreen", return_value=mock_screen
):
# Import after patching to avoid needing actual display
from desktop_app.face_widget import FaceWindow
# Mock the parent class __init__ to avoid Qt initialization issues
with patch.object(FaceWindow, "__init__", lambda self, parent=None: None):
window = FaceWindow.__new__(FaceWindow)
window._width = 350
window._height = 450
# Mock width() and height() methods
window.width = lambda: 350
window.height = lambda: 450
# Track move calls
move_calls = []
window.move = lambda x, y: move_calls.append((x, y))
# Call the positioning method
window._position_on_right()
# Verify positioning
assert len(move_calls) == 1
x, y = move_calls[0]
# Should be on right side with 20px margin
# x = 1920 - 350 - 20 = 1550
assert x == 1550
# Should be vertically centered
# y = 0 + (1080 - 450) // 2 = 315
assert y == 315
def test_handles_none_screen_gracefully(self):
"""FaceWindow should handle missing screen gracefully."""
with patch(
"desktop_app.face_widget.QApplication.primaryScreen", return_value=None
):
from desktop_app.face_widget import FaceWindow
with patch.object(FaceWindow, "__init__", lambda self, parent=None: None):
window = FaceWindow.__new__(FaceWindow)
move_calls = []
window.move = lambda x, y: move_calls.append((x, y))
# Should not raise an exception
window._position_on_right()
# Should not move if no screen
assert len(move_calls) == 0
def test_adapts_to_different_screen_sizes(self):
"""FaceWindow should adapt to different screen sizes."""
test_cases = [
# (screen_right, screen_top, screen_height, expected_x, expected_y)
(1920, 0, 1080, 1550, 315), # Standard 1080p
(2560, 0, 1440, 2190, 495), # 1440p
(3840, 0, 2160, 3470, 855), # 4K
(1366, 0, 768, 996, 159), # Common laptop
]
window_width = 350
window_height = 450
margin = 20
for screen_right, screen_top, screen_height, expected_x, expected_y in test_cases:
mock_screen = MagicMock()
mock_screen.availableGeometry.return_value = MagicMock(
right=lambda r=screen_right: r,
top=lambda t=screen_top: t,
height=lambda h=screen_height: h,
)
with patch(
"desktop_app.face_widget.QApplication.primaryScreen",
return_value=mock_screen,
):
from desktop_app.face_widget import FaceWindow
with patch.object(
FaceWindow, "__init__", lambda self, parent=None: None
):
window = FaceWindow.__new__(FaceWindow)
window.width = lambda: window_width
window.height = lambda: window_height
move_calls = []
window.move = lambda x, y: move_calls.append((x, y))
window._position_on_right()
assert len(move_calls) == 1
x, y = move_calls[0]
assert x == expected_x, f"For screen {screen_right}x{screen_height}"
assert y == expected_y, f"For screen {screen_right}x{screen_height}"
class TestFaceWidgetImports:
"""Tests that daemon modules can import face_widget from the correct location.
These smoke tests catch broken imports after refactoring, which previously
failed silently due to try/except ImportError blocks in daemon code.
"""
@pytest.mark.unit
def test_face_widget_importable_from_desktop_app(self):
"""face_widget should be importable from desktop_app package."""
from desktop_app.face_widget import get_jarvis_state, JarvisState
assert get_jarvis_state is not None
assert JarvisState is not None
@pytest.mark.unit
def test_jarvis_state_enum_has_expected_values(self):
"""JarvisState enum should have all expected states."""
from desktop_app.face_widget import JarvisState
expected_states = ['ASLEEP', 'IDLE', 'LISTENING', 'THINKING', 'SPEAKING']
for state in expected_states:
assert hasattr(JarvisState, state), f"JarvisState missing {state}"
@pytest.mark.unit
def test_tts_module_face_widget_import(self):
"""TTS module's face_widget import should work.
This tests the actual import path used in jarvis/output/tts.py
"""
# Simulate the import done in tts.py
try:
from desktop_app.face_widget import get_jarvis_state, JarvisState
success = True
except ImportError:
success = False
assert success, "TTS module cannot import face_widget - check import path"
@pytest.mark.unit
def test_listener_module_face_widget_import(self):
"""Listener module's face_widget import should work.
This tests the actual import path used in jarvis/listening/listener.py
"""
try:
from desktop_app.face_widget import get_jarvis_state, JarvisState
success = True
except ImportError:
success = False
assert success, "Listener module cannot import face_widget - check import path"
@pytest.mark.unit
def test_state_manager_module_face_widget_import(self):
"""State manager module's face_widget import should work.
This tests the actual import path used in jarvis/listening/state_manager.py
"""
try:
from desktop_app.face_widget import get_jarvis_state, JarvisState
success = True
except ImportError:
success = False
assert success, "State manager cannot import face_widget - check import path"
@pytest.mark.unit
def test_reply_engine_module_face_widget_import(self):
"""Reply engine module's face_widget import should work.
This tests the actual import path used in jarvis/reply/engine.py
"""
try:
from desktop_app.face_widget import get_jarvis_state, JarvisState
success = True
except ImportError:
success = False
assert success, "Reply engine cannot import face_widget - check import path"
class _HeadlessStateManager:
"""Lightweight stand-in for JarvisStateManager that works without Qt.
Reproduces only the file-based state logic so tests can run on
headless CI where QObject cannot be instantiated.
"""
def __init__(self):
import threading
from desktop_app.face_widget import JarvisState, _get_jarvis_state_file
self._state = JarvisState.ASLEEP
self._state_lock = threading.Lock()
self._state_file = _get_jarvis_state_file()
self._write_state(JarvisState.ASLEEP)
@property
def state(self):
import os
from desktop_app.face_widget import JarvisState
try:
if os.path.exists(self._state_file):
with open(self._state_file, 'r') as f:
return JarvisState(f.read().strip())
except (ValueError, OSError):
pass
with self._state_lock:
return self._state
def set_state(self, state):
with self._state_lock:
self._state = state
self._write_state(state)
def _write_state(self, state):
try:
with open(self._state_file, 'w') as f:
f.write(state.value)
except OSError:
pass
class TestJarvisStateManager:
"""Tests for JarvisStateManager cross-process state sharing."""
@pytest.fixture(autouse=True)
def cleanup_state_file(self):
"""Clean up state file and singleton before/after each test.
Replaces get_jarvis_state with a headless factory so tests work
on CI without a running QApplication or display server.
"""
import tempfile
import os
from desktop_app import face_widget
state_file = os.path.join(tempfile.gettempdir(), "jarvis_state")
# Reset singleton before test
face_widget._jarvis_state_instance = None
# Clean up state file
if os.path.exists(state_file):
os.remove(state_file)
# Replace the singleton factory with one that returns a
# headless stand-in, avoiding QObject entirely.
_orig_get = face_widget.get_jarvis_state
def _headless_get():
if face_widget._jarvis_state_instance is None:
face_widget._jarvis_state_instance = _HeadlessStateManager()
return face_widget._jarvis_state_instance
face_widget.get_jarvis_state = _headless_get
yield
face_widget.get_jarvis_state = _orig_get
face_widget._jarvis_state_instance = None
# Clean up state file
if os.path.exists(state_file):
os.remove(state_file)
@pytest.mark.unit
def test_state_manager_creates_file_if_not_exists(self):
"""State manager should create state file if it doesn't exist."""
import tempfile
import os
from desktop_app import face_widget
from desktop_app.face_widget import JarvisState
state_file = os.path.join(tempfile.gettempdir(), "jarvis_state")
# File shouldn't exist before getting state manager
assert not os.path.exists(state_file)
# Get state manager (creates singleton) — must go through
# face_widget.get_jarvis_state() to pick up the fixture's patch.
sm = face_widget.get_jarvis_state()
# File should now exist
assert os.path.exists(state_file)
# Default state should be ASLEEP
assert sm.state == JarvisState.ASLEEP
@pytest.mark.unit
def test_state_manager_always_starts_asleep(self):
"""State manager should always start ASLEEP, ignoring stale file state.
The state file is for cross-process communication during a session,
not for persisting state across app restarts. A fresh launch should
always start in ASLEEP state.
"""
import tempfile
import os
from desktop_app import face_widget
from desktop_app.face_widget import JarvisState
state_file = os.path.join(tempfile.gettempdir(), "jarvis_state")
# Create file with SPEAKING state (leftover from previous session)
with open(state_file, 'w') as f:
f.write("speaking")
# Reset singleton to simulate a fresh app launch
face_widget._jarvis_state_instance = None
# Get state manager - should start ASLEEP, not read stale file
sm = face_widget.get_jarvis_state()
# State should be ASLEEP (fresh start), not SPEAKING (stale state)
assert sm.state == JarvisState.ASLEEP
@pytest.mark.unit
def test_state_manager_file_based_sharing(self):
"""State changes should persist to file for cross-process sharing.
During a session, the daemon (separate process) writes state to the file
and the desktop app reads it via the state property. But on fresh launch,
the state manager always resets to ASLEEP.
"""
import tempfile
import os
from desktop_app import face_widget
from desktop_app.face_widget import JarvisState
state_file = os.path.join(tempfile.gettempdir(), "jarvis_state")
# Get state manager and set state
sm = face_widget.get_jarvis_state()
sm.set_state(JarvisState.SPEAKING)
# Verify file contains correct state (for cross-process sharing)
with open(state_file, 'r') as f:
content = f.read().strip()
assert content == "speaking"
# Verify the same instance reads updated state from file
assert sm.state == JarvisState.SPEAKING
# Simulate external process updating state (daemon writes to file)
with open(state_file, 'w') as f:
f.write("thinking")
# Same instance should pick up change from file
assert sm.state == JarvisState.THINKING
@pytest.mark.unit
def test_state_manager_handles_invalid_file_content(self):
"""State manager should handle invalid file content gracefully."""
import tempfile
import os
from desktop_app import face_widget
from desktop_app.face_widget import JarvisState
state_file = os.path.join(tempfile.gettempdir(), "jarvis_state")
# Create file with invalid content
with open(state_file, 'w') as f:
f.write("invalid_state")
# Get state manager - should reinitialize with ASLEEP
sm = face_widget.get_jarvis_state()
# State should be ASLEEP (default) since file had invalid content
assert sm.state == JarvisState.ASLEEP
# File should be fixed
with open(state_file, 'r') as f:
content = f.read().strip()
assert content == "asleep"