import pytest from fastapi.testclient import TestClient from app.main import create_app from app.mobile.session import RpcError def test_identity_persistence_and_rename(tmp_path): app = create_app(project_root=tmp_path, driver_kind='mock') mobile = app.state.mobile_control before = mobile.identity() renamed = mobile.dispatch('device.rename', {'name': '客厅小屏幕'}) assert renamed['device_id'] == before['device_id'] assert renamed['device_name'] == '客厅小屏幕' other = create_app(project_root=tmp_path, driver_kind='mock') assert other.state.mobile_control.identity() == renamed def test_web_change_invalidates_mobile_revision(tmp_path): app = create_app(project_root=tmp_path, driver_kind='mock') mobile = app.state.mobile_control settings = mobile.dispatch('settings.get', {}) client = TestClient(app) assert client.put('/api/config', json={'brightness': 37}).status_code == 200 with pytest.raises(RpcError, match='CONFLICT'): mobile.dispatch('settings.patch', {'expected_revision': settings['revision'], 'changes': {'brightness': 20}}) revision = mobile.settings()['revision'] mobile.dispatch('settings.patch', {'expected_revision': revision, 'changes': {'brightness': 25}}) assert client.get('/api/config').json()['brightness'] == 25 def test_library_and_frame_readback(tmp_path): app = create_app(project_root=tmp_path, driver_kind='mock') mobile = app.state.mobile_control first = mobile.dispatch('library.list', {'limit': 1}) assert len(first['items']) == 1 and first['next_cursor'] second = mobile.dispatch('library.list', {'cursor': first['next_cursor'], 'limit': 1}) assert second['items'][0]['id'] != first['items'][0]['id'] item = first['items'][0] assert mobile.dispatch('library.thumbnail', item)['mime'] == 'image/png' mobile.dispatch('content.play', item) frame = mobile.dispatch('frame.get', {}) assert mobile.dispatch('frame.get', {'known_revision': frame['frame_revision']})['unchanged'] with pytest.raises(RpcError, match='CONFLICT'): mobile.dispatch('content.play', {**item, 'revision': 'old'}) def test_forbidden_operation_and_settings_fields(tmp_path): mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'): mobile.dispatch('shell.run', {'command': 'anything'}) with pytest.raises(RpcError, match='BAD_REQUEST'): mobile.dispatch('settings.patch', {'expected_revision': mobile.settings()['revision'], 'changes': {'low_voltage_protection_enabled': False}}) def test_wifi_open_network_and_password_not_returned(tmp_path): mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control revision = mobile.dispatch('wifi.get', {})['revision'] result = mobile.dispatch('wifi.set', dict(expected_revision=revision, ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot')) assert mobile.dispatch('task.get', {'task_id': result['task_id']})['state'] == 'succeeded' wifi = mobile.dispatch('wifi.get', {}) assert 'password' not in wifi['saved'] assert not wifi['saved']['password_configured'] assert wifi['saved']['security'] == 'open' assert wifi['revision'] != revision mobile.dispatch('wifi.set', dict(expected_revision=wifi['revision'], ssid='Test Private', security='wpa-psk', password_action='replace', password='public-test-fixture', ipv4_mode='dhcp', activation='next_boot')) wifi = mobile.dispatch('wifi.get', {}) assert 'password' not in wifi['saved'] and wifi['saved']['password_configured'] def test_scan_escaped_ssid_deduplicates_and_marks_unsupported(): from app.network.manager import NmcliNetworkManager class Stub(NmcliNetworkManager): def _run(self, args, *, timeout=10): assert timeout == 15 and '--rescan' in args return '*:Test\\:WiFi:WPA2:42\n:Test\\:WiFi:WPA2:81\n:Open:--:37\n:EAP:WPA2 802.1X:80' rows = Stub().scan() assert rows[0] == dict(ssid='Test:WiFi', security='wpa-psk', signal_percent=81, connected=True) assert rows[1]['security'] == 'unsupported' assert rows[2]['security'] == 'open' def test_mobile_default_and_animation_controls_share_web_state(tmp_path): app = create_app(project_root=tmp_path, driver_kind='mock') with TestClient(app) as client: mobile = app.state.mobile_control items = mobile.dispatch('library.list', {})['items'] animation = next(item for item in items if item['type'] == 'animation' and item['playable']) mobile.dispatch('content.default.set', animation) assert mobile.dispatch('content.default.get', {})['id'] == animation['id'] assert client.get('/api/display/default-content').json()['id'] == animation['id'] playback = client.get('/api/status').json()['state']['animation_playback'] controlled = mobile.dispatch('playback.patch', dict( session_id=playback['session_id'], paused=True, position_ms=0, speed=1.5)) assert controlled['animation_playback']['paused'] current = client.get('/api/status').json()['state']['animation_playback'] assert current['position_ms'] == 0 and current['speed'] == 1.5 mobile.dispatch('content.play', animation) with pytest.raises(RpcError, match='CONFLICT'): mobile.dispatch('playback.patch', dict(session_id=playback['session_id'], paused=False)) def test_mobile_library_uses_persistent_mixed_order_and_invalidates_cursor(tmp_path): from app.demo_library import demo_template app = create_app(project_root=tmp_path, driver_kind='mock') mobile = app.state.mobile_control templates, animations = mobile.templates, mobile.animations import base64 t = templates.create('user-template', {'version': 1, 'width': 64, 'height': 64, 'pixelRgb': base64.b64encode(bytes(64*64*3)).decode('ascii'), 'elements': []}) # Empty animations are valid library items, even though not playable. a = animations.create('user-animation') defaults = [{'type': 'template', 'id': t['id']}, {'type': 'animation', 'id': a['id']}] store = app.state.library_order_store current = store.get(defaults) first = mobile.dispatch('library.list', {'limit': 1}) order = list(reversed(defaults)) store.update(order, default_items=defaults, expected_revision=current['revision']) items = mobile.dispatch('library.list', {})['items'] assert [{'type': i['type'], 'id': i['id']} for i in items if not i['is_demo']] == order with pytest.raises(RpcError, match='CONFLICT'): mobile.dispatch('library.list', {'cursor': first['next_cursor']}) @pytest.mark.parametrize('reason,expected', [(53, 'NETWORK_UNAVAILABLE'), (5, 'IP_CONFIG_FAILED'), (11, 'UNKNOWN'), (7, 'UNKNOWN')]) def test_wifi_failure_classification_does_not_guess_authentication(monkeypatch, reason, expected): import subprocess from app.network.manager import NmcliNetworkManager, NetworkManagerError class Backend(NmcliNetworkManager): def _run(self, args, *, timeout=10): if 'up' in args: raise NetworkManagerError('sanitized') return str(reason) + ' (reason)' monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, '', '')) with pytest.raises(NetworkManagerError) as error: Backend().activate('public-test-id') assert error.value.error_code == expected def test_wifi_auth_requires_explicit_wrong_key_event(monkeypatch): import subprocess from app.network.manager import NmcliNetworkManager, NetworkManagerError class Backend(NmcliNetworkManager): def _run(self, args, *, timeout=10): if 'up' in args: raise NetworkManagerError('sanitized') return '7 (no-secrets)' monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, 'wlan0: CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="fixture" auth_failures=1 duration=10 reason=WRONG_KEY', '')) with pytest.raises(NetworkManagerError) as error: Backend().activate('public-test-id') assert error.value.error_code == 'AUTH_FAILED' assert str(error.value) == 'WiFi activation failed' @pytest.mark.parametrize('failure', ['AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN']) def test_task_returns_only_sanitized_failure_code(tmp_path, failure): from app.network.manager import NetworkManagerError mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control def fail(*args, **kwargs): raise NetworkManagerError('private backend detail must not escape', error_code=failure) mobile.dispatch('wifi.set', dict(expected_revision=mobile.wifi()['revision'], ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot')) mobile.network.backend.activate = fail task = 'public-test-task' mobile._activate(task) result = mobile.dispatch('task.get', {'task_id': task}) assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure} assert 'private' not in str(result)