From 43337bfa67bde953a5296e9eba780561e7c9c21c Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Tue, 8 Sep 2026 09:17:23 -0500 Subject: [PATCH] Scope: set_channel_coupling() iterated items() and raised on every call Connecting to the oscilloscope failed with "'list' object has no attribute 'upper'" whatever the IP was. configure_channels() runs on connect and sets the coupling on all four channels; set_channel_coupling() looped over CHANNEL_COUPLING.items(), so `variants` was the whole ('AC', ['AC']) pair and the comprehension called .upper() on the list half. It raised before looking at the value it was given, so no coupling could ever be accepted. The tuple unpacking was dropped from `for long_form, variants in ...items()` in 709dc529 without changing .items() to .values(); the four sibling validators (acquire mode, trigger slope, trigger mode, data encoding) all use .values(). The suite missed it because tests/fakes.py FakeScope replaces these setters with recording stubs, so nothing exercised the real class. The new test drives TektronixOscilloscopeBase itself with only the socket replaced, covering the connect-time path and the other validated setters alongside. Co-Authored-By: Claude Opus 5 --- hardware/tektronix_base.py | 2 +- tests/test_tektronix_base.py | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/test_tektronix_base.py diff --git a/hardware/tektronix_base.py b/hardware/tektronix_base.py index 54f8f3a..54c946f 100755 --- a/hardware/tektronix_base.py +++ b/hardware/tektronix_base.py @@ -292,7 +292,7 @@ class TektronixOscilloscopeBase: # Validate coupling 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]: valid = True break diff --git a/tests/test_tektronix_base.py b/tests/test_tektronix_base.py new file mode 100644 index 0000000..16ba18d --- /dev/null +++ b/tests/test_tektronix_base.py @@ -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)