Skip to content

Commit 2eba383

Browse files
dccoteclaude
andcommitted
Cover SR830 discovery, guards, and debug-port paths with tests
Problem: SR830Device.doInitializeDevice and _candidateAdaptors (the FTDI adaptor probe/confirm/pin logic) were only exercised on real hardware, because DebugSR830Device overrides doInitializeDevice. Several defensive branches (unsupported-enum guards, the sensitivity clamp, base-capability defaults, and DebugPrologixGPIBPort conveniences) were also untested, and the Prologix handshake test asserted the pre-fix command set without exercising the real port. Solution: add hardware-free tests. TestSR830Discovery patches comports() and PrologixGPIBPort to cover discovery, serial pinning, serial-number narrowing, explicit portPath, and both failure raises. Add TestDebugPrologixGPIBPort for the port primitives, TestCapabilityDefaults for the base optional hooks and base getDemodulatedValues, and debug-device tests for the enum guards, the sensitivity clamp, supportedSampleRates, and the aux/frequency SNAP codes. Rewrite the handshake test to exercise the real configureController and assert the manual-read (++auto 0, ++eot_enable 0) handshake. This takes sr830device.py and prologixgpibport.py to 100% line coverage (62 tests, all passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2DmAfLHSiK1fb3PiXDyJP
1 parent c3da636 commit 2eba383

1 file changed

Lines changed: 255 additions & 16 deletions

File tree

hardwarelibrary/tests/testSR830.py

Lines changed: 255 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import env
22
import unittest
3+
from unittest import mock
34

45
from hardwarelibrary.physicaldevice import PhysicalDevice
6+
from hardwarelibrary.communication import PrologixGPIBPort
7+
from hardwarelibrary.communication.communicationport import CommunicationReadTimeout
58
from hardwarelibrary.daq import (
69
AnalogInputDevice, AnalogOutputDevice, AnalogInputStreamDevice,
710
PhaseLockedDetectionDevice, TriggerableDevice,
811
InputSource, AuxInput, AuxOutput, StreamChannel, TriggerSource, SampleClock,
912
SR830Device, DebugSR830Device, DebugPrologixGPIBPort,
1013
)
1114

15+
SR830_IDN = "Stanford_Research_Systems,SR830,s/n86552,ver1.07"
16+
1217

1318
class TestDebugSR830Device(unittest.TestCase):
1419
def setUp(self):
@@ -183,6 +188,35 @@ def testSampleRateSnapsToNearest(self):
183188
self.assertEqual(self.device._sampleRateIndexFor(512), self.device.sampleRates.index(512))
184189
self.assertEqual(self.device._sampleRateIndexFor(60), self.device.sampleRates.index(64))
185190

191+
def testSupportedSampleRates(self):
192+
rates = self.device.supportedSampleRates()
193+
self.assertEqual(rates, sorted(rates))
194+
self.assertEqual(rates[-1], 512)
195+
196+
def testSensitivityClampsToLargestStep(self):
197+
# A request above the 1 V maximum full-scale clamps to that largest step
198+
# rather than raising.
199+
self.device.setSensitivity(100.0)
200+
self.assertAlmostEqual(self.device.getSensitivity(), 1.0)
201+
202+
def testSetTriggerSourceRejectsUnknown(self):
203+
with self.assertRaises(ValueError):
204+
self.device.setTriggerSource(SampleClock.Internal)
205+
206+
def testSetInputSourceRejectsUnknown(self):
207+
with self.assertRaises(ValueError):
208+
self.device.setInputSource(SampleClock.Internal)
209+
210+
def testSnapReadsAuxAndFrequencyCodes(self):
211+
# Codes 5-8 are Aux1-4, 9 is the reference frequency, and an unknown code
212+
# reads back as 0.0.
213+
values = self.device.snap(5, 6, 7, 8, 9, 99)
214+
self.assertEqual(len(values), 6)
215+
self.assertAlmostEqual(values[0], 0.10)
216+
self.assertAlmostEqual(values[3], 0.40)
217+
self.assertAlmostEqual(values[4], 1.0e3)
218+
self.assertAlmostEqual(values[5], 0.0)
219+
186220

187221
class TestPhaseLockedDetectionContract(unittest.TestCase):
188222
def testDeclaresAllCapabilities(self):
@@ -194,25 +228,84 @@ def testDeclaresAllCapabilities(self):
194228
self.assertIsInstance(device, TriggerableDevice)
195229

196230

197-
class TestPrologixGPIBPort(unittest.TestCase):
198-
def testControllerHandshakeOnOpen(self):
199-
port = DebugPrologixGPIBPort()
200-
sent = []
201-
original = port.writeData
231+
class _MinimalLockIn(PhaseLockedDetectionDevice, TriggerableDevice):
232+
"""A bare capability implementation that supplies only the abstract hooks, so
233+
the base-class optional hooks and the base getDemodulatedValues are exercised
234+
(SR830Device overrides all of these)."""
202235

203-
def recordingWriteData(data, endPoint=None):
204-
sent.append(bytes(data).decode("utf-8").strip())
205-
return original(data, endPoint)
236+
def getInPhaseVoltage(self):
237+
return 0.1
206238

207-
port.writeData = recordingWriteData
208-
port.open()
209-
# Simulate the same handshake PrologixGPIBPort.open() sends.
210-
for command in ("++mode 1", "++addr 8", "++auto 1", "++eoi 1", "++eos 2",
211-
"++eot_enable 1", "++eot_char 10", "++read_tmo_ms 1500"):
212-
port.writeString(command + "\n")
213-
self.assertIn("++mode 1", sent)
239+
def getQuadratureVoltage(self):
240+
return 0.2
241+
242+
def getMagnitude(self):
243+
return 0.3
244+
245+
def getPhase(self):
246+
return 45.0
247+
248+
def getReferenceFrequency(self):
249+
return 1000.0
250+
251+
def getInputSource(self):
252+
return InputSource.SingleEnded
253+
254+
def setInputSource(self, source):
255+
pass
256+
257+
def getSensitivity(self):
258+
return 1.0
259+
260+
def setSensitivity(self, volts):
261+
pass
262+
263+
def getTimeConstant(self):
264+
return 0.1
265+
266+
def setTimeConstant(self, seconds):
267+
pass
268+
269+
def setTriggerSource(self, source):
270+
pass
271+
272+
def getTriggerSource(self):
273+
return TriggerSource.Internal
274+
275+
def softwareTrigger(self):
276+
pass
277+
278+
279+
class TestCapabilityDefaults(unittest.TestCase):
280+
def testOptionalHooksDefaultToNone(self):
281+
device = _MinimalLockIn()
282+
self.assertIsNone(device.supportedInputSources())
283+
self.assertIsNone(device.supportedSensitivities())
284+
self.assertIsNone(device.supportedTimeConstants())
285+
self.assertIsNone(device.supportedTriggerSources())
286+
287+
def testBaseGetDemodulatedValues(self):
288+
values = _MinimalLockIn().getDemodulatedValues()
289+
self.assertEqual(set(values),
290+
{"X", "Y", "R", "theta", "referenceFrequency"})
291+
self.assertAlmostEqual(values["R"], 0.3)
292+
self.assertAlmostEqual(values["referenceFrequency"], 1000.0)
293+
294+
295+
class TestPrologixGPIBPort(unittest.TestCase):
296+
def testControllerHandshakeUsesManualRead(self):
297+
# Exercise the real PrologixGPIBPort.configureController (it only calls
298+
# writeString, so no serial line is needed) and assert the manual-read
299+
# handshake: ++auto 0 with no controller-appended reply terminator.
300+
port = PrologixGPIBPort(gpibAddress=8)
301+
sent = []
302+
port.writeString = lambda text: sent.append(text.strip())
303+
port.configureController()
304+
self.assertEqual(sent[0], "++mode 1")
214305
self.assertIn("++addr 8", sent)
215-
self.assertIn("++auto 1", sent)
306+
self.assertIn("++auto 0", sent)
307+
self.assertIn("++eot_enable 0", sent)
308+
self.assertNotIn("++auto 1", sent)
216309

217310
def testQueryRoundTripsThroughStringPrimitives(self):
218311
port = DebugPrologixGPIBPort()
@@ -291,5 +384,151 @@ def testAcquireWaveform(self):
291384
self.assertTrue(all(isinstance(value, float) for value in waveform[StreamChannel.X]))
292385

293386

387+
class TestDebugPrologixGPIBPort(unittest.TestCase):
388+
def setUp(self):
389+
self.port = DebugPrologixGPIBPort()
390+
self.port.open()
391+
392+
def testIsOpenReflectsState(self):
393+
self.assertTrue(self.port.isOpen)
394+
self.port.close()
395+
self.assertFalse(self.port.isOpen)
396+
397+
def testBytesAvailableAndFlush(self):
398+
self.port.writeString("FREQ?\n")
399+
self.assertGreater(self.port.bytesAvailable(), 0)
400+
self.port.flush()
401+
self.assertEqual(self.port.bytesAvailable(), 0)
402+
403+
def testSampleRateRegisterRoundTrip(self):
404+
self.port.writeString("SRAT 7\n")
405+
self.port.writeString("SRAT?\n")
406+
self.assertEqual(self.port.readString().strip(), "7")
407+
408+
def testReadTimesOutWhenBufferShort(self):
409+
with self.assertRaises(CommunicationReadTimeout):
410+
self.port.readData(1)
411+
412+
def testControllerLineProducesNoReply(self):
413+
self.port.writeString("++mode 1\n")
414+
self.assertEqual(self.port.bytesAvailable(), 0)
415+
416+
def testUnknownCommandProducesNoReply(self):
417+
self.port.writeString("BOGUS?\n")
418+
self.assertEqual(self.port.bytesAvailable(), 0)
419+
420+
def testOutputValueUnknownIndexReadsZero(self):
421+
self.port.writeString("OUTP? 9\n")
422+
self.assertAlmostEqual(float(self.port.readString()), 0.0)
423+
424+
425+
class _FakeComPort:
426+
"""Stand-in for a serial.tools.list_ports entry."""
427+
428+
def __init__(self, device, serialNumber, vid=0x0403, pid=0x6001):
429+
self.device = device
430+
self.serial_number = serialNumber
431+
self.vid = vid
432+
self.pid = pid
433+
434+
435+
class _FakeAdaptorPort(DebugPrologixGPIBPort):
436+
"""A Prologix port whose *IDN? reply depends on which serial port it opened,
437+
so a discovery probe can be simulated without hardware. identitiesByPath maps
438+
a portPath to its *IDN? reply; a missing/None entry means the adaptor never
439+
answers (the read times out)."""
440+
441+
identitiesByPath = {}
442+
443+
def __init__(self, gpibAddress, portPath=None, **kwargs):
444+
super().__init__()
445+
self.gpibAddress = gpibAddress
446+
self.portPath = portPath
447+
448+
def _process(self, line):
449+
if line == "*IDN?":
450+
return type(self).identitiesByPath.get(self.portPath)
451+
return super()._process(line)
452+
453+
454+
class TestSR830Discovery(unittest.TestCase):
455+
"""Hardware-free coverage of SR830Device.doInitializeDevice / _candidateAdaptors,
456+
which DebugSR830Device bypasses by overriding doInitializeDevice."""
457+
458+
def setUp(self):
459+
_FakeAdaptorPort.identitiesByPath = {}
460+
461+
def _patched(self, comportList):
462+
return (
463+
mock.patch("hardwarelibrary.daq.sr830device.PrologixGPIBPort", _FakeAdaptorPort),
464+
mock.patch("hardwarelibrary.daq.sr830device.comports", return_value=comportList),
465+
)
466+
467+
def testDiscoversSR830AndPinsSerial(self):
468+
_FakeAdaptorPort.identitiesByPath = {
469+
"/dev/A": "Keithley,MODEL 2000,1234,A01",
470+
"/dev/B": SR830_IDN,
471+
}
472+
comportList = [_FakeComPort("/dev/A", "AAAA"), _FakeComPort("/dev/B", "BBBB")]
473+
portPatch, comportPatch = self._patched(comportList)
474+
with portPatch, comportPatch:
475+
device = SR830Device()
476+
device.initializeDevice()
477+
try:
478+
self.assertIn("SR830", device.idn)
479+
self.assertEqual(device.serialNumber, "BBBB")
480+
finally:
481+
device.shutdownDevice()
482+
483+
def testRaisesWhenNoAdaptorPresent(self):
484+
comportList = [_FakeComPort("/dev/Z", "ZZZZ", vid=0x1234, pid=0x5678)]
485+
portPatch, comportPatch = self._patched(comportList)
486+
with portPatch, comportPatch:
487+
device = SR830Device()
488+
with self.assertRaises(PhysicalDevice.UnableToInitialize):
489+
device.initializeDevice()
490+
491+
def testRaisesWhenNoSR830AmongAdaptors(self):
492+
_FakeAdaptorPort.identitiesByPath = {
493+
"/dev/A": None,
494+
"/dev/B": "Keithley,MODEL 2000,1234,A01",
495+
}
496+
comportList = [_FakeComPort("/dev/A", "AAAA"), _FakeComPort("/dev/B", "BBBB")]
497+
portPatch, comportPatch = self._patched(comportList)
498+
with portPatch, comportPatch:
499+
device = SR830Device()
500+
with self.assertRaises(PhysicalDevice.UnableToInitialize):
501+
device.initializeDevice()
502+
503+
def testExplicitPortPathIsSoleCandidate(self):
504+
_FakeAdaptorPort.identitiesByPath = {"/dev/X": SR830_IDN}
505+
with mock.patch("hardwarelibrary.daq.sr830device.PrologixGPIBPort", _FakeAdaptorPort), \
506+
mock.patch("hardwarelibrary.daq.sr830device.comports",
507+
side_effect=AssertionError("comports() must not be consulted with an explicit portPath")):
508+
device = SR830Device(portPath="/dev/X")
509+
device.initializeDevice()
510+
try:
511+
self.assertIn("SR830", device.idn)
512+
finally:
513+
device.shutdownDevice()
514+
515+
def testSerialNumberNarrowsCandidates(self):
516+
_FakeAdaptorPort.identitiesByPath = {"/dev/match": SR830_IDN}
517+
comportList = [
518+
_FakeComPort("/dev/other", "OTHER"),
519+
_FakeComPort("/dev/none", None),
520+
_FakeComPort("/dev/match", "WANTED"),
521+
]
522+
portPatch, comportPatch = self._patched(comportList)
523+
with portPatch, comportPatch:
524+
device = SR830Device(serialNumber="WANT")
525+
device.initializeDevice()
526+
try:
527+
self.assertIn("SR830", device.idn)
528+
self.assertEqual(device.serialNumber, "WANTED")
529+
finally:
530+
device.shutdownDevice()
531+
532+
294533
if __name__ == '__main__':
295534
unittest.main()

0 commit comments

Comments
 (0)