Merge fix-scope-channel-coupling: the scope could not be connected to

set_channel_coupling() looped over CHANNEL_COUPLING.items(), so it called
.upper() on the list half of each pair and raised "'list' object has no
attribute 'upper'" before looking at its argument.  configure_channels()
sets the coupling on every channel as part of connecting, so no IP would
work.  Fixed to .values(), with a test that drives the real instrument
class rather than the FakeScope that stubbed the setters out.
This commit is contained in:
Thomas Ales
2026-09-08 09:45:33 -05:00
2 changed files with 72 additions and 1 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ class TektronixOscilloscopeBase:
# Validate coupling # Validate coupling
valid = False valid = False
for variants in self.CHANNEL_COUPLING.items(): for variants in self.CHANNEL_COUPLING.values():
if coupling_upper in [v.upper() for v in variants]: if coupling_upper in [v.upper() for v in variants]:
valid = True valid = True
break break
+71
View File
@@ -0,0 +1,71 @@
"""TektronixOscilloscopeBase: the SCPI setters that validate their input.
These run against the real class with only the socket replaced. The scan
tests use FakeScope, which stubs the setters out entirely — so a setter
could raise on every call and nothing in the suite would notice, which is
what happened: set_channel_coupling() iterated CHANNEL_COUPLING.items()
instead of .values(), called .upper() on the list half of each pair, and
raised AttributeError for any coupling at all. configure_channels() runs
on connect, so the scope could not be connected to.
"""
import pytest
from core.scope_sras import SRAS_CHANNELS, configure_channels
from hardware.tektronix_base import TektronixOscilloscopeBase
class RecordingScope(TektronixOscilloscopeBase):
"""The real instrument class with the wire replaced by a list."""
def __init__(self):
super().__init__(resource_name="192.0.2.1")
self._connected = True
self.written: list[str] = []
def write(self, command):
self.written.append(command)
@pytest.fixture
def scope():
return RecordingScope()
def test_connect_time_channel_setup_reaches_the_wire(scope):
"""The regression: every command configure_channels() sends must go out."""
configure_channels(scope)
for ch, profile in SRAS_CHANNELS.items():
assert f"SELect:CH{ch} ON" in scope.written
assert f"CH{ch}:COUPling {profile.coupling}" in scope.written
assert f"CH{ch}:TERmination {profile.termination_ohm}" in scope.written
assert f"CH{ch}:SCAle {profile.scale_v_div}" in scope.written
@pytest.mark.parametrize("coupling", ["DC", "AC", "dc", "ac"])
def test_channel_coupling_accepts_both_modes_in_any_case(scope, coupling):
scope.set_channel_coupling(1, coupling)
assert scope.written == [f"CH1:COUPling {coupling}"]
def test_channel_coupling_rejects_an_unknown_mode(scope):
"""Rejection has to be the documented ValueError, not an AttributeError
raised while building the list of valid options."""
with pytest.raises(ValueError, match="Invalid coupling mode"):
scope.set_channel_coupling(1, "GND")
assert scope.written == []
# The other setters share this shape; a table keeps them honest together.
@pytest.mark.parametrize("setter,good,bad,sent", [
("set_acquire_mode", "AVERAGE", "SMOOTH", "ACQuire:MODe AVERAGE"),
("set_acquire_mode", "SAM", "SMOOTH", "ACQuire:MODe SAM"),
("set_trigger_slope", "RISE", "SIDEWAYS", "TRIGger:A:EDGE:SLOpe RISE"),
("set_trigger_mode", "NORMAL", "SOMETIMES", "TRIGger:A:MODe NORMAL"),
])
def test_validated_setters_take_valid_values_and_reject_the_rest(
scope, setter, good, bad, sent):
getattr(scope, setter)(good)
assert scope.written == [sent]
with pytest.raises(ValueError):
getattr(scope, setter)(bad)