Files
matrix-screen-controller/核桃派软件源代码/tests/test_mobile_control.py
T

266 lines
14 KiB
Python

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_progressive_preview_and_revision_cleanup(tmp_path):
import base64
from io import BytesIO
from PIL import Image
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
assert mobile.identity()['protocol_minor'] == 3
assert 'wifi_transport' in mobile.identity()['capabilities']
assert 'library_progressive' in mobile.identity()['capabilities']
created = mobile.templates.create('预览清理', {
'version': 1, 'width': 64, 'height': 64,
'pixelRgb': base64.b64encode(bytes(64 * 64 * 3)).decode('ascii'), 'elements': [],
})
summary = mobile.dispatch('library.summary', {})
items = mobile.dispatch('library.list', {})['items']
assert summary == {'library_revision': mobile.dispatch('library.list', {})['library_revision'], 'item_count': len(items)}
item = next(item for item in items if item['id'] == created['id'])
small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64'])
large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64'])
assert len(small) < len(large)
with Image.open(BytesIO(small)) as image:
assert image.format == 'PNG' and image.size == (16, 16)
with Image.open(BytesIO(large)) as image:
assert image.format == 'PNG' and image.size == (64, 64)
old_file = mobile.preview_cache.root / mobile.preview_cache.name(item)
assert old_file.read_bytes() == small
mobile.templates.rename(created['id'], '预览清理新版', created['revision'])
assert mobile.dispatch('library.summary', {})['library_revision'] != summary['library_revision']
assert not old_file.exists()
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.preview', item)
empty = mobile.animations.create('空动图')
empty_item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == empty['id'])
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.preview', empty_item)
def test_user_animation_preview_reads_persisted_first_frame(tmp_path):
import base64
from io import BytesIO
from PIL import Image
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
scene = {'version': 1, 'width': 64, 'height': 64,
'pixelRgb': base64.b64encode(bytes((12, 34, 56)) * (64 * 64)).decode('ascii'), 'elements': []}
created = mobile.animations.create_from_frames('用户动图', [{'scene': scene, 'duration_ms': 100}])
item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == created['id'])
small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64'])
large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64'])
with Image.open(BytesIO(small)) as image:
assert image.size == (16, 16) and image.getpixel((0, 0)) == (12, 34, 56)
with Image.open(BytesIO(large)) as image:
assert image.size == (64, 64) and image.getpixel((0, 0)) == (12, 34, 56)
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)
def test_animation_chunks_and_read_only(tmp_path):
import base64
import hashlib
from io import BytesIO
from PIL import Image
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
item = next(i for i in mobile.dispatch('library.list', {})['items'] if i['type'] == 'animation')
info = mobile.dispatch('library.animation.info', {**item, 'limit': 1})
assert info['frames'][0]['index'] == 0
frames = list(info['frames'])
while info['next_offset'] is not None:
info = mobile.dispatch('library.animation.info', {**item, 'limit': 1, 'offset': info['next_offset']})
frames += info['frames']
assert len(frames) == info['frame_count']
assert all(f['duration_ms'] > 0 for f in frames)
before = mobile.dispatch('frame.get', {})['frame_revision']
for tier, size in [('preview', 16), ('full', 64)]:
for frame in frames:
offset, data = 0, bytearray()
while True:
result = mobile.dispatch('library.animation.chunk', {**item, 'tier': tier, 'frame_index': frame['index'], 'offset': offset})
chunk = base64.b64decode(result['data_base64'], validate=True)
assert 0 < len(chunk) <= 4096 and result['offset'] == offset
data.extend(chunk)
if result['next_offset'] is None:
break
assert result['next_offset'] == offset + len(chunk)
offset = result['next_offset']
assert len(data) == result['total_bytes']
assert hashlib.sha256(data).hexdigest() == result['sha256']
with Image.open(BytesIO(data)) as image:
assert image.size == (size, size)
assert mobile.dispatch('frame.get', {})['frame_revision'] == before
for bad in [{'offset': -1}, {'limit': 51}, {'offset': True}]:
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('library.animation.info', {**item, **bad})
for bad in [{'frame_index': -1}, {'frame_index': True}, {'tier': 'invalid'}, {'offset': 999999}]:
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('library.animation.chunk', {**item, 'tier': 'full', 'frame_index': 0, **bad})
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.animation.info', {**item, 'revision': 'stale'})