Adds a Windows port of the host daemon so the Clawdmeter stays connected on Windows independent of WSL. Mirrors the macOS daemon and speaks the existing GATT data service unchanged — no firmware changes. - claude_usage_daemon_windows.py: Windows-local OAuth token read + Anthropic poll + BLE scan/connect/write, with auto-reconnect (connect-retry wrapper, zombie-link break, split fast/slow backoff) - tray_windows.py: pystray login-startup tray app (status icon + Quit) - autostart_windows.py: winreg HKCU\Run autostart via pythonw.exe - icon_assets.py: per-state tray icons composited from logo.h - install-windows.ps1 + daemon/README-windows.md: turnkey setup - pytest suite: token / poll / reconnect / tray / autostart / no-WSL guard
329 lines
13 KiB
Python
329 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for daemon/tray_windows.py — APP-01.
|
|
|
|
Covers:
|
|
TrayState scalar setters and initial state
|
|
header_text() for all three states including last_sync=None
|
|
daemon main() accepts tray_state and populates ts.loop / ts.stop_event
|
|
Quit routes through loop.call_soon_threadsafe (not stop_event.set directly)
|
|
Error toast fires only on transition INTO error state (D-04)
|
|
|
|
All pystray usage is inside tray_windows.main() (deferred import), so these
|
|
tests can import the pure helpers (TrayState, header_text) and test Quit/toast
|
|
handlers with mocked icons without importing the GTK-less top-level pystray.
|
|
|
|
Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
|
|
"""
|
|
import asyncio
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
|
|
|
import pytest
|
|
|
|
from daemon.tray_windows import TrayState, header_text, _acquire_single_instance, _ERROR_ALREADY_EXISTS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TrayState — initial state and setters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_tray_state_initial():
|
|
"""TrayState initialises to scanning state with no last_sync."""
|
|
ts = TrayState()
|
|
assert ts.state == "scanning"
|
|
assert ts.reason == ""
|
|
assert ts.last_sync is None
|
|
assert ts.loop is None
|
|
assert ts.stop_event is None
|
|
|
|
|
|
def test_set_connected():
|
|
"""set_connected(ts_float) sets state='connected', clears reason, records last_sync."""
|
|
ts = TrayState()
|
|
now = time.time()
|
|
ts.set_connected(now)
|
|
assert ts.state == "connected"
|
|
assert ts.reason == ""
|
|
assert ts.last_sync == now
|
|
|
|
|
|
def test_set_scanning():
|
|
"""set_scanning() sets state='scanning', clears reason."""
|
|
ts = TrayState()
|
|
ts.set_error("something bad") # put it in error first
|
|
ts.set_scanning()
|
|
assert ts.state == "scanning"
|
|
assert ts.reason == ""
|
|
|
|
|
|
def test_set_error():
|
|
"""set_error(why) sets state='error' and stores the reason string."""
|
|
ts = TrayState()
|
|
ts.set_error("token expired — run claude login")
|
|
assert ts.state == "error"
|
|
assert ts.reason == "token expired — run claude login"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# header_text — D-05 string shapes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_header_text_scanning():
|
|
"""header_text returns 'Scanning…' in scanning state."""
|
|
ts = TrayState()
|
|
ts.set_scanning()
|
|
assert header_text(ts) == "Scanning…"
|
|
|
|
|
|
def test_header_text_error():
|
|
"""header_text returns 'Error: {reason}' in error state."""
|
|
ts = TrayState()
|
|
ts.set_error("token expired — run claude login")
|
|
result = header_text(ts)
|
|
assert result == "Error: token expired — run claude login"
|
|
|
|
|
|
def test_header_text_connected_with_last_sync():
|
|
"""header_text returns 'Connected · last update HH:MM' when last_sync is set."""
|
|
ts = TrayState()
|
|
# Use a known timestamp so we can predict the HH:MM string.
|
|
known_ts = time.mktime(time.strptime("2026-06-01 14:32:00", "%Y-%m-%d %H:%M:%S"))
|
|
ts.set_connected(known_ts)
|
|
result = header_text(ts)
|
|
# Extract the HH:MM portion from the actual local time expansion.
|
|
expected_when = time.strftime("%H:%M", time.localtime(known_ts))
|
|
assert result == f"Connected · last update {expected_when}"
|
|
|
|
|
|
def test_header_text_connected_never_when_last_sync_none():
|
|
"""header_text returns 'Connected · last update never' when last_sync is None."""
|
|
ts = TrayState()
|
|
# Manually set state without using set_connected so last_sync stays None.
|
|
ts.state = "connected"
|
|
ts.last_sync = None
|
|
result = header_text(ts)
|
|
assert result == "Connected · last update never"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# daemon main() populates ts.loop and ts.stop_event
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_main_populates_tray_state_loop_and_stop_event():
|
|
"""daemon main(tray_state=ts) sets ts.loop and ts.stop_event before the loop body."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
ts = TrayState()
|
|
populated = {}
|
|
|
|
async def _fake_scan():
|
|
# Record the state of ts at first scan entry (after main() startup lines).
|
|
populated["loop"] = ts.loop
|
|
populated["stop_event"] = ts.stop_event
|
|
# Signal stop so the loop exits cleanly.
|
|
ts.stop_event.set()
|
|
return None # no device found
|
|
|
|
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
|
asyncio.run(mod.main(tray_state=ts))
|
|
|
|
assert populated.get("loop") is not None, "ts.loop must be set by daemon main()"
|
|
assert populated.get("stop_event") is not None, "ts.stop_event must be set by daemon main()"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quit handler routes through call_soon_threadsafe (not stop_event.set directly)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_quit_uses_call_soon_threadsafe():
|
|
"""The Quit menu handler calls loop.call_soon_threadsafe(stop_event.set) and icon.stop().
|
|
|
|
It must NOT call stop_event.set() directly from the tray thread
|
|
(RESEARCH Pitfall 2 / T-04-06 mitigation).
|
|
"""
|
|
# Build a TrayState with a mocked loop and stop_event.
|
|
ts = TrayState()
|
|
mock_loop = MagicMock()
|
|
mock_stop_event = MagicMock()
|
|
ts.loop = mock_loop
|
|
ts.stop_event = mock_stop_event
|
|
|
|
# Build the Quit handler the same way tray_windows.main() does, without
|
|
# importing pystray at the module level. We construct a local closure
|
|
# that mirrors the on_quit body.
|
|
mock_icon = MagicMock()
|
|
|
|
def _on_quit(icon_ref, _item):
|
|
# This is the exact body from tray_windows.main() — keep in sync.
|
|
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
|
icon_ref.stop()
|
|
|
|
_on_quit(mock_icon, None)
|
|
|
|
# call_soon_threadsafe must have been called with stop_event.set as the arg.
|
|
mock_loop.call_soon_threadsafe.assert_called_once_with(mock_stop_event.set)
|
|
# icon.stop() must have been called.
|
|
mock_icon.stop.assert_called_once()
|
|
# stop_event.set() must NOT have been called directly.
|
|
mock_stop_event.set.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error toast fires only on transition INTO error (D-04)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_error_toast_on_entry_only():
|
|
"""The tray refresh loop fires icon.notify() only on transition INTO error.
|
|
|
|
Sequence: scanning -> error -> error
|
|
Expected: notify called exactly once (on the scanning->error transition).
|
|
"""
|
|
ts = TrayState()
|
|
ts.set_scanning()
|
|
|
|
mock_icon = MagicMock()
|
|
mock_icon._running = True
|
|
|
|
# Simulate the _refresh loop's state-change detection logic from tray_windows.main().
|
|
# We run two transitions manually:
|
|
# 1. scanning -> error (should call notify once)
|
|
# 2. error -> error (no change — notify must NOT fire again)
|
|
prev_state: dict = {"state": None}
|
|
|
|
def _process_state_change(new_state: str, reason: str = "") -> None:
|
|
"""Mirror the relevant part of the _refresh loop body."""
|
|
ts.state = new_state
|
|
ts.reason = reason
|
|
current = ts.state
|
|
if current != prev_state["state"]:
|
|
if current == "error" and prev_state["state"] != "error":
|
|
mock_icon.notify(ts.reason or "Clawdmeter error", "Clawdmeter")
|
|
prev_state["state"] = current
|
|
|
|
# Transition 1: scanning -> error (notify should fire)
|
|
_process_state_change("scanning")
|
|
_process_state_change("error", "token expired — run claude login")
|
|
# Transition 2: error -> error (same state — no call)
|
|
_process_state_change("error", "token expired — run claude login")
|
|
|
|
mock_icon.notify.assert_called_once_with(
|
|
"token expired — run claude login", "Clawdmeter"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-instance guard (named mutex) — duplicate-launch / ARSO collision
|
|
# ---------------------------------------------------------------------------
|
|
# Field bug: Windows "restart apps after sign-in" (ARSO) restored a console
|
|
# `python.exe tray_windows.py` instance while the headless `pythonw` autostart
|
|
# also fired — two trays fighting over the one BLE link. The guard makes a
|
|
# second instance exit before it touches BLE.
|
|
|
|
def test_single_instance_noop_off_windows():
|
|
"""Off-Windows the guard is a no-op that returns a truthy sentinel (never None)."""
|
|
with patch("daemon.tray_windows.sys") as mock_sys:
|
|
mock_sys.platform = "linux"
|
|
assert _acquire_single_instance() is not None
|
|
|
|
|
|
def _fake_kernel32(last_error: int, handle: int):
|
|
"""Build a fake ctypes module tree whose CreateMutexW returns `handle` and
|
|
whose get_last_error() returns `last_error`."""
|
|
fake_kernel32 = MagicMock()
|
|
fake_kernel32.CreateMutexW.return_value = handle
|
|
fake_ctypes = MagicMock()
|
|
fake_ctypes.WinDLL.return_value = fake_kernel32
|
|
fake_ctypes.get_last_error.return_value = last_error
|
|
return fake_ctypes
|
|
|
|
|
|
def test_single_instance_first_instance_gets_handle():
|
|
"""First instance: CreateMutexW succeeds, no prior owner → returns the handle."""
|
|
fake_ctypes = _fake_kernel32(last_error=0, handle=0xABCD)
|
|
with patch("daemon.tray_windows.sys") as mock_sys, \
|
|
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
|
mock_sys.platform = "win32"
|
|
assert _acquire_single_instance() == 0xABCD
|
|
|
|
|
|
def test_single_instance_second_instance_gets_none():
|
|
"""Second instance: mutex already exists → returns None so caller exits."""
|
|
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0xABCD)
|
|
with patch("daemon.tray_windows.sys") as mock_sys, \
|
|
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
|
mock_sys.platform = "win32"
|
|
assert _acquire_single_instance() is None
|
|
|
|
|
|
def test_single_instance_fails_open_on_null_handle():
|
|
"""If CreateMutexW returns NULL, fail OPEN (truthy) — never block tray startup."""
|
|
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0)
|
|
with patch("daemon.tray_windows.sys") as mock_sys, \
|
|
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
|
mock_sys.platform = "win32"
|
|
result = _acquire_single_instance()
|
|
assert result is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: cwd-independent package + asset resolution (SC#1 logon autostart)
|
|
# ---------------------------------------------------------------------------
|
|
# Field bug: launching `pythonw.exe daemon\tray_windows.py` at logon starts with
|
|
# cwd = System32, so `import daemon.*` raised ModuleNotFoundError and the relative
|
|
# logo path failed — the tray crashed silently with no icon. tray_windows must
|
|
# self-locate the repo root from __file__ so it works from any working directory.
|
|
|
|
def test_repo_root_is_parent_of_daemon_package():
|
|
"""_REPO_ROOT points at the dir that CONTAINS the daemon package."""
|
|
import os
|
|
import daemon.tray_windows as tw
|
|
|
|
assert os.path.isdir(os.path.join(tw._REPO_ROOT, "daemon"))
|
|
assert os.path.isfile(
|
|
os.path.join(tw._REPO_ROOT, "firmware", "src", "logo.h")
|
|
), "brand logo must resolve from _REPO_ROOT, not the current working directory"
|
|
|
|
|
|
def test_repo_root_on_sys_path_after_import():
|
|
"""Importing tray_windows puts the repo root on sys.path so `daemon.*` resolves regardless of cwd."""
|
|
import sys
|
|
import daemon.tray_windows as tw
|
|
|
|
assert tw._REPO_ROOT in sys.path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: daemon main() must run in a BACKGROUND thread (SC#1 tray launch)
|
|
# ---------------------------------------------------------------------------
|
|
# Field bug: under the tray the loop runs in threading.Thread (pystray owns the
|
|
# main thread). OS signal-handler installation (loop.add_signal_handler /
|
|
# signal.signal) only works on the main thread, so main() raised
|
|
# "signal only works in main thread" and the daemon thread died on startup.
|
|
# main() must guard signal setup to the main thread; the tray owns shutdown.
|
|
|
|
def test_main_runs_in_background_thread_without_signal_error():
|
|
"""main(tray_state=ts) started from a non-main thread must not raise on signal setup."""
|
|
import threading as _threading
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
ts = TrayState()
|
|
errors: list = []
|
|
|
|
async def _fake_scan():
|
|
ts.stop_event.set() # exit the loop immediately
|
|
return None
|
|
|
|
def _run() -> None:
|
|
try:
|
|
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
|
asyncio.run(mod.main(tray_state=ts))
|
|
except Exception as exc: # noqa: BLE001 — capture for the assertion
|
|
errors.append(exc)
|
|
|
|
t = _threading.Thread(target=_run)
|
|
t.start()
|
|
t.join(timeout=10)
|
|
|
|
assert not t.is_alive(), "daemon main() hung in background thread"
|
|
assert not errors, f"main() raised in a background thread: {errors!r}"
|