Skip to content

Commit ce507a9

Browse files
committed
tests: Add test coverage for console and terminal handling
Add unit tests for the terminal handling in term.py, covering the external console launch (microcom and telnet fallback), the internal console read/write loop, and terminal setup and teardown. Also add tests for the new client methods including is_allowed(), set_initial_state(), set_end_state() and the new parser arguments. Fix an UnboundLocalError in term.internal() where log_fd could be referenced in the finally block before being assigned. Signed-off-by: Simon Glass <sjg@chromium.org> Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 410e900 commit ce507a9

3 files changed

Lines changed: 668 additions & 2 deletions

File tree

labgrid/util/term.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ async def external(check_allowed, host, port, resource, logfile, listen_only):
5353
if logfile:
5454
logging.warning("--logfile option not supported by telnet, ignoring")
5555

56-
if logfile:
57-
call.append(f"--logfile={logfile}")
5856
logging.info("connecting to %s calling %s", resource, " ".join(call))
5957
p = await asyncio.create_subprocess_exec(*call)
6058
while p.returncode is None:
@@ -131,6 +129,7 @@ async def run(check_allowed, cons, log_fd, listen_only):
131129

132130
to_cons += data
133131

132+
# Drain one byte at a time, honouring txdelay between bytes
134133
if to_cons and time.monotonic() > next_cons:
135134
cons._write(to_cons[:1])
136135
to_cons = to_cons[1:]

tests/test_client_unit.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Unit tests for labgrid.remote.client"""
2+
3+
import argparse
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from labgrid.remote.client import ClientSession, UserError, get_parser
9+
10+
11+
# --- is_allowed() tests ---
12+
13+
@pytest.fixture
14+
def session():
15+
"""Create a minimal ClientSession-like object for testing"""
16+
s = object.__new__(ClientSession)
17+
s.args = argparse.Namespace()
18+
return s
19+
20+
21+
@pytest.fixture
22+
def mock_place():
23+
place = MagicMock()
24+
place.name = "testplace"
25+
place.acquired = "myhost/myuser"
26+
place.allowed = {"myhost/myuser"}
27+
return place
28+
29+
30+
class TestIsAllowed:
31+
def test_place_not_acquired(self, session, mock_place):
32+
mock_place.acquired = None
33+
with patch.object(session, "gethostname", return_value="myhost"), \
34+
patch.object(session, "getuser", return_value="myuser"):
35+
result = session.is_allowed(mock_place)
36+
assert "not acquired" in result
37+
38+
def test_place_acquired_by_us(self, session, mock_place):
39+
with patch.object(session, "gethostname", return_value="myhost"), \
40+
patch.object(session, "getuser", return_value="myuser"):
41+
result = session.is_allowed(mock_place)
42+
assert result is None
43+
44+
def test_place_acquired_by_different_user(self, session, mock_place):
45+
mock_place.acquired = "myhost/otheruser"
46+
mock_place.allowed = {"myhost/otheruser"}
47+
with patch.object(session, "gethostname", return_value="myhost"), \
48+
patch.object(session, "getuser", return_value="myuser"):
49+
result = session.is_allowed(mock_place)
50+
assert "not acquired by your user" in result
51+
assert "otheruser" in result
52+
53+
def test_place_acquired_on_different_host(self, session, mock_place):
54+
mock_place.acquired = "otherhost/myuser"
55+
mock_place.allowed = {"otherhost/myuser"}
56+
with patch.object(session, "gethostname", return_value="myhost"), \
57+
patch.object(session, "getuser", return_value="myuser"):
58+
result = session.is_allowed(mock_place)
59+
assert "not acquired on this computer" in result
60+
assert "otherhost" in result
61+
62+
def test_place_acquired_elsewhere_but_allowed(self, session, mock_place):
63+
"""User is in the allowed set even though place was acquired elsewhere"""
64+
mock_place.acquired = "otherhost/otheruser"
65+
mock_place.allowed = {"otherhost/otheruser", "myhost/myuser"}
66+
with patch.object(session, "gethostname", return_value="myhost"), \
67+
patch.object(session, "getuser", return_value="myuser"):
68+
result = session.is_allowed(mock_place)
69+
assert result is None
70+
71+
72+
# --- _check_allowed() tests ---
73+
74+
class TestCheckAllowed:
75+
def test_raises_on_not_allowed(self, session, mock_place):
76+
mock_place.acquired = None
77+
with patch.object(session, "gethostname", return_value="myhost"), \
78+
patch.object(session, "getuser", return_value="myuser"):
79+
with pytest.raises(UserError, match="not acquired"):
80+
session._check_allowed(mock_place)
81+
82+
def test_no_raise_when_allowed(self, session, mock_place):
83+
with patch.object(session, "gethostname", return_value="myhost"), \
84+
patch.object(session, "getuser", return_value="myuser"):
85+
session._check_allowed(mock_place) # should not raise
86+
87+
88+
# --- set_initial_state() / set_end_state() tests ---
89+
90+
class TestStateTransitions:
91+
def test_set_initial_state_no_state(self, session):
92+
"""No-op when args.state is not set"""
93+
session.args.state = None
94+
target = MagicMock()
95+
session.set_initial_state(target)
96+
target.get_driver.assert_not_called()
97+
98+
def test_set_initial_state_with_state(self, session):
99+
session.args.state = "uboot"
100+
session.args.initial_state = None
101+
target = MagicMock()
102+
strategy = MagicMock()
103+
target.get_driver.return_value = strategy
104+
105+
session.set_initial_state(target)
106+
107+
target.get_driver.assert_called_once_with("Strategy")
108+
strategy.transition.assert_called_once_with("uboot")
109+
strategy.force.assert_not_called()
110+
111+
def test_set_initial_state_with_force(self, session):
112+
session.args.state = "uboot"
113+
session.args.initial_state = "off"
114+
target = MagicMock()
115+
strategy = MagicMock()
116+
target.get_driver.return_value = strategy
117+
118+
session.set_initial_state(target)
119+
120+
strategy.force.assert_called_once_with("off")
121+
strategy.transition.assert_called_once_with("uboot")
122+
123+
def test_set_end_state_no_state(self, session):
124+
session.args.end_state = None
125+
target = MagicMock()
126+
session.set_end_state(target)
127+
target.get_driver.assert_not_called()
128+
129+
def test_set_end_state_with_state(self, session):
130+
session.args.end_state = "off"
131+
target = MagicMock()
132+
strategy = MagicMock()
133+
target.get_driver.return_value = strategy
134+
135+
session.set_end_state(target)
136+
137+
target.get_driver.assert_called_once_with("Strategy")
138+
strategy.transition.assert_called_once_with("off")
139+
140+
def test_set_end_state_none_target_no_state(self, session):
141+
"""set_end_state with no end_state is a no-op even with None target"""
142+
session.args.end_state = None
143+
session.set_end_state(None) # should not raise
144+
145+
146+
# --- get_parser() tests ---
147+
148+
class TestGetParser:
149+
def test_acquire_argument(self):
150+
parser = get_parser()
151+
args = parser.parse_args(["-a", "acquire"])
152+
assert args.acquire is True
153+
154+
def test_end_state_argument(self):
155+
parser = get_parser()
156+
args = parser.parse_args(["-e", "off", "places"])
157+
assert args.end_state == "off"
158+
159+
def test_release_force_argument(self):
160+
parser = get_parser()
161+
args = parser.parse_args(["release", "--force"])
162+
assert args.force is True
163+
164+
def test_console_internal_argument(self):
165+
parser = get_parser()
166+
args = parser.parse_args(["console", "--internal"])
167+
assert args.internal is True
168+
169+
def test_acquire_flag(self):
170+
parser = get_parser()
171+
args = parser.parse_args(["-a", "places"])
172+
assert args.acquire is True
173+
174+
def test_defaults(self):
175+
parser = get_parser()
176+
args = parser.parse_args(["places"])
177+
assert args.acquire is False
178+
assert args.end_state is None
179+
180+
def test_release_has_no_auto_attribute(self):
181+
"""The release subparser defines --force, not --auto.
182+
183+
The _release_place() code checks args.auto, but that attribute
184+
is never defined by any argument. This test documents the
185+
mismatch — it should probably be args.force instead.
186+
"""
187+
parser = get_parser()
188+
args = parser.parse_args(["release"])
189+
assert not hasattr(args, "auto") or args.auto is None
190+
assert hasattr(args, "force")

0 commit comments

Comments
 (0)