mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 22:13:43 +08:00
5755042c96
- IPv6-only startup blocked indefinitely. - Saved initial volume overridden with 100%. - Unbounded diagnostic logging in the audio loop. - Hidden speaker-disconnection status while paused. - Duplicate slider handlers, redundant CSS, verbose documentation, and test lint issues.
201 lines
7.0 KiB
Python
201 lines
7.0 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from openpilot.starpilot.soloist import client, supervisor
|
|
from openpilot.starpilot.soloist.tests.test_runtime import make_supervisor, ready_layout
|
|
from openpilot.starpilot.soloist.paths import Layout
|
|
|
|
|
|
def test_local_api_failure_recovers_without_rollback_or_erasing_login(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
sup.tick()
|
|
sup.tick() # the default fake answered once
|
|
(layout.state / 'account').write_text('saved')
|
|
sup.client.snapshot = lambda: {'connected': False}
|
|
sup.tick()
|
|
now[0] += 121
|
|
sup.tick()
|
|
assert sup.player is None and sup.fast_failures == 0
|
|
now[0] += 11
|
|
sup.tick()
|
|
assert len(spawned) == 4
|
|
assert (layout.state / 'account').read_text() == 'saved'
|
|
|
|
|
|
def test_auth_heartbeat_refreshes_state_after_relogin(tmp_path):
|
|
now = [0.0]
|
|
player = client.PlayerClient(tmp_path, clock=lambda: now[0])
|
|
events = [True, False, True]
|
|
class Connection:
|
|
sent = []
|
|
def settimeout(self, _timeout): pass
|
|
def send(self, message): self.sent.append(json.loads(message)['command'])
|
|
def recv(self):
|
|
if not events:
|
|
player.stop_event.set()
|
|
return ''
|
|
now[0] += 16
|
|
return json.dumps({'type': 'auth_state', 'logged_in': events.pop(0)})
|
|
connection = Connection()
|
|
player._session(connection)
|
|
assert connection.sent.count('get_auth_state') >= 3
|
|
assert connection.sent.count('get_state') == 2
|
|
|
|
|
|
def test_unresponsive_socket_is_not_a_healthy_player(tmp_path):
|
|
now = [0.0]
|
|
player = client.PlayerClient(tmp_path, clock=lambda: now[0])
|
|
class Connection:
|
|
def settimeout(self, _timeout): pass
|
|
def send(self, _message): pass
|
|
def recv(self):
|
|
now[0] += 16
|
|
raise TimeoutError()
|
|
with pytest.raises(TimeoutError, match='health check'):
|
|
player._session(Connection())
|
|
assert not player.snapshot()['connected']
|
|
|
|
|
|
def test_lost_login_recovers_but_first_pairing_waits_without_restart(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned, logged_in=False)
|
|
sup.tick()
|
|
for _ in range(5):
|
|
now[0] += 200
|
|
sup.tick()
|
|
assert len(spawned) == 2 # unlimited time for first pairing
|
|
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': True}
|
|
sup.tick()
|
|
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': False}
|
|
sup.tick()
|
|
now[0] += 121
|
|
sup.tick()
|
|
assert sup.player is None
|
|
now[0] += 11
|
|
sup.tick()
|
|
assert len(spawned) == 4
|
|
sup.handle({'command': 'reset_session'})
|
|
assert not sup.authenticated_once
|
|
|
|
|
|
def test_lost_login_while_playing_gets_a_longer_grace(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
sup.tick()
|
|
state = client.PlayerState()
|
|
state.apply({'type': 'auth_state', 'logged_in': True, 'is_active': True})
|
|
state.apply({'type': 'playback_changed', 'status': 'playing'})
|
|
state.value['connected'] = True
|
|
sup.client.snapshot = lambda: dict(state.value)
|
|
sup.tick()
|
|
state.apply({'type': 'auth_state', 'logged_in': False}) # the real client clears playback on logout
|
|
for _ in range(5):
|
|
now[0] += 100
|
|
sup.tick()
|
|
assert sup.player is not None and len(spawned) == 2
|
|
now[0] += supervisor.PLAYING_GRACE_SECONDS
|
|
sup.tick()
|
|
assert sup.player is None
|
|
|
|
|
|
def test_silent_build_counts_toward_rollback(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
rolled = []
|
|
monkeypatch.setattr(supervisor.package, 'rollback', lambda _l: rolled.append(True) or True)
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
def silent(): return {'connected': False}
|
|
for _ in range(supervisor.MAX_FAST_FAILURES):
|
|
sup.tick()
|
|
sup.client.snapshot = silent
|
|
sup.tick()
|
|
now[0] += supervisor.UNRESPONSIVE_SECONDS + 1
|
|
sup.tick()
|
|
assert sup.player is None
|
|
now[0] += 120
|
|
assert rolled == [True]
|
|
|
|
|
|
def test_speaker_check_runs_off_the_tick(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
class Output:
|
|
calls = 0
|
|
def ready(self, _address):
|
|
self.calls += 1
|
|
return self.calls % 2 == 0 # flaps on every check
|
|
spawned, queued = [], []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
sup.output = Output()
|
|
sup.tick()
|
|
sup.run_async = queued.append
|
|
for _ in range(6):
|
|
now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1
|
|
sup.tick()
|
|
assert len(queued) <= 1 # the tick only queued the blocking check
|
|
if queued:
|
|
queued.pop()()
|
|
assert len(spawned) == 2
|
|
|
|
|
|
def test_phone_source_never_runs_soloist(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
sup.tick()
|
|
client = sup.client
|
|
assert any('soloist' in process.argv[0] for process in spawned)
|
|
supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', output='test'), layout)
|
|
spawned.clear()
|
|
for _ in range(5):
|
|
now[0] += 60
|
|
sup.tick()
|
|
assert client.stopped and sup.client is None
|
|
assert not any('soloist' in process.argv[0] for process in spawned)
|
|
assert sup.status()['state'] == 'no_phone'
|
|
|
|
|
|
def test_phone_page_never_shows_the_spotify_speaker(tmp_path, monkeypatch):
|
|
layout = Layout(tmp_path / 'soloist')
|
|
ready_layout(layout, monkeypatch)
|
|
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
|
monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda _action: None)
|
|
supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
|
class Output:
|
|
connected = False
|
|
def ready(self, _address): return self.connected
|
|
class Phone:
|
|
def powered(self): return True
|
|
def ready(self, _address, _now): return True
|
|
def track(self, _address): return None
|
|
def close(self): pass
|
|
spawned = []
|
|
sup, now = make_supervisor(layout, spawned)
|
|
sup.output, sup.phone = Output(), Phone()
|
|
sup.tick()
|
|
assert sup.status()['outputWaiting'] == 'Car'
|
|
sup.output.connected = True
|
|
supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', phone_address='11:22:33:44:55:66',
|
|
output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
|
now[0] += 1
|
|
sup.tick()
|
|
assert sup.player is not None and 'soloist' not in sup.player.argv[0]
|
|
assert sup.status()['outputWaiting'] == ''
|