283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""
|
|
Integration test: ESP32 WiFi + WebServer sketch compilation & emulation.
|
|
|
|
Verifies the full pipeline for the user's specific sketch:
|
|
1. Sketch structure is valid for ESP32 compilation
|
|
2. WiFi auto-detection triggers wifi_enabled flag
|
|
3. QEMU args include -nic when wifi_enabled=True
|
|
4. WiFi/BLE serial parser extracts correct events from QEMU output
|
|
5. IoT Gateway proxy URL construction
|
|
6. hostfwd port allocation
|
|
"""
|
|
import socket
|
|
import unittest
|
|
|
|
# ── The user's exact sketch ──────────────────────────────────────────────────
|
|
|
|
WEBSERVER_SKETCH = r'''#include <WiFi.h>
|
|
#include <WebServer.h>
|
|
|
|
const char* ssid = "Velxio-GUEST";
|
|
const char* password = "";
|
|
|
|
WebServer server(80);
|
|
|
|
void handleRoot() {
|
|
server.send(200, "text/html", "<h1>Hola desde ESP32 🚀</h1>");
|
|
}
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
WiFi.begin(ssid, password);
|
|
Serial.print("Conectando");
|
|
while (WiFi.status() != WL_CONNECTED) {
|
|
delay(500);
|
|
Serial.print(".");
|
|
}
|
|
Serial.println("\nConectado!");
|
|
Serial.print("IP: ");
|
|
Serial.println(WiFi.localIP());
|
|
server.on("/", handleRoot);
|
|
server.begin();
|
|
Serial.println("Servidor HTTP iniciado");
|
|
}
|
|
|
|
void loop() {
|
|
server.handleClient();
|
|
}
|
|
'''
|
|
|
|
# Simulated QEMU serial output for this sketch
|
|
SIMULATED_SERIAL_OUTPUT = """I (432) wifi:wifi sta start
|
|
I (500) wifi:new:Velxio-GUEST, old: , ASSOC
|
|
I (800) wifi:connected with Velxio-GUEST, aid = 1, channel 6
|
|
I (1200) esp_netif_handlers: sta ip: 192.168.4.2, mask: 255.255.255.0
|
|
Conectando...
|
|
Conectado!
|
|
IP: 192.168.4.2
|
|
Servidor HTTP iniciado
|
|
"""
|
|
|
|
|
|
class TestSketchStructure(unittest.TestCase):
|
|
"""Validate the sketch has the correct structure for ESP32 compilation."""
|
|
|
|
def test_has_wifi_include(self):
|
|
self.assertIn('#include <WiFi.h>', WEBSERVER_SKETCH)
|
|
|
|
def test_has_webserver_include(self):
|
|
self.assertIn('#include <WebServer.h>', WEBSERVER_SKETCH)
|
|
|
|
def test_uses_velxio_guest_ssid(self):
|
|
self.assertIn('"Velxio-GUEST"', WEBSERVER_SKETCH)
|
|
|
|
def test_empty_password(self):
|
|
self.assertIn('password = ""', WEBSERVER_SKETCH)
|
|
|
|
def test_webserver_port_80(self):
|
|
self.assertIn('WebServer server(80)', WEBSERVER_SKETCH)
|
|
|
|
def test_has_setup_and_loop(self):
|
|
self.assertIn('void setup()', WEBSERVER_SKETCH)
|
|
self.assertIn('void loop()', WEBSERVER_SKETCH)
|
|
|
|
def test_serial_begin_115200(self):
|
|
self.assertIn('Serial.begin(115200)', WEBSERVER_SKETCH)
|
|
|
|
def test_wifi_begin_call(self):
|
|
self.assertIn('WiFi.begin(ssid, password)', WEBSERVER_SKETCH)
|
|
|
|
def test_server_routes_registered(self):
|
|
self.assertIn('server.on("/", handleRoot)', WEBSERVER_SKETCH)
|
|
self.assertIn('server.begin()', WEBSERVER_SKETCH)
|
|
|
|
def test_handle_client_in_loop(self):
|
|
self.assertIn('server.handleClient()', WEBSERVER_SKETCH)
|
|
|
|
def test_handle_root_sends_html(self):
|
|
self.assertIn('server.send(200, "text/html"', WEBSERVER_SKETCH)
|
|
|
|
|
|
class TestWifiAutoDetection(unittest.TestCase):
|
|
"""Test that the sketch triggers WiFi auto-detection."""
|
|
|
|
@staticmethod
|
|
def detect_wifi(content: str) -> bool:
|
|
return any(pattern in content for pattern in [
|
|
'#include <WiFi.h>',
|
|
'#include <esp_wifi.h>',
|
|
'#include "WiFi.h"',
|
|
'WiFi.begin(',
|
|
])
|
|
|
|
def test_detects_wifi_in_webserver_sketch(self):
|
|
self.assertTrue(self.detect_wifi(WEBSERVER_SKETCH))
|
|
|
|
def test_detects_via_wifi_h_include(self):
|
|
self.assertIn('#include <WiFi.h>', WEBSERVER_SKETCH)
|
|
|
|
def test_detects_via_wifi_begin(self):
|
|
self.assertIn('WiFi.begin(', WEBSERVER_SKETCH)
|
|
|
|
def test_no_detection_for_blink_sketch(self):
|
|
blink = 'void setup() { pinMode(13, OUTPUT); }\nvoid loop() { digitalWrite(13, HIGH); delay(1000); }'
|
|
self.assertFalse(self.detect_wifi(blink))
|
|
|
|
|
|
class TestQemuNicArgs(unittest.TestCase):
|
|
"""Test QEMU -nic arg construction for the WebServer sketch."""
|
|
|
|
@staticmethod
|
|
def build_args(wifi_enabled: bool, machine: str = 'esp32-picsimlab',
|
|
hostfwd_port: int = 0) -> list[bytes]:
|
|
args = [b'qemu', b'-M', machine.encode(), b'-nographic']
|
|
if wifi_enabled:
|
|
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
|
|
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
|
|
if hostfwd_port:
|
|
nic_arg += f',hostfwd=tcp::{hostfwd_port}-192.168.4.15:80'
|
|
args.extend([b'-nic', nic_arg.encode()])
|
|
return args
|
|
|
|
def test_wifi_enabled_adds_nic(self):
|
|
args = self.build_args(wifi_enabled=True)
|
|
self.assertIn(b'-nic', args)
|
|
|
|
def test_nic_uses_esp32_wifi_model(self):
|
|
args = self.build_args(wifi_enabled=True)
|
|
nic_val = args[args.index(b'-nic') + 1].decode()
|
|
self.assertIn('model=esp32_wifi', nic_val)
|
|
|
|
def test_nic_uses_192_168_4_subnet(self):
|
|
args = self.build_args(wifi_enabled=True)
|
|
nic_val = args[args.index(b'-nic') + 1].decode()
|
|
self.assertIn('net=192.168.4.0/24', nic_val)
|
|
|
|
def test_hostfwd_maps_to_port_80(self):
|
|
"""WebServer listens on port 80, hostfwd should route to it."""
|
|
args = self.build_args(wifi_enabled=True, hostfwd_port=54321)
|
|
nic_val = args[args.index(b'-nic') + 1].decode()
|
|
self.assertIn('hostfwd=tcp::54321-192.168.4.15:80', nic_val)
|
|
|
|
def test_no_hostfwd_when_port_zero(self):
|
|
args = self.build_args(wifi_enabled=True, hostfwd_port=0)
|
|
nic_val = args[args.index(b'-nic') + 1].decode()
|
|
self.assertNotIn('hostfwd', nic_val)
|
|
|
|
def test_wifi_disabled_no_nic(self):
|
|
args = self.build_args(wifi_enabled=False)
|
|
self.assertNotIn(b'-nic', args)
|
|
|
|
|
|
class TestSerialOutputParsing(unittest.TestCase):
|
|
"""Test WiFi/BLE serial parser with the simulated QEMU output."""
|
|
|
|
def test_parse_wifi_events_from_serial(self):
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
wifi_events, ble_events = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
|
|
# Should have at least: initializing, connected, got_ip
|
|
self.assertGreaterEqual(len(wifi_events), 3)
|
|
|
|
def test_first_event_is_initializing(self):
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
wifi_events, _ = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
self.assertEqual(wifi_events[0]['status'], 'initializing')
|
|
|
|
def test_connected_event_has_velxio_guest_ssid(self):
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
wifi_events, _ = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
connected = [e for e in wifi_events if e['status'] == 'connected']
|
|
self.assertTrue(len(connected) > 0)
|
|
self.assertIn('Velxio-GUEST', connected[0].get('ssid', ''))
|
|
|
|
def test_got_ip_event_has_correct_ip(self):
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
wifi_events, _ = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
got_ip = [e for e in wifi_events if e['status'] == 'got_ip']
|
|
self.assertEqual(len(got_ip), 1)
|
|
self.assertEqual(got_ip[0]['ip'], '192.168.4.2')
|
|
|
|
def test_ip_in_slirp_subnet(self):
|
|
"""QEMU slirp assigns IPs in 192.168.4.x range."""
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
wifi_events, _ = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
got_ip = [e for e in wifi_events if e['status'] == 'got_ip']
|
|
self.assertTrue(got_ip[0]['ip'].startswith('192.168.4.'))
|
|
|
|
def test_no_ble_events_in_wifi_only_sketch(self):
|
|
from app.services.wifi_status_parser import parse_serial_text
|
|
_, ble_events = parse_serial_text(SIMULATED_SERIAL_OUTPUT)
|
|
self.assertEqual(len(ble_events), 0)
|
|
|
|
|
|
class TestHttpServerDetection(unittest.TestCase):
|
|
"""Detect HTTP server from serial output patterns."""
|
|
|
|
@staticmethod
|
|
def detect_http_server(serial_output: str) -> dict:
|
|
import re
|
|
server_patterns = [
|
|
r'[Ss]erver\s+at:\s*([\d.]+)',
|
|
r'[Ss]ervidor\s+HTTP\s+iniciado',
|
|
]
|
|
ip_pattern = r'IP:\s*([\d.]+)'
|
|
has_server = any(re.search(p, serial_output) for p in server_patterns)
|
|
ip_match = re.search(ip_pattern, serial_output)
|
|
return {
|
|
'detected': has_server,
|
|
'ip': ip_match.group(1) if ip_match else None,
|
|
}
|
|
|
|
def test_detects_http_server(self):
|
|
result = self.detect_http_server(SIMULATED_SERIAL_OUTPUT)
|
|
self.assertTrue(result['detected'])
|
|
|
|
def test_extracts_ip_address(self):
|
|
result = self.detect_http_server(SIMULATED_SERIAL_OUTPUT)
|
|
self.assertEqual(result['ip'], '192.168.4.2')
|
|
|
|
def test_no_detection_for_blink_output(self):
|
|
result = self.detect_http_server('Hello World!\nBlink LED\n')
|
|
self.assertFalse(result['detected'])
|
|
self.assertIsNone(result['ip'])
|
|
|
|
|
|
class TestIoTGatewayUrl(unittest.TestCase):
|
|
"""Test IoT Gateway URL construction for the WebServer sketch."""
|
|
|
|
def test_root_gateway_url(self):
|
|
client_id = 'board-1'
|
|
url = f'http://localhost:8001/api/gateway/{client_id}/'
|
|
self.assertEqual(url, 'http://localhost:8001/api/gateway/board-1/')
|
|
|
|
def test_gateway_proxies_to_hostfwd_port(self):
|
|
hostfwd_port = 54321
|
|
esp_url = f'http://127.0.0.1:{hostfwd_port}/'
|
|
self.assertIn(':54321/', esp_url)
|
|
|
|
|
|
class TestFreePortAllocation(unittest.TestCase):
|
|
"""Test that _find_free_port returns a valid port for hostfwd."""
|
|
|
|
def test_find_free_port(self):
|
|
from app.api.routes.simulation import _find_free_port
|
|
port = _find_free_port()
|
|
self.assertIsInstance(port, int)
|
|
self.assertGreater(port, 0)
|
|
self.assertLess(port, 65536)
|
|
|
|
def test_free_port_is_usable(self):
|
|
from app.api.routes.simulation import _find_free_port
|
|
port = _find_free_port()
|
|
# Verify we can bind to the returned port
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
s.bind(('127.0.0.1', port))
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|