Introduce the multi-provider seam on the host, behaviour-identical for Anthropic.
- config v2: `providers` (per-provider enable + creds), `active_provider`,
`display_order`; v1 configs migrate transparently (anthropic enabled+active).
New accessors: active_provider_id / provider_conf / enabled_providers.
- daemon/providers/: Provider ABC + normalized ProviderStatus (maps to the same
s/sr/w/wr/st/tk/to/tc/tn BLE fields), AnthropicProvider (wraps the existing
OAuth+poll+local-usage logic), StubProvider for openai/zai until M2/M3, and a
get_provider() registry.
- poll loop: polls the config-selected active provider each cycle and tags the
payload with `pv` (the brand-theme id the watch will wear). Watch->PC
`{"cmd":"prov","id":".."}` persists the active provider and repolls at once.
- clawdmeter.spec: bundle daemon.providers for the frozen exe.
- test: 7 new provider/config tests; fix a stale start_notify count assertion
(the M2 command channel means both REQ and CMD subscriptions are attempted).
Verified live: config loads as v2, active provider resolves to AnthropicProvider,
a real poll returns ok=True (s=53%, w=5%); full suite 87 passed (+7 new).
726 lines
30 KiB
Python
726 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for connect_and_run reconnect hardening — BLE-03.
|
|
|
|
Covers:
|
|
D-01: connect-retry wrapper (post-wake WinRT failure modes)
|
|
D-03: zombie-link consecutive-failure break (stale is_connected)
|
|
|
|
Run: python -m pytest daemon/tests/test_windows_reconnect.py -x -q
|
|
"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from bleak.exc import BleakError
|
|
|
|
from daemon.claude_usage_daemon_windows import (
|
|
AuthError,
|
|
Session,
|
|
_wait_first,
|
|
connect_and_run,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _run(coro):
|
|
"""Run a coroutine synchronously for synchronous test functions."""
|
|
return asyncio.run(coro)
|
|
|
|
|
|
def _make_device(address="AA:BB:CC:DD:EE:FF"):
|
|
"""Build a minimal fake BLEDevice."""
|
|
device = MagicMock()
|
|
device.address = address
|
|
return device
|
|
|
|
|
|
async def _make_event(set_):
|
|
ev = asyncio.Event()
|
|
if set_:
|
|
ev.set()
|
|
return ev
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D-01: connect-retry wrapper tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_connect_retry_exhaustion_on_bleak_error(monkeypatch, capsys):
|
|
"""BleakError on every connect attempt exhausts CONNECT_RETRIES then returns False."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
|
mock_client.is_connected = False
|
|
mock_client.disconnect = AsyncMock()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
assert result is False
|
|
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
|
|
|
|
|
|
def test_connect_retry_exhaustion_on_timeout_error(monkeypatch, capsys):
|
|
"""asyncio.TimeoutError on every connect attempt is treated same as BleakError."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(side_effect=asyncio.TimeoutError())
|
|
mock_client.is_connected = False
|
|
mock_client.disconnect = AsyncMock()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
assert result is False
|
|
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
|
|
|
|
|
|
def test_connect_retry_calls_disconnect_between_attempts(monkeypatch):
|
|
"""Guarded disconnect() is called between failed connect attempts."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
|
mock_client.is_connected = False
|
|
mock_client.disconnect = AsyncMock()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
|
_run(connect_and_run(device, stop_event))
|
|
|
|
# disconnect is called between attempts (at least CONNECT_RETRIES - 1 times)
|
|
assert mock_client.disconnect.call_count >= mod.CONNECT_RETRIES - 1
|
|
|
|
|
|
def test_connect_success_on_first_attempt_no_extra_retries(monkeypatch):
|
|
"""First-attempt success consumes exactly 1 connect call and proceeds past connect block."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
# stop_event is set so the loop exits immediately after connecting
|
|
stop_event = asyncio.run(_make_event(True))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(return_value=None) # success
|
|
mock_client.is_connected = True
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client.start_notify = AsyncMock()
|
|
mock_client.write_gatt_char = AsyncMock(return_value=None)
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
|
|
_run(connect_and_run(device, stop_event))
|
|
|
|
assert mock_client.connect.call_count == 1
|
|
|
|
|
|
def test_connect_retry_exhaustion_does_not_log_token(monkeypatch, capsys):
|
|
"""On exhaustion, no log line contains the patched token sentinel (T-03-01)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
TOKEN_SENTINEL = "sk-ant-SUPERSECRET-DO-NOT-LOG-12345"
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
|
mock_client.is_connected = False
|
|
mock_client.disconnect = AsyncMock()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value=TOKEN_SENTINEL), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
|
_run(connect_and_run(device, stop_event))
|
|
|
|
captured = capsys.readouterr()
|
|
assert TOKEN_SENTINEL not in captured.out, "Token sentinel leaked to stdout (T-03-01)"
|
|
assert TOKEN_SENTINEL not in captured.err, "Token sentinel leaked to stderr (T-03-01)"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D-03: zombie-link consecutive-failure break tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_zombie_client():
|
|
"""Build a mock BleakClient that connects successfully but has is_connected stuck True."""
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(return_value=None)
|
|
mock_client.is_connected = True # stale flag — never goes False
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client.start_notify = AsyncMock()
|
|
return mock_client
|
|
|
|
|
|
def test_zombie_link_break_after_limit_consecutive_failures(monkeypatch):
|
|
"""Loop breaks after exactly ZOMBIE_BREAK_LIMIT consecutive False writes (default 1)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
mock_client = _make_zombie_client()
|
|
|
|
write_call_count = [0]
|
|
|
|
async def fake_write_payload(payload):
|
|
write_call_count[0] += 1
|
|
return False # always fail — zombie link
|
|
|
|
fake_session = AsyncMock()
|
|
fake_session.write_payload = fake_write_payload
|
|
fake_session.refresh_requested = MagicMock()
|
|
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
|
fake_session.refresh_requested.clear = MagicMock()
|
|
fake_session.refresh_requested.wait = AsyncMock()
|
|
|
|
# Force elapsed >= POLL_INTERVAL immediately
|
|
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
|
|
|
async def fast_wait_for(coro, timeout):
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api",
|
|
new=AsyncMock(return_value={"ok": True})), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
|
side_effect=fast_wait_for):
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
# With ZOMBIE_BREAK_LIMIT=1, one False write should break the loop
|
|
assert write_call_count[0] == mod.ZOMBIE_BREAK_LIMIT
|
|
# Should return used_successfully=False (no successful write)
|
|
assert result is False
|
|
|
|
|
|
def test_zombie_counter_resets_on_success_with_raised_limit(monkeypatch):
|
|
"""A failed write followed by success resets counter (limit raised to 2 to exercise reset)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
mock_client = _make_zombie_client()
|
|
|
|
# Sequence: False (counter=1), True (counter reset to 0), False (counter=1 again), break
|
|
write_results = iter([False, True, False])
|
|
write_call_count = [0]
|
|
|
|
async def fake_write_payload(payload):
|
|
write_call_count[0] += 1
|
|
try:
|
|
return next(write_results)
|
|
except StopIteration:
|
|
return False
|
|
|
|
# After success, subsequent False write breaks at limit=2 (requires 2 consecutive)
|
|
# With limit=2: False (1), True (reset to 0), False (1), False (2 -> break)
|
|
# But we only have 3 items in write_results; after StopIteration returns False.
|
|
# Let's use a longer sequence to ensure reset-then-2-failures trip the break.
|
|
write_results2 = [False, True, False, False]
|
|
write_call_count2 = [0]
|
|
|
|
async def fake_write_payload2(payload):
|
|
write_call_count2[0] += 1
|
|
if write_call_count2[0] - 1 < len(write_results2):
|
|
return write_results2[write_call_count2[0] - 1]
|
|
return False
|
|
|
|
fake_session = AsyncMock()
|
|
fake_session.write_payload = fake_write_payload2
|
|
fake_session.refresh_requested = MagicMock()
|
|
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
|
fake_session.refresh_requested.clear = MagicMock()
|
|
fake_session.refresh_requested.wait = AsyncMock()
|
|
|
|
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
|
monkeypatch.setattr(mod, "ZOMBIE_BREAK_LIMIT", 2) # raise limit to test reset logic
|
|
|
|
async def fast_wait_for(coro, timeout):
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api",
|
|
new=AsyncMock(return_value={"ok": True})), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
|
side_effect=fast_wait_for):
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
# With limit=2 and sequence [False, True, False, False]:
|
|
# cycle 1: False -> consecutive_failures=1 (no break, limit=2)
|
|
# cycle 2: True -> consecutive_failures=0 (reset)
|
|
# cycle 3: False -> consecutive_failures=1 (no break)
|
|
# cycle 4: False -> consecutive_failures=2 -> break
|
|
assert write_call_count2[0] == 4, (
|
|
f"Expected 4 write calls (reset-on-success logic), got {write_call_count2[0]}"
|
|
)
|
|
# used_successfully=True because cycle 2 succeeded
|
|
assert result is True
|
|
|
|
|
|
def test_zombie_break_disconnect_called_in_finally(monkeypatch):
|
|
"""The finally block calls client.disconnect() exactly once on the zombie-break path."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
mock_client = _make_zombie_client()
|
|
|
|
async def fake_write_payload(payload):
|
|
return False # always fail
|
|
|
|
fake_session = AsyncMock()
|
|
fake_session.write_payload = fake_write_payload
|
|
fake_session.refresh_requested = MagicMock()
|
|
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
|
fake_session.refresh_requested.clear = MagicMock()
|
|
fake_session.refresh_requested.wait = AsyncMock()
|
|
|
|
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
|
|
|
async def fast_wait_for(coro, timeout):
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api",
|
|
new=AsyncMock(return_value={"ok": True})), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
|
side_effect=fast_wait_for):
|
|
_run(connect_and_run(device, stop_event))
|
|
|
|
# The finally block calls disconnect() exactly once
|
|
assert mock_client.disconnect.call_count == 1
|
|
|
|
|
|
def test_zombie_break_returns_used_successfully_false(monkeypatch):
|
|
"""connect_and_run returns used_successfully=False after zombie break with no writes."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
mock_client = _make_zombie_client()
|
|
|
|
async def fake_write_payload(payload):
|
|
return False
|
|
|
|
fake_session = AsyncMock()
|
|
fake_session.write_payload = fake_write_payload
|
|
fake_session.refresh_requested = MagicMock()
|
|
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
|
fake_session.refresh_requested.clear = MagicMock()
|
|
fake_session.refresh_requested.wait = AsyncMock()
|
|
|
|
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
|
|
|
async def fast_wait_for(coro, timeout):
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api",
|
|
new=AsyncMock(return_value={"ok": True})), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
|
side_effect=fast_wait_for):
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
# main() uses this return value to route into reconnect branch
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D-05: split fast-reconnect vs slow-search backoff in main()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_next_backoff_slow_search_doubles_to_60():
|
|
"""_next_backoff doubles correctly and never exceeds 60 (slow-search cap)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
values = []
|
|
b = 1
|
|
for _ in range(10):
|
|
b = mod._next_backoff(b, 60)
|
|
values.append(b)
|
|
|
|
assert values == [2, 4, 8, 16, 32, 60, 60, 60, 60, 60]
|
|
assert max(values) <= 60
|
|
|
|
|
|
def test_next_backoff_fast_reconnect_doubles_to_cap():
|
|
"""_next_backoff doubles correctly and never exceeds RECONNECT_BACKOFF_CAP (default 8)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
cap = mod.RECONNECT_BACKOFF_CAP
|
|
assert cap < 60, "Fast cap must be strictly lower than search cap"
|
|
|
|
values = []
|
|
b = 1
|
|
for _ in range(8):
|
|
b = mod._next_backoff(b, cap)
|
|
values.append(b)
|
|
|
|
# Should double until hitting the cap, then stay there
|
|
assert max(values) <= cap
|
|
# Should reach the cap (not just stay at 1)
|
|
assert values[-1] == cap
|
|
|
|
|
|
def test_next_backoff_one_to_two():
|
|
"""_next_backoff(1, 60) == 2 (basic sanity)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
assert mod._next_backoff(1, 60) == 2
|
|
|
|
|
|
def test_next_backoff_at_cap_stays():
|
|
"""_next_backoff(cap, cap) == cap (does not overflow)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
assert mod._next_backoff(mod.RECONNECT_BACKOFF_CAP, mod.RECONNECT_BACKOFF_CAP) == mod.RECONNECT_BACKOFF_CAP
|
|
|
|
|
|
def test_main_scan_miss_uses_search_backoff():
|
|
"""When scan_for_device returns None, asyncio.wait_for receives search_backoff timeout values."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
# Capture main()'s internal stop_event by intercepting asyncio.Event()
|
|
internal_stop_event = [None]
|
|
real_Event = asyncio.Event
|
|
|
|
def capturing_Event():
|
|
ev = real_Event()
|
|
internal_stop_event[0] = ev
|
|
return ev
|
|
|
|
recorded_timeouts = []
|
|
call_count = [0]
|
|
MAX_CALLS = 3
|
|
|
|
async def fake_scan():
|
|
return None # always miss -> slow-search regime
|
|
|
|
async def fake_wait_for(coro, timeout):
|
|
recorded_timeouts.append(timeout)
|
|
call_count[0] += 1
|
|
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
|
|
internal_stop_event[0].set() # terminate main()'s outer while loop
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
|
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
|
_run(mod.main())
|
|
|
|
# Should have recorded timeouts from search_backoff sequence: 1, 2, 4 (then stop)
|
|
assert len(recorded_timeouts) >= 2
|
|
# Timeouts should be doubling (search_backoff sequence)
|
|
assert recorded_timeouts[0] == 1
|
|
assert recorded_timeouts[1] == 2
|
|
# None should exceed the search cap (60)
|
|
assert all(t <= 60 for t in recorded_timeouts)
|
|
|
|
|
|
def test_main_connect_fail_uses_reconnect_backoff():
|
|
"""When connect_and_run returns False, asyncio.wait_for receives reconnect_backoff timeouts (fast cap)."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
# Capture main()'s internal stop_event
|
|
internal_stop_event = [None]
|
|
real_Event = asyncio.Event
|
|
|
|
def capturing_Event():
|
|
ev = real_Event()
|
|
internal_stop_event[0] = ev
|
|
return ev
|
|
|
|
fake_device = _make_device()
|
|
recorded_timeouts = []
|
|
call_count = [0]
|
|
MAX_CALLS = 3
|
|
|
|
async def fake_scan():
|
|
return fake_device # always finds device
|
|
|
|
async def fake_connect_and_run(device, event, tray_state=None):
|
|
return False # always fails -> fast-reconnect regime
|
|
|
|
async def fake_wait_for(coro, timeout):
|
|
recorded_timeouts.append(timeout)
|
|
call_count[0] += 1
|
|
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
|
|
internal_stop_event[0].set()
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
|
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
|
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
|
_run(mod.main())
|
|
|
|
# Should have recorded timeouts from reconnect_backoff sequence: 1, 2, 4 (then stop)
|
|
assert len(recorded_timeouts) >= 2
|
|
assert recorded_timeouts[0] == 1
|
|
assert recorded_timeouts[1] == 2
|
|
# All timeouts must be at or below RECONNECT_BACKOFF_CAP (fast cap, < 60)
|
|
assert all(t <= mod.RECONNECT_BACKOFF_CAP for t in recorded_timeouts)
|
|
|
|
|
|
def test_main_reconnect_backoff_reset_on_success():
|
|
"""A successful connect_and_run (returns True) resets reconnect_backoff to 1."""
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
# Capture main()'s internal stop_event
|
|
internal_stop_event = [None]
|
|
real_Event = asyncio.Event
|
|
|
|
def capturing_Event():
|
|
ev = real_Event()
|
|
internal_stop_event[0] = ev
|
|
return ev
|
|
|
|
fake_device = _make_device()
|
|
recorded_timeouts = []
|
|
call_count = [0]
|
|
|
|
# Sequence: fail (reconnect_backoff=1), succeed (reset), fail (reconnect_backoff=1 again)
|
|
connect_results = [False, True, False]
|
|
connect_idx = [0]
|
|
|
|
async def fake_scan():
|
|
return fake_device
|
|
|
|
async def fake_connect_and_run(device, event, tray_state=None):
|
|
idx = connect_idx[0]
|
|
connect_idx[0] += 1
|
|
if idx < len(connect_results):
|
|
return connect_results[idx]
|
|
return False
|
|
|
|
async def fake_wait_for(coro, timeout):
|
|
recorded_timeouts.append(timeout)
|
|
call_count[0] += 1
|
|
if call_count[0] >= 2 and internal_stop_event[0] is not None:
|
|
internal_stop_event[0].set() # stop after 2 waits (first fail + post-success fail)
|
|
raise asyncio.TimeoutError()
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
|
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
|
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
|
|
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
|
_run(mod.main())
|
|
|
|
# First wait: reconnect_backoff=1 (initial failure)
|
|
# Second wait: reconnect_backoff=1 again (reset by success, then another failure)
|
|
assert len(recorded_timeouts) >= 2
|
|
assert recorded_timeouts[0] == 1, f"Expected 1 on first fail, got {recorded_timeouts[0]}"
|
|
assert recorded_timeouts[1] == 1, f"Expected 1 after success reset, got {recorded_timeouts[1]}"
|
|
|
|
|
|
def test_main_no_saved_addr_file_or_skip_addr():
|
|
"""main() does not reference SAVED_ADDR_FILE or skip_addr (Windows is stateless - D-04)."""
|
|
import inspect
|
|
import daemon.claude_usage_daemon_windows as mod
|
|
|
|
source = inspect.getsource(mod.main)
|
|
assert "SAVED_ADDR_FILE" not in source, "main() must not reference SAVED_ADDR_FILE (D-04)"
|
|
assert "skip_addr" not in source, "main() must not reference skip_addr (macOS-only)"
|
|
assert "retrieve_connected" not in source.lower(), \
|
|
"main() must not reference retrieve_connected (macOS HID path)"
|
|
|
|
|
|
def test_requirements_windows_contains_required_deps():
|
|
"""requirements-windows.txt must contain the expected deps.
|
|
|
|
Phase 3 (reconnect) added no new deps; Phase 4 (tray) adds pystray + Pillow.
|
|
This test asserts the final expected state: bleak, httpx, pystray, Pillow
|
|
must be present; winreg must NOT be listed (it is stdlib — no install needed).
|
|
"""
|
|
req_path = Path(__file__).parent.parent / "requirements-windows.txt"
|
|
content = req_path.read_text()
|
|
lines = {line.strip().lower() for line in content.splitlines()
|
|
if line.strip() and not line.strip().startswith("#")}
|
|
|
|
assert "bleak" in lines, "bleak must be in requirements-windows.txt"
|
|
assert "httpx" in lines, "httpx must be in requirements-windows.txt"
|
|
assert "pystray" in lines, "pystray must be in requirements-windows.txt (Phase 4)"
|
|
assert "pillow" in lines, "Pillow must be in requirements-windows.txt (Phase 4)"
|
|
assert "winreg" not in lines, "winreg is stdlib — must NOT be in requirements-windows.txt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# G-03-01: start_notify() OSError must not crash the daemon (SC#3 power-cycle)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_start_notify_oserror_does_not_crash_connect_and_run():
|
|
"""G-03-01 regression: on post-power-cycle reconnect, WinRT's start_notify()
|
|
CCCD write can raise a raw OSError/WinError when the just-rebooted peer GATT
|
|
server is not yet ready. The optional refresh subscription must degrade
|
|
gracefully — connect_and_run must NOT propagate the OSError and must proceed
|
|
into the poll loop (returning normally), so the daemon never restarts (SC#3/SC#4).
|
|
"""
|
|
device = _make_device()
|
|
# stop_event set so the poll loop exits immediately after subscription setup
|
|
stop_event = asyncio.run(_make_event(True))
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.connect = AsyncMock(return_value=None) # connect succeeds
|
|
mock_client.is_connected = True
|
|
mock_client.disconnect = AsyncMock()
|
|
# The exact failure observed on hardware (SC#3, 2026-06-02):
|
|
# OSError: [WinError -2147023673] The operation was canceled by the user.
|
|
mock_client.start_notify = AsyncMock(
|
|
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
|
|
)
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
|
|
# Must NOT raise OSError — graceful degradation into the poll loop.
|
|
result = _run(connect_and_run(device, stop_event))
|
|
|
|
# start_notify was actually attempted (and raised), but was swallowed — for
|
|
# BOTH subscriptions the daemon sets up: the REQ refresh channel and the CMD
|
|
# command channel (battery + HA). Both degrade gracefully.
|
|
assert mock_client.start_notify.call_count == 2
|
|
# Function returned normally instead of propagating the OSError.
|
|
assert result is False
|
|
# The link was cleaned up via the finally block.
|
|
assert mock_client.disconnect.call_count >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC#2 field report: write_payload() OSError must not crash the daemon thread
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_write_payload_oserror_returns_false_not_raises():
|
|
"""SC#2 regression: write_gatt_char can raise a raw OSError/WinError (NOT a
|
|
BleakError) when the peer GATT server goes transiently unavailable mid-write.
|
|
write_payload must catch it and return False — tripping the zombie-link break
|
|
for a clean reconnect — instead of propagating an uncaught exception that
|
|
silently kills the daemon=True background thread and freezes the tray.
|
|
"""
|
|
mock_client = AsyncMock()
|
|
mock_client.write_gatt_char = AsyncMock(
|
|
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
|
|
)
|
|
session = Session(mock_client)
|
|
|
|
result = _run(session.write_payload({"ok": True}))
|
|
|
|
assert result is False # caught and reported, not raised
|
|
assert mock_client.write_gatt_char.call_count == 1
|
|
|
|
|
|
def test_write_payload_bleak_error_still_returns_false():
|
|
"""The pre-existing BleakError path must keep returning False (no regression
|
|
from widening the except to also cover OSError)."""
|
|
mock_client = AsyncMock()
|
|
mock_client.write_gatt_char = AsyncMock(side_effect=BleakError("disconnected"))
|
|
session = Session(mock_client)
|
|
|
|
assert _run(session.write_payload({"ok": True})) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC#3 graceful Quit: _wait_first wakes immediately on stop (clean disconnect)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_wait_first_returns_immediately_when_an_event_is_set():
|
|
"""The poll loop's TICK wait must break the instant stop_event is set, so the
|
|
finally: client.disconnect() runs before the process exits (SC#3). The outer
|
|
wait_for(2s) fails fast if _wait_first wrongly blocks for the full 30s timeout."""
|
|
async def go():
|
|
refresh = asyncio.Event()
|
|
stop = asyncio.Event()
|
|
stop.set() # stop signalled
|
|
await asyncio.wait_for(_wait_first(refresh, stop, timeout=30.0), timeout=2.0)
|
|
assert not refresh.is_set() # loser waiter drained, refresh untouched
|
|
_run(go())
|
|
|
|
|
|
def test_wait_first_returns_after_timeout_when_no_event_set():
|
|
"""With neither event set, _wait_first returns after `timeout` (the normal
|
|
poll-tick path) rather than hanging."""
|
|
async def go():
|
|
await asyncio.wait_for(
|
|
_wait_first(asyncio.Event(), asyncio.Event(), timeout=0.05), timeout=2.0
|
|
)
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC#5: transient poll failure must NOT toast "token expired"; only a real
|
|
# 401/403 (AuthError) should. A boot-time DNS blip returns None, not AuthError.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _connected_mock_client():
|
|
client = AsyncMock()
|
|
client.connect = AsyncMock(return_value=None)
|
|
client.is_connected = True
|
|
client.disconnect = AsyncMock()
|
|
client.start_notify = AsyncMock()
|
|
return client
|
|
|
|
|
|
def test_transient_poll_failure_does_not_set_error():
|
|
"""poll_api returning None (network/DNS, timeout, 5xx, 429) is transient and
|
|
must leave the tray state untouched — not flip it to 'token expired' (SC#5
|
|
field report: `getaddrinfo failed` at boot wrongly fired the toast)."""
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
tray_state = MagicMock()
|
|
client = _connected_mock_client()
|
|
|
|
async def fake_poll(_token):
|
|
stop_event.set() # end the loop after this single transient failure
|
|
return None
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
|
|
_run(connect_and_run(device, stop_event, tray_state))
|
|
|
|
tray_state.set_error.assert_not_called()
|
|
tray_state.set_connected.assert_not_called()
|
|
|
|
|
|
def test_auth_error_sets_token_expired():
|
|
"""A genuine 401/403 surfaces as AuthError and DOES flip the tray to the
|
|
actionable 'token expired — run claude login' error state."""
|
|
device = _make_device()
|
|
stop_event = asyncio.run(_make_event(False))
|
|
tray_state = MagicMock()
|
|
client = _connected_mock_client()
|
|
|
|
async def fake_poll(_token):
|
|
stop_event.set()
|
|
raise AuthError(401)
|
|
|
|
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
|
|
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
|
|
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
|
|
_run(connect_and_run(device, stop_event, tray_state))
|
|
|
|
tray_state.set_error.assert_called_once_with("token expired — run claude login")
|