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 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-08 09:17:23 -05:00
parent 92bbf02c8e
commit 43337bfa67
2 changed files with 72 additions and 1 deletions
+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)