Files

371 lines
14 KiB
Python

import json
import time
import threading
from pathlib import Path
import pytest
from PIL import Image
from fastapi.testclient import TestClient
from app.config.store import ConfigStore
from app.display.service import DisplayService
from app.display.wifi_indicator import render_wifi_indicator
from app.network.manager import MockNetworkManager, NmcliNetworkManager
from app.network.service import WifiNetworkService, validate_wifi_settings
from app.network.store import WifiConfigError, WifiConfigStore
from app.main import create_app
from test_mobile_session import connection, request
@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1'])
def test_nmcli_multiple_dns_can_roundtrip_without_password(monkeypatch, raw_dns):
backend = NmcliNetworkManager()
values = {'802-11-wireless.ssid': 'test-network', 'ipv4.method': 'manual',
'ipv4.addresses': '192.0.2.20/24', 'ipv4.gateway': '192.0.2.1',
'ipv4.dns': raw_dns, '802-11-wireless-security.key-mgmt': 'wpa-psk'}
monkeypatch.setattr(backend, '_connection_value', lambda uuid, field, **kwargs: values.get(field, ''))
saved = backend.read_saved('test-profile')
assert saved['dns_servers'] == ['192.0.2.1', '192.0.2.2']
checked = validate_wifi_settings(saved, previous=saved)
assert checked['dns_servers'] == saved['dns_servers']
assert checked['password'] is None
UUID = "12345678-1234-5678-1234-567812345678"
def saved_profile(**updates):
value = {
"connection_uuid": UUID,
"ssid": "test-hotspot",
"password": "secret123",
"password_configured": True,
"ipv4_mode": "dhcp",
"address": None,
"prefix": None,
"gateway": None,
"dns_servers": [],
}
value.update(updates)
return value
def active_profile(**updates):
value = {
"connected": True,
"connection_uuid": UUID,
"ssid": "test-hotspot",
"ipv4_address": "192.168.1.93",
"control_url": "http://192.168.1.93:8080/",
}
value.update(updates)
return value
def test_wifi_store_creates_strict_v1_and_preserves_boot_session(tmp_path):
data = tmp_path / "data"
runtime = tmp_path / "run"
first = WifiConfigStore(data, runtime, boot_id="boot-a", monotonic=lambda: 100.0)
assert first.config == {
"managed_connection_uuid": None,
"prompt_delay_seconds": 30,
}
assert first.session["deadline_monotonic"] == 130.0
first.update({"managed_connection_uuid": UUID, "prompt_delay_seconds": 45})
first.dismiss_for_boot()
same = WifiConfigStore(data, runtime, boot_id="boot-a", monotonic=lambda: 110.0)
assert same.is_new_boot_session is False
assert same.session["deadline_monotonic"] == 130.0
assert same.session["dismissed"] is True
assert same.config["prompt_delay_seconds"] == 45
next_boot = WifiConfigStore(data, runtime, boot_id="boot-b", monotonic=lambda: 200.0)
assert next_boot.is_new_boot_session is True
assert next_boot.session["deadline_monotonic"] == 245.0
assert next_boot.session["dismissed"] is False
persisted = json.loads((data / "wifi_config.json").read_text(encoding="utf-8"))
assert persisted["schema_version"] == 1
@pytest.mark.parametrize(
"document",
[
{"schema_version": 2, "managed_connection_uuid": None, "prompt_delay_seconds": 30},
{"schema_version": 1, "managed_connection_uuid": None, "prompt_delay_seconds": True},
{"schema_version": 1, "managed_connection_uuid": None, "prompt_delay_seconds": 30, "extra": 1},
],
)
def test_invalid_wifi_store_is_rejected_without_rewrite(tmp_path, document):
data = tmp_path / "data"
data.mkdir()
path = data / "wifi_config.json"
original = (json.dumps(document, ensure_ascii=False) + "\n").encode("utf-8")
path.write_bytes(original)
with pytest.raises(WifiConfigError):
WifiConfigStore(data, tmp_path / "run", boot_id="boot")
assert path.read_bytes() == original
def test_wifi_validation_defaults_optional_manual_fields():
checked = validate_wifi_settings(
{
"ssid": "phone",
"password": "12345678",
"ipv4_mode": "manual",
"address": "192.168.7.50",
"gateway": "192.168.7.1",
"prefix": None,
"dns_servers": [],
},
previous=None,
)
assert checked["prefix"] == 24
assert checked["dns_servers"] == ["192.168.7.1"]
with pytest.raises(WifiConfigError, match="gateway"):
validate_wifi_settings(
{
"ssid": "phone",
"password": "12345678",
"ipv4_mode": "manual",
"address": "192.168.7.50",
"gateway": "10.0.0.1",
},
previous=None,
)
def test_network_service_binds_active_profile_and_separates_activation_modes(tmp_path):
config = ConfigStore(project_root=tmp_path)
display = DisplayService(config, driver_kind="mock", startup_boot_id="boot")
backend = MockNetworkManager(saved_profile(), active_profile())
wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot")
service = WifiNetworkService(wifi_store, backend, display)
request = {
"ssid": "test-hotspot",
"password": None,
"ipv4_mode": "manual",
"address": "192.168.1.94",
"gateway": "192.168.1.1",
"prefix": None,
"dns_servers": [],
"prompt_delay_seconds": 30,
"activation": "next_boot",
}
try:
assert wifi_store.config["managed_connection_uuid"] == UUID
assert "password" not in service.get_status()["saved"]
assert service.get_status(include_secret=True)["saved"]["password"] == "secret123"
saved = service.save_settings(request)
assert saved["activation"] == "next_boot"
assert backend.activations == []
assert backend.saved["address"] == "192.168.1.94"
assert backend.saved["password"] == "secret123"
deadline = wifi_store.session["deadline_monotonic"]
network_snapshot = dict(backend.saved)
delay_result = service.save_prompt_delay(45)
assert delay_result["prompt_delay_seconds"] == 45
assert wifi_store.config["prompt_delay_seconds"] == 45
assert wifi_store.session["deadline_monotonic"] == deadline
assert backend.saved == network_snapshot
assert backend.activations == []
request["activation"] = "immediate"
scheduled = service.save_settings(request)
service.activate_saved(scheduled["operation_id"])
assert backend.activations == [UUID]
assert wifi_store.session["operation"]["state"] == "succeeded"
finally:
service.close()
display.close()
def test_wifi_indicator_frames_and_display_overlay_restore_user_state(tmp_path):
connected = render_wifi_indicator(
"SSID: demo IP: http://192.168.1.2:8080/",
offset=0,
connected=True,
)
disconnected = render_wifi_indicator(
"SSID: demo 密码: 12345678",
offset=0,
connected=False,
)
assert connected.mode == disconnected.mode == "RGB"
assert connected.size == disconnected.size == (64, 64)
assert disconnected.getbbox() is not None
connected_text = render_wifi_indicator("IP: 192.168", offset=64, connected=True)
text_pixels = [
pixel
for y in range(26, 42)
for x in range(64)
if (pixel := connected_text.getpixel((x, y))) != (0, 0, 0)
]
assert text_pixels
assert set(text_pixels) == {(255, 255, 255)}
disconnected_text = render_wifi_indicator("SSID: demo", offset=64, connected=False)
lower_pixels = [
pixel
for y in range(42, 64)
for x in range(64)
if (pixel := disconnected_text.getpixel((x, y))) != (0, 0, 0)
]
assert lower_pixels
assert set(lower_pixels) == {(255, 255, 255)}
config = ConfigStore(project_root=tmp_path)
config.update({"brightness": 27, "last_mode": "composition"})
display = DisplayService(config, driver_kind="mock", startup_boot_id="boot-wifi")
try:
display.fill((12, 34, 56))
before_revision = display.get_status()["state"]["revision"]
display.show_wifi_indicator(connected=True, message="SSID: demo IP: http://192.168.1.2:8080/")
status = display.get_status()["state"]
assert status["mode"] == "wifi_connected_indicator"
assert status["effective_brightness"] == 50
assert status["wifi_indicator_active"] is True
assert status["revision"] == before_revision
assert config.config["last_mode"] == "fill"
display.dismiss_wifi_indicator()
final = display.get_status()["state"]
assert final["mode"] == "fill"
assert final["effective_brightness"] == 27
assert display.get_current_frame().getpixel((0, 0)) == (12, 34, 56)
finally:
display.close()
def test_network_cached_status_does_not_repeat_backend_queries(tmp_path):
class CountingNetworkManager(MockNetworkManager):
def __init__(self):
super().__init__(saved_profile(), active_profile())
self.saved_reads = 0
self.active_reads = 0
def read_saved(self, connection_uuid, *, include_secret=False):
self.saved_reads += 1
return super().read_saved(connection_uuid, include_secret=include_secret)
def read_active(self):
self.active_reads += 1
return super().read_active()
config = ConfigStore(project_root=tmp_path)
display = DisplayService(config, driver_kind="mock", startup_boot_id="boot")
backend = CountingNetworkManager()
wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot")
service = WifiNetworkService(wifi_store, backend, display)
try:
live = service.get_status()
reads = (backend.saved_reads, backend.active_reads)
backend.active = active_profile(connected=False)
cached = service.get_cached_status()
assert cached["active"] == live["active"]
assert (backend.saved_reads, backend.active_reads) == reads
finally:
service.close()
display.close()
def test_dismissed_network_prompt_does_not_reappear_after_disconnect(tmp_path):
config = ConfigStore(project_root=tmp_path)
display = DisplayService(config, driver_kind="mock", startup_boot_id="boot")
backend = MockNetworkManager(saved_profile(), active_profile())
wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot")
service = WifiNetworkService(wifi_store, backend, display, poll_seconds=0.01)
try:
service._refresh_indicator()
assert display.get_status()["state"]["wifi_indicator_active"] is True
service.dismiss_for_boot()
backend.active = active_profile(
connected=False,
ssid=None,
ipv4_address=None,
control_url=None,
)
service._refresh_indicator()
assert wifi_store.session["dismissed"] is True
assert display.get_status()["state"]["wifi_indicator_active"] is False
finally:
service.close()
display.close()
@pytest.mark.parametrize('prompt_state', ['before_delay', 'connected', 'disconnected'])
def test_mobile_open_dismisses_prompt_and_preserves_user_frame(tmp_path, prompt_state):
backend = MockNetworkManager(saved_profile(), active_profile())
app = create_app(project_root=tmp_path, driver_kind='mock', startup_boot_id='boot-a', network_backend=backend)
network = app.state.network_service
display = app.state.display_service
try:
image = Image.new('RGB', (64, 64), (12, 34, 56))
display.show_image(image, notify_activity=False)
before = display.get_status()
config_before = display.config_store.config
assert not TestClient(app).get('/api/status').json()['network']['prompt']['dismissed_for_boot']
if prompt_state != 'before_delay':
backend.active = active_profile(connected=prompt_state == 'connected')
network._refresh_indicator()
assert display.get_status()['state']['wifi_indicator_active']
manager, cipher, _ = connection(manager=app.state.mobile_sessions)
assert not network.store.session['dismissed']
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
manager.disconnect('a')
for connected in [False, True, False]:
backend.active = active_profile(connected=connected)
network._refresh_indicator()
assert not display.get_status()['state']['wifi_indicator_active']
assert network.get_cached_status()['prompt']['dismissed_for_boot']
assert display.get_current_frame().tobytes() == image.tobytes()
after = display.get_status()
for key in ['revision', 'brightness', 'orientation', 'mode', 'current_content']:
assert after['state'][key] == before['state'][key]
assert display.config_store.config == config_before
same = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-a')
assert same.session['dismissed']
next_boot = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-b')
assert not next_boot.session['dismissed']
finally:
network.close()
display.close()
@pytest.mark.parametrize('connected', [False, True])
def test_late_network_read_cannot_restore_dismissed_prompt(tmp_path, connected):
config = ConfigStore(project_root=tmp_path)
display = DisplayService(config, driver_kind='mock', startup_boot_id='boot')
backend = MockNetworkManager(saved_profile(), active_profile(connected=connected))
store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id='boot')
service = WifiNetworkService(store, backend, display)
entered, release = threading.Event(), threading.Event()
read_active = backend.read_active
def delayed_read():
entered.set()
assert release.wait(5)
return read_active()
backend.read_active = delayed_read
thread = threading.Thread(target=service._refresh_indicator)
try:
thread.start()
assert entered.wait(5)
service.dismiss_for_boot()
release.set()
thread.join(5)
assert not thread.is_alive()
assert store.session['dismissed']
assert not display.get_status()['state']['wifi_indicator_active']
finally:
release.set()
thread.join(5)
service.close()
display.close()