216 lines
9.7 KiB
Python
216 lines
9.7 KiB
Python
"""Read-only serial ownership preflight and delayed USB reply regressions."""
|
|
|
|
import importlib.util
|
|
import contextlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
BASE = Path(__file__).resolve().parents[1] / "tools/hil"
|
|
sys.path.insert(0, str(BASE))
|
|
import serial_session as session
|
|
import ethernet_primary_probe as primary
|
|
|
|
|
|
class PortOwnershipTests(unittest.TestCase):
|
|
def run_inspector(self, result):
|
|
return patch.object(session.subprocess, "run", return_value=result)
|
|
|
|
def test_existing_owner_refuses_without_killing_anything(self):
|
|
with patch.object(session.sys, "platform", "linux"), \
|
|
patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \
|
|
self.run_inspector(SimpleNamespace(returncode=0)) as run:
|
|
with self.assertRaisesRegex(RuntimeError, "already in use"):
|
|
session.assert_port_idle("/dev/ttyUSB0")
|
|
self.assertEqual(run.call_args.args[0][:2], ["/usr/bin/fuser", "--"])
|
|
self.assertNotIn("-k", run.call_args.args[0])
|
|
|
|
def test_idle_port_is_allowed(self):
|
|
with patch.object(session.sys, "platform", "linux"), \
|
|
patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \
|
|
self.run_inspector(SimpleNamespace(returncode=1)):
|
|
session.assert_port_idle("/dev/ttyUSB0")
|
|
|
|
def test_inspection_failure_refuses(self):
|
|
with patch.object(session.sys, "platform", "linux"), \
|
|
patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \
|
|
self.run_inspector(SimpleNamespace(returncode=2)):
|
|
with self.assertRaisesRegex(RuntimeError, "fuser error"):
|
|
session.assert_port_idle("/dev/ttyUSB0")
|
|
|
|
def test_timeout_refuses(self):
|
|
with patch.object(session.sys, "platform", "linux"), \
|
|
patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \
|
|
patch.object(session.subprocess, "run", side_effect=subprocess.TimeoutExpired("fuser", 3)):
|
|
with self.assertRaisesRegex(RuntimeError, "Cannot safely inspect"):
|
|
session.assert_port_idle("/dev/ttyUSB0")
|
|
|
|
def test_missing_optional_inspector_does_not_spawn_a_process(self):
|
|
with patch.object(session.sys, "platform", "linux"), \
|
|
patch.object(session.shutil, "which", return_value=None), \
|
|
patch.object(session.subprocess, "run") as run:
|
|
session.assert_port_idle("/dev/ttyUSB0")
|
|
run.assert_not_called()
|
|
|
|
def test_non_linux_does_not_try_fuser(self):
|
|
with patch.object(session.sys, "platform", "win32"), \
|
|
patch.object(session.subprocess, "run") as run:
|
|
session.assert_port_idle("COM4")
|
|
run.assert_not_called()
|
|
|
|
|
|
class DelayedPort:
|
|
def __init__(self, clock, first_byte=1):
|
|
self.clock = clock
|
|
self.first_byte = first_byte
|
|
self.sent = False
|
|
self.in_waiting = 0
|
|
|
|
def read(self, count):
|
|
self.clock.now += 0.04
|
|
if not self.sent and self.clock.now >= self.first_byte:
|
|
self.sent = True
|
|
return b"delayed reply\r\n"
|
|
return b""
|
|
|
|
|
|
class DelayedReplyTests(unittest.TestCase):
|
|
def bench_module(self):
|
|
# Avoid importing optional pySerial just to exercise the pure read loop.
|
|
from types import ModuleType
|
|
serial = ModuleType("serial")
|
|
tools = ModuleType("serial.tools")
|
|
tools.list_ports = ModuleType("serial.tools.list_ports")
|
|
spec = importlib.util.spec_from_file_location("ethernet_bench_delayed_test", BASE / "ethernet_bench_serial.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
with patch.dict(sys.modules, {"serial": serial, "serial.tools": tools,
|
|
"serial.tools.list_ports": tools.list_ports}):
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
def test_bench_waits_full_deadline_for_first_reply_byte(self):
|
|
module = self.bench_module()
|
|
clock = SimpleNamespace(now=0)
|
|
port = DelayedPort(clock, first_byte=1.2)
|
|
with patch.object(module.time, "monotonic", side_effect=lambda: clock.now):
|
|
self.assertEqual(module.read_quiet(port, timeout=3), "delayed reply\r\n")
|
|
self.assertGreaterEqual(clock.now, 1.2)
|
|
self.assertLess(clock.now, 2)
|
|
|
|
|
|
class TimedChunks:
|
|
def __init__(self, clock, events):
|
|
self.clock = clock
|
|
self.events = list(events)
|
|
self.pending = bytearray()
|
|
|
|
@property
|
|
def in_waiting(self):
|
|
return len(self.pending)
|
|
|
|
def read(self, count):
|
|
self.clock.now += 0.04
|
|
while self.events and self.events[0][0] <= self.clock.now:
|
|
self.pending.extend(self.events.pop(0)[1])
|
|
out = bytes(self.pending[:count])
|
|
del self.pending[:count]
|
|
return out
|
|
|
|
|
|
class CommandTransactionTests(unittest.TestCase):
|
|
def transact(self, events, command="set radio 909.5,500,5,5", timeout=3):
|
|
clock = SimpleNamespace(now=0)
|
|
port = TimedChunks(clock, events)
|
|
with patch.object(session.time, "monotonic", side_effect=lambda: clock.now):
|
|
reply = session.read_command_reply(port, command, timeout)
|
|
return reply, clock.now
|
|
|
|
def test_immediate_echo_then_long_nvs_pause_waits_for_actual_reply(self):
|
|
reply, elapsed = self.transact([
|
|
(0.04, b"> set radio 909.5,500,5,5\r\n"),
|
|
(1.1, b" -> OK - saved\r\n> ")])
|
|
self.assertIn("OK - saved", reply)
|
|
self.assertGreaterEqual(elapsed, 1.1)
|
|
|
|
def test_stale_other_command_reply_and_prompt_cannot_complete_this_command(self):
|
|
reply, elapsed = self.transact([
|
|
(0.04, b" -> OK - previous command\r\n> "),
|
|
(0.7, b"set radio 909.5,500,5,5\r\n"),
|
|
(1.4, b" -> OK - current command\r\n> ")])
|
|
self.assertIn("OK - current command", reply)
|
|
self.assertGreaterEqual(elapsed, 1.4)
|
|
|
|
def test_wrong_echo_does_not_count_even_with_prompt(self):
|
|
clock = SimpleNamespace(now=0)
|
|
port = TimedChunks(clock, [(0.04, b"> get radio\r\n -> > 909.5,500,5,5\r\n> ")])
|
|
with patch.object(session.time, "monotonic", side_effect=lambda: clock.now):
|
|
with self.assertRaises(session.SerialCommandTimeout) as caught:
|
|
session.read_command_reply(port, "get radio2", timeout=1)
|
|
self.assertIn("get radio", caught.exception.partial_reply)
|
|
self.assertGreaterEqual(clock.now, 1)
|
|
|
|
def test_getter_value_prefix_is_not_a_completion_prompt(self):
|
|
reply, elapsed = self.transact([
|
|
(0.04, b"> get radio\r\n -> > 909.5,500,5,5\r\n"),
|
|
(0.9, b">"), (1.2, b" ")], command="get radio")
|
|
self.assertTrue(reply.endswith("> "))
|
|
self.assertGreaterEqual(elapsed, 1.2)
|
|
|
|
def test_masked_password_echo_is_matched_exactly_without_requiring_plaintext(self):
|
|
reply, _ = self.transact([(0.04, b"> set wifi.pwd ********\r\n"),
|
|
(1.0, b" -> OK\r\n> ")], command="set wifi.pwd secret42")
|
|
self.assertNotIn("secret42", reply)
|
|
self.assertIn("OK", reply)
|
|
|
|
def test_timeout_stops_remaining_commands_and_still_attempts_stop(self):
|
|
module = DelayedReplyTests().bench_module()
|
|
port = Mock(is_open=True)
|
|
info = SimpleNamespace(device="/dev/ttyUSB-test", serial_number="test", vid=0x1A86, pid=0x7523)
|
|
output = io.StringIO()
|
|
args = ["ethernet_bench_serial.py", "--port", "/dev/ttyUSB-test",
|
|
"--command", "set radio 909.5,500,5,5", "--command", "get radio"]
|
|
with patch.object(module.serial, "Serial", return_value=port, create=True), \
|
|
patch.object(module.list_ports, "comports", return_value=[info], create=True), \
|
|
patch.object(module, "assert_port_idle"), \
|
|
patch.object(module, "open_configured_session"), \
|
|
patch.object(module, "read_quiet", return_value="WELCOME"), \
|
|
patch.object(module, "read_command_reply", side_effect=session.SerialCommandTimeout("echo only")), \
|
|
patch.object(sys, "argv", args), contextlib.redirect_stdout(output):
|
|
self.assertEqual(module.main(), 1)
|
|
written = [call.args[0] for call in port.write.call_args_list]
|
|
self.assertNotIn(b"get radio\n", written)
|
|
self.assertEqual(written[-1], b"+++MESHCORE-TERM-STOP\n")
|
|
self.assertFalse(json.loads(output.getvalue())["commands"][0]["completed"])
|
|
port.close.assert_called_once()
|
|
|
|
def test_bench_stops_after_post_reply_quiet_interval(self):
|
|
module = DelayedReplyTests().bench_module()
|
|
clock = SimpleNamespace(now=0)
|
|
with patch.object(module.time, "monotonic", side_effect=lambda: clock.now):
|
|
module.read_quiet(DelayedPort(clock, first_byte=0), timeout=3)
|
|
self.assertLess(clock.now, 0.6)
|
|
|
|
def test_bench_silence_waits_whole_deadline(self):
|
|
module = DelayedReplyTests().bench_module()
|
|
clock = SimpleNamespace(now=0)
|
|
with patch.object(module.time, "monotonic", side_effect=lambda: clock.now):
|
|
self.assertEqual(module.read_quiet(DelayedPort(clock, first_byte=99), timeout=3), "")
|
|
self.assertGreaterEqual(clock.now, 3)
|
|
|
|
def test_primary_helper_also_waits_for_delayed_first_byte(self):
|
|
clock = SimpleNamespace(now=0)
|
|
node = SimpleNamespace(port=DelayedPort(clock, first_byte=1.2))
|
|
with patch.object(primary.time, "monotonic", side_effect=lambda: clock.now):
|
|
self.assertEqual(primary.SerialNode.read(node, timeout=3), "delayed reply\r\n")
|
|
self.assertGreaterEqual(clock.now, 1.2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|