diff --git a/README.rst b/README.rst index 578625d..ec2bf0b 100644 --- a/README.rst +++ b/README.rst @@ -1,132 +1,72 @@ +:orphan: -Test Support -============ +#################################### +test_support: XMOS test support tools +#################################### -This repo contains helpers for testing XMOS xCORE applictions and includes the following: +:vendor: XMOS +:scope: General Use +:description: Python helpers for testing XMOS xCORE applications and libraries +:category: Testing +:keywords: test, xsim, pyxsim, pytest, coverage +:devices: xcore-200, xcore.ai -- Python wrapper for the xCORE simulator (xsim) -- Python access functions for XE files -- Python code coverage measurement (xcov) for pytest +******* +Summary +******* -Basic usage: Pyxsim -------------------- +``test_support`` provides shared Python infrastructure for XMOS simulation and +testbench code. It includes helpers for running xsim from pytest, interacting +with simulated ports and pins, comparing simulator output, reading XE metadata, +and modelling resolved bus wires in Python testbenches. -Pyxsim provides Python helpers for running xsim-based tests from pytest. A -typical test builds a simulator thread, optionally attaches plugins, and calls -``Pyxsim.run_on_simulator_`` with a tester such as ``ComparisonTester``. +******** +Features +******** -When pytest output capture is passed via ``capfd``, Pyxsim captures simulator -and simthread output and replays it according to the pytest verbosity level: +* Python wrapper for the xCORE simulator XSI interface. +* Pytest-oriented simulator runners and output comparison helpers. +* Simulator thread support for Python-driven testbench activity. +* Resolved wire model for buses with multiple named drivers. +* Python access helpers for XE files. +* Python code coverage support for xsim traces. -* ``pytest ...`` keeps output compact and reports pass/fail details. -* ``pytest ... -v`` enables verbose tester output, including captured - ``OUTPUT:`` lines from the simulator stream. -* ``pytest ... -vv`` also prints the matching ``GOLDEN:`` lines from - ``ComparisonTester`` and is useful when diagnosing mismatches. +************ +Known issues +************ -``ComparisonTester`` supports regular-expression matching, ordered or unordered -comparison, ignored output patterns, and optional suppression of common xsim -multidrive diagnostics. +* None -Resolved bus wires ------------------- +**************** +Development repo +**************** -``Pyxsim.bus`` contains a small resolved-wire model for Python testbenches: +* `test_support `_ (https://www.github.com/xmos/test_support) -* ``BusWire`` models one digital bus wire with named drivers, protocol mode, - and pull-up state. -* ``DriveMode.HIGH_Z`` represents a released line driver. -* ``DriveMode.DRIVE_LOW`` and ``DriveMode.DRIVE_HIGH`` represent hard-driven - values. -* When all drivers are high-Z and pull-up is enabled, the wire resolves high. -* If any driver drives low, the resolved value is low unless another driver is - hard-driving high. -* A hard-low and hard-high combination is reported as ``hard_clash`` in the - wire snapshot. -* Hard-drive operations raise ``BusWireHardClash`` immediately if they create a - hard-low versus hard-high conflict. The exception includes the wire name, - resolved value, active low drivers, active high drivers, and the driver/mode - being applied. +************** +Required tools +************** -Example:: +* Python 3.12 +* XMOS XTC Tools - from Pyxsim.bus import BusMode, BusWire +********************************* +Required libraries (dependencies) +********************************* - sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) - sda.release("controller") - sda.drive("target", 0) - - snapshot = sda.snapshot() - assert snapshot.resolved == 0 - assert not snapshot.hard_clash - -Use ``release(driver)`` when the testbench is handing ownership of a wire to -another bus participant. Use ``drive(driver, 1)`` only when the protocol phase -really requires a hard high drive. If the wire has a modeled pull-up, released -drivers can still resolve to a high value without being recorded as hard-driving -high. - -Basic usage: xcoverage ----------------------- - -This only suit for xsim. - -It requires disassembly and elf file which dumped from binary file (.xe file) by: - - * xobjdump --split [.xe]. - * xobjdump -S [.xe] -o [output_file_name.dump]. - * run the above 2 step by youself or run method from xcov: generate_elf_disasm("/path_to/(name-of-xe).xe", "/path_where_store_elf_and_disasm", "/path_to/(name-of-disasm).dump") - -.xe must make with -g flag to enable the gdb bugger otherwise xcoverage won't work!. - -It also needs a tracing file from xsim by running: - - * xsim --trace-to [output_file_name.txt] [.xe]. - -``xcov_process`` -....................... - -This is the main function to be called in your test. -It returns the average coverage and save the data in .xcov file in xcov dir. -.xcov file is necessary for the below "xcov_combine" function. - -xcov_process(disasm, trace, xcov_dir). - - * @param disam: path to disasm file. - * @param trace: path to trace file. - * @param xcov_dir : path where xcov directory locates. - * @return average coverage of all src file. - * @output generate xcov file for xcov_combine and save in xcov dir. - -``xcov_combine`` -....................... - -see example in examples/code_coverage - -``combine_process`` -....................... - -see example in examples/code_coverage - -``Mark the source code as not expected to be hit`` -........................................................ - -Add a comment "//NOCOVER" or "//NOCOVERSTART" and "//NOCOVEREND" beside you source code. It wouldn't be counted in coverage. - -see example in test/test_xcoverage - -``Excluded File`` -........................................................ -Passing an excluded_file arg in xcov_process(), eg: - -xcov_process(disasm, trace, xcov_dir, excluded_file=["/tests/shared/test_main.xc","/tests/shared/shared.h" ]) - -Software version and dependencies -................................. - -The CHANGELOG contains information about the current and previous versions. -For a list of direct dependencies, look for DEPENDENT_MODULES in test_support/module_build_info. +* ``colorama`` +******** +Examples +******** +* ``examples/pyxsim`` demonstrates a simulator thread interacting with an xCORE application. +* ``examples/code_coverage`` demonstrates xsim trace based coverage processing. +******* +Support +******* +This package is supported by XMOS Ltd. Issues can be raised against the software +at `www.xmos.com/support `_ or using GitHub +`issues `_. diff --git a/doc/rst/test_support.rst b/doc/rst/test_support.rst new file mode 100644 index 0000000..620246c --- /dev/null +++ b/doc/rst/test_support.rst @@ -0,0 +1,241 @@ +############ +test_support +############ + +************ +Introduction +************ + +``test_support`` provides shared Python infrastructure for XMOS simulation and +testbench code. It wraps the XSI simulator interface, provides helper classes +for simulator threads and plugins, implements comparison testers, and includes +common models used by library tests. + +The main Python modules are: + +* ``Pyxsim.pyxsim``: XSI wrapper, simulator thread support, plugin execution, + and pin/port access helpers. +* ``Pyxsim.bus``: resolved wire and bus-driver model for Python testbenches. +* ``Pyxsim.testers``: output comparison and filtering helpers. +* ``Pyxsim.xe``: helpers for reading metadata from XE files. + +***** +Usage +***** + +Install ``test_support`` into the Python environment used by the tests. During +library development this is normally done from the repository checkout in +editable mode: + +.. code-block:: console + + python -m pip install -e . + +Tests usually import ``Pyxsim`` and run a built ``.xe`` under xsim. A typical +pytest test constructs any required simulator threads, creates a tester for the +expected output, and passes pytest's ``capfd`` fixture so simulator output can be +captured and replayed consistently. + +.. code-block:: python + + from pathlib import Path + + import Pyxsim + from Pyxsim.testers import ComparisonTester + + + def test_example(capfd): + xe = Path("bin/example.xe") + tester = ComparisonTester(["ready", "done"]) + + assert Pyxsim.run_on_simulator_( + xe, + tester=tester, + capfd=capfd, + ) + +Pass ``simargs`` for xsim command-line arguments and ``appargs`` for arguments +passed to the simulated application. + +***************** +Comparison Tester +***************** + +``ComparisonTester`` compares simulator output with expected lines. The expected +output can be supplied as a list of strings, a newline-separated string, or a +file object. + +By default, comparison is ordered and exact. Set ``regexp=True`` to treat each +expected line as a regular expression. Set ``ordered=False`` when the expected +lines may appear in any order. + +.. code-block:: python + + tester = ComparisonTester( + [r"status: [0-9]+", "done"], + regexp=True, + ordered=True, + ) + +Use ``ignore`` to suppress known non-deterministic or irrelevant lines: + +.. code-block:: python + + tester = ComparisonTester( + ["done"], + ignore=[r"timestamp: .*"], + ) + +Common xsim multidrive diagnostics are suppressed by default. Pass +``suppress_multidrive_messages=False`` if a test needs to assert on those +messages directly. + +When pytest output capture is passed via ``capfd``, Pyxsim captures simulator and +simthread output and replays it according to the pytest verbosity level: + +* ``pytest ...`` keeps output compact and reports pass/fail details. +* ``pytest ... -v`` enables verbose tester output, including captured + ``OUTPUT:`` lines from the simulator stream. +* ``pytest ... -vv`` also prints the matching ``GOLDEN:`` lines from + ``ComparisonTester`` and is useful when diagnosing mismatches. + +***************** +Simulator Threads +***************** + +Simulator threads let Python testbench code interact with the simulated xCORE +application while xsim is running. Define a class derived from +``Pyxsim.SimThread`` and implement ``run()``. + +.. code-block:: python + + import Pyxsim + + + class ExampleSimThread(Pyxsim.SimThread): + def run(self): + trigger = "tile[0]:XS1_PORT_1G" + response = "tile[0]:XS1_PORT_1H" + + self.wait_for_port_pins_change([trigger]) + value = self.xsi.sample_port_pins(response) + self.xsi.drive_port_pins(response, 1 - value) + +Common simthread helpers are: + +* ``wait_for_port_pins_change(ports)``: wait until one of the supplied ports + changes drive state or value. +* ``wait_for_next_cycle()``: yield until the simulator advances. +* ``wait_until(time)``: wait until the simulator reaches an absolute time. +* ``wait(predicate)``: wait until a Python predicate returns true. +* ``xsi.drive_port_pins(port, value)``: drive a simulator port from Python. +* ``xsi.sample_port_pins(port)``: sample the current value of a simulator port. +* ``xsi.is_port_driving(port)``: test whether the xCORE application is actively + driving a port pin. + +************************* +XSI Port Sampling Warning +************************* + +``sample_port_pins`` should not be treated as a pure observation in all +testbench situations. Sampling a port through XSI can affect Python-driven port +state in the simulator. This matters when Python code is also driving a resolved +value onto the same port, for example when emulating pull-ups or mirroring a +resolved bus model into xsim. + +If a caller only wants to know whether the xCORE target is actively driving a +port, check ``is_port_driving()`` before calling ``sample_port_pins()``. Only +sample when the target is known to be driving; otherwise keep the corresponding +Python-side bus driver released. + +.. code-block:: python + + if xsi.is_port_driving(sda_port): + target_value = xsi.sample_port_pins(sda_port) + target.sda.drive(target_value) + else: + target.sda.release() + +This avoids accidentally disturbing the Python-driven resolved value while still +allowing target-driven values to be mirrored into the Python bus model. + +******************* +Resolved Bus Model +******************* + +``Pyxsim.bus`` models digital wires that may be driven by multiple named bus +participants. It distinguishes high-Z release from hard-drive high so that tests +can model open-drain and push-pull behaviour and detect hard clashes. + +Create physical wires and driver objects, then bind them with ``Bus``: + +.. code-block:: python + + from Pyxsim.bus import Bus, BusDriver, BusMode, BusWire + + scl = BusWire("scl", mode=BusMode.PUSH_PULL, pullup_enabled=False) + sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) + + controller = BusDriver("controller") + target = BusDriver("target") + + Bus([scl, sda], [controller, target]) + + controller.scl.drive(1) + controller.sda.release() + target.sda.drive(0) + + value = sda.resolved_value() + +The ``Bus`` constructor validates wire and driver names and binds each driver to +each wire. Wire handles are exposed as attributes on the driver, such as +``controller.sda``. + +``DriveMode.HIGH_Z`` represents a released line driver. ``DriveMode.DRIVE_LOW`` +and ``DriveMode.DRIVE_HIGH`` represent hard-driven values. When all drivers are +high-Z and pull-up is enabled, the wire resolves high. If any driver drives low, +the resolved value is low unless another driver is hard-driving high. + +Use ``release(driver)`` when the testbench is handing ownership of a wire to +another bus participant. Use ``drive(driver, 1)`` only when the protocol phase +really requires a hard high drive. If the wire has a modeled pull-up, released +drivers can still resolve to a high value without being recorded as hard-driving +high. + +A hard-low and hard-high combination is reported as ``hard_clash`` in the wire +snapshot. Hard-drive operations raise ``BusWireHardClash`` immediately if they +create a hard-low versus hard-high conflict. The exception includes the wire +name, resolved value, active low drivers, active high drivers, and the +driver/mode being applied. + +********************* +Tracing and Debugging +********************* + +Enable instruction tracing with ``instTracing=True`` and VCD tracing with +``vcdTracing=True`` when running a simulation. Trace files are written under the +``logs`` directory using the ``xsim_trace_`` prefix. + +.. code-block:: python + + Pyxsim.run_on_simulator_( + xe, + simthreads=[ExampleSimThread()], + instTracing=True, + vcdTracing=True, + ) + +Use the ``timeout`` argument to bound long-running simulations. If the simulator +process does not exit before the timeout expires, Pyxsim terminates it and the +run fails. + +******** +Examples +******** + +``examples/pyxsim`` contains a small simulator-thread example. The xCORE +application changes one port, the Python simthread observes that change, then the +simthread drives another port to wake the application. + +``examples/code_coverage`` contains a minimal example of processing xsim trace +data for source coverage. diff --git a/lib/python/Pyxsim/bus.py b/lib/python/Pyxsim/bus.py index 0f7a04c..c0c7617 100644 --- a/lib/python/Pyxsim/bus.py +++ b/lib/python/Pyxsim/bus.py @@ -106,8 +106,8 @@ def disable_pullup(self): """Disable the modeled pull-up bias.""" self.pullup_enabled = False - def set_driver(self, driver, mode, context=None): - """Set a named driver's hard-drive mode.""" + def _set_driver(self, driver, mode, context=None): + """Internal method to set a named driver's hard-drive mode.""" if not isinstance(mode, DriveMode): mode = DriveMode(mode) driver = self._key(driver) @@ -115,27 +115,6 @@ def set_driver(self, driver, mode, context=None): if mode != DriveMode.HIGH_Z: self.assert_no_hard_clash(driver=driver, mode=mode, context=context) - def release(self, driver): - """Set a named driver to high-Z.""" - self.set_driver(driver, DriveMode.HIGH_Z) - - def drive(self, driver, value, context=None): - """Set a named driver to hard-drive low or high.""" - if value not in (0, 1): - raise ValueError("BusWire drive value must be 0 or 1") - if value == 1: - self.drive_high(driver, context=context) - else: - self.drive_low(driver, context=context) - - def drive_low(self, driver, context=None): - """Set a named driver to hard-drive low.""" - self.set_driver(driver, DriveMode.DRIVE_LOW, context=context) - - def drive_high(self, driver, context=None): - """Set a named driver to hard-drive high.""" - self.set_driver(driver, DriveMode.DRIVE_HIGH, context=context) - def driver_mode(self, driver): """Return a named driver's mode, defaulting to high-Z.""" return self._drivers.get(self._key(driver), DriveMode.HIGH_Z) @@ -240,3 +219,102 @@ def _debug_snapshot(self, snapshot, context=None, exclude=None): f"pullup={snapshot.pullup_enabled} " f"released_value={self.released_value}" ) + + def _drive(self, driver_name: str, value: int, context=None): + """Internal drive method for OO API.""" + if value not in (0, 1): + raise ValueError("BusWire drive value must be 0 or 1") + mode = DriveMode.DRIVE_HIGH if value == 1 else DriveMode.DRIVE_LOW + self._set_driver(driver_name, mode, context=context) + + def _release(self, driver_name: str): + """Internal release method for OO API.""" + self._set_driver(driver_name, DriveMode.HIGH_Z) + + +class BusWireDriver: + """Bound driver handle for a specific driver on a specific wire.""" + + def __init__(self, wire: BusWire, driver: 'BusDriver'): + self._wire = wire + self._driver = driver + + def drive(self, value: int, context=None): + """Drive this wire to the specified value (0 or 1).""" + self._wire._drive(self._driver.name, value, context=context) + + def release(self): + """Release this driver to high-Z.""" + self._wire._release(self._driver.name) + + def mode(self) -> DriveMode: + """Return the current drive mode of this driver.""" + return self._wire.driver_mode(self._driver.name) + + +class BusDriver: + """Named driver that can be bound to multiple wires.""" + + def __init__(self, name: str): + self.name = name + self._wires: dict[str, BusWireDriver] = {} + + def _bind_wire(self, wire: BusWire): + """Internal method to bind a wire to this driver.""" + # Validate wire name is a valid Python identifier + if not isinstance(wire.name, str) or not wire.name.isidentifier(): + raise ValueError(f"Wire name must be a valid Python identifier: '{wire.name}'") + + # Check for duplicate binding + if wire.name in self._wires: + raise ValueError(f"Driver '{self.name}' is already bound to wire '{wire.name}'") + + # Check for conflicts with existing BusDriver attributes + if hasattr(self, wire.name): + raise ValueError(f"Wire name '{wire.name}' conflicts with BusDriver attribute") + + # Create the wire driver handle and bind it as a real attribute + wire_driver = BusWireDriver(wire, self) + self._wires[wire.name] = wire_driver + setattr(self, wire.name, wire_driver) + + +def _duplicate_names(names): + seen = set() + duplicates = set() + for name in names: + if name in seen: + duplicates.add(name) + seen.add(name) + return sorted(duplicates) + + +class Bus: + """Container that binds drivers to wires and enforces global name uniqueness.""" + + def __init__(self, wires: list[BusWire], drivers: list[BusDriver]): + # Validate wire name uniqueness first + wire_names = [w.name for w in wires] + duplicate_wire_names = _duplicate_names(wire_names) + if duplicate_wire_names: + raise ValueError(f"Duplicate wire names: {duplicate_wire_names}") + + # Validate driver name uniqueness + driver_names = [d.name for d in drivers] + duplicate_driver_names = _duplicate_names(driver_names) + if duplicate_driver_names: + raise ValueError(f"Duplicate driver names: {duplicate_driver_names}") + + # Validate global uniqueness across all wire and driver names + all_names = wire_names + driver_names + duplicate_names = _duplicate_names(all_names) + if duplicate_names: + raise ValueError(f"Duplicate names found (wires and drivers must have globally unique names): {duplicate_names}") + + self._wires = {w.name: w for w in wires} + self._drivers = {d.name: d for d in drivers} + + # Bind all drivers to all wires + for driver in drivers: + for wire in wires: + driver._bind_wire(wire) diff --git a/lib/python/Pyxsim/pyxsim.py b/lib/python/Pyxsim/pyxsim.py index 7181837..b95d867 100644 --- a/lib/python/Pyxsim/pyxsim.py +++ b/lib/python/Pyxsim/pyxsim.py @@ -208,6 +208,9 @@ def drive_port_pins(self, port, val): self._xsi.drive_port_pins(tile, p, mask, val) def sample_port_pins(self, port): + # WARNING: XSI port sampling can affect Python-driven port state. If a + # caller only wants to observe xcore-driven state, check + # is_port_driving() before sampling. (tile, p, bit, mask) = parse_port(port) val = self._xsi.sample_port_pins(tile, p, mask) if bit: @@ -358,6 +361,8 @@ def sample_pin(self, package, pin): return c_value.value def sample_port_pins(self, tile, port, mask): + # WARNING: xsi_sample_port_pins() is not always a passive observation; + # it can disturb Python-driven port state in xsim/XSI interactions. c_tile = c_char_p(tile.encode("utf-8")) c_port = c_char_p(port.encode("utf-8")) c_mask = c_uint(mask) diff --git a/tests/test_bus_line_model.py b/tests/test_bus_line_model.py index c4e0dd7..95a04fe 100644 --- a/tests/test_bus_line_model.py +++ b/tests/test_bus_line_model.py @@ -1,15 +1,19 @@ # Copyright 2026 XMOS LIMITED. # This Software is subject to the terms of the XMOS Public Licence: Version 1. import pytest -from Pyxsim.bus import BusWire, BusMode, BusWire, BusWireHardClash, DriveMode +from Pyxsim.bus import Bus, BusDriver, BusWire, BusMode, BusWireHardClash, DriveMode def test_bus_wire_all_released_resolves_to_released_value(): - line = BusWire("sda", released_value=1) - line.release("controller") - line.release("target") + sda = BusWire("sda", released_value=1) + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) - snapshot = line.snapshot() + controller.sda.release() + target.sda.release() + + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.hard_clash is False @@ -17,11 +21,15 @@ def test_bus_wire_all_released_resolves_to_released_value(): def test_bus_wire_hard_low_beats_released(): - line = BusWire("sda") - line.drive_low("controller") - line.release("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) + target.sda.release() - snapshot = line.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 0 assert snapshot.hard_clash is False @@ -29,33 +37,45 @@ def test_bus_wire_hard_low_beats_released(): def test_bus_wire_hard_high_beats_released(): - line = BusWire("sda") - line.drive_high("controller") - line.release("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(1) + target.sda.release() - snapshot = line.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.hard_clash is False assert snapshot.drivers_high == ("controller",) def test_bus_wire_multiple_low_drivers_do_not_clash(): - line = BusWire("sda") - line.drive_low("controller") - line.drive_low("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) - snapshot = line.snapshot() + controller.sda.drive(0) + target.sda.drive(0) + + snapshot = sda.snapshot() assert snapshot.resolved == 0 assert snapshot.hard_clash is False assert snapshot.drivers_low == ("controller", "target") def test_bus_wire_low_and_high_drivers_clash(): - line = BusWire("sda") - line.drive_low("controller") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) with pytest.raises(BusWireHardClash) as exc_info: - line.drive_high("target") + target.sda.drive(1) snapshot = exc_info.value.snapshot assert snapshot.resolved == 0 @@ -64,64 +84,252 @@ def test_bus_wire_low_and_high_drivers_clash(): assert snapshot.drivers_high == ("target",) -def test_bus_wire_accepts_drive_mode_values(): - line = BusWire("sda") - line.set_driver("controller", DriveMode.DRIVE_HIGH.value) - - assert line.driver_mode("controller") == DriveMode.DRIVE_HIGH - assert line.resolved_value() == 1 - - def test_bus_wire_release_with_pullup_resolves_high(): - wire = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) - wire.release("controller") - wire.release("target") + sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.release() + target.sda.release() - snapshot = wire.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.mode == BusMode.OPEN_DRAIN assert snapshot.pullup_enabled is True def test_bus_wire_release_without_pullup_uses_released_value(): - wire = BusWire("sda", pullup_enabled=False, released_value=0) - wire.release("controller") + sda = BusWire("sda", pullup_enabled=False, released_value=0) + controller = BusDriver("controller") + Bus([sda], [controller]) - assert wire.resolved_value() == 0 + controller.sda.release() + + assert sda.resolved_value() == 0 def test_bus_wire_warns_once_when_floating_without_pullup_or_released_value(capsys): - wire = BusWire("sda", pullup_enabled=False) - wire.release("controller") + sda = BusWire("sda", pullup_enabled=False) + controller = BusDriver("controller") + Bus([sda], [controller]) + + controller.sda.release() - assert wire.resolved_value() is None - assert wire.resolved_value() is None + assert sda.resolved_value() is None + assert sda.resolved_value() is None captured = capsys.readouterr() assert captured.out.count("Bus wire sda is floating") == 1 def test_bus_wire_configures_mode_and_pullup(): - wire = BusWire("sda", released_value=0) + sda = BusWire("sda", released_value=0) + controller = BusDriver("controller") + Bus([sda], [controller]) - wire.configure(mode=BusMode.PUSH_PULL, pullup_enabled=False) + sda.configure(mode=BusMode.PUSH_PULL, pullup_enabled=False) - snapshot = wire.snapshot() + snapshot = sda.snapshot() assert snapshot.mode == BusMode.PUSH_PULL assert snapshot.pullup_enabled is False def test_bus_wire_drive_value_sets_hard_drive_mode(): - wire = BusWire("sda") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) - wire.drive("controller", 1) + controller.sda.drive(1) - assert wire.driver_mode("controller") == DriveMode.DRIVE_HIGH + assert controller.sda.mode() == DriveMode.DRIVE_HIGH @pytest.mark.parametrize("value", [-1, 2, 3]) def test_bus_wire_drive_rejects_non_single_wire_values(value): - wire = BusWire("sda") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) with pytest.raises(ValueError, match="0 or 1"): - wire.drive("controller", value) + controller.sda.drive(value) + + +# New OO API-specific tests + +def test_bus_binds_drivers_to_multiple_wires(): + """Test that Bus binds all drivers to all wires.""" + scl = BusWire("scl", pullup_enabled=False) + sda = BusWire("sda", pullup_enabled=True) + controller = BusDriver("controller") + target = BusDriver("target") + + Bus([scl, sda], [controller, target]) + + # All drivers should be bound to all wires + controller.scl.drive(1) + controller.sda.drive(0) + target.scl.release() + target.sda.release() + + assert scl.resolved_value() == 1 + assert sda.resolved_value() == 0 + + +def test_bus_rejects_duplicate_wire_names(): + """Test that Bus raises on duplicate wire names.""" + sda1 = BusWire("sda") + sda2 = BusWire("sda") + controller = BusDriver("controller") + + with pytest.raises(ValueError, match=r"Duplicate wire names: \['sda'\]"): + Bus([sda1, sda2], [controller]) + + +def test_bus_rejects_duplicate_driver_names(): + """Test that Bus raises on duplicate driver names.""" + sda = BusWire("sda") + controller1 = BusDriver("controller") + controller2 = BusDriver("controller") + + with pytest.raises(ValueError, match=r"Duplicate driver names: \['controller'\]"): + Bus([sda], [controller1, controller2]) + + +def test_bus_enforces_global_uniqueness(): + """Test that wire and driver names must be globally unique.""" + sda = BusWire("sda") + sda_driver = BusDriver("sda") # Same name as wire + + with pytest.raises(ValueError, match=r"globally unique names\): \['sda'\]"): + Bus([sda], [sda_driver]) + + +def test_driver_attribute_access_for_unbound_wire_raises(): + """Test that accessing unbound wire raises AttributeError.""" + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) + + with pytest.raises(AttributeError, match="'BusDriver' object has no attribute 'scl'"): + controller.scl.drive(0) + + +def test_bus_wire_driver_mode_queries(): + """Test that BusWireDriver.mode() returns correct DriveMode.""" + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) + + # Initially high-Z + assert controller.sda.mode() == DriveMode.HIGH_Z + + # After driving low + controller.sda.drive(0) + assert controller.sda.mode() == DriveMode.DRIVE_LOW + + # After driving high + controller.sda.drive(1) + assert controller.sda.mode() == DriveMode.DRIVE_HIGH + + # After release + controller.sda.release() + assert controller.sda.mode() == DriveMode.HIGH_Z + + +def test_driver_release_only_affects_that_driver(): + """Test that releasing one driver doesn't affect other drivers.""" + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) + target.sda.drive(0) + + # Both driving low + assert sda.resolved_value() == 0 + assert controller.sda.mode() == DriveMode.DRIVE_LOW + assert target.sda.mode() == DriveMode.DRIVE_LOW + + # Release controller only + controller.sda.release() + + # Controller released, target still driving + assert sda.resolved_value() == 0 + assert controller.sda.mode() == DriveMode.HIGH_Z + assert target.sda.mode() == DriveMode.DRIVE_LOW + + +def test_hard_clash_still_detected_with_oo_api(): + """Test that BusWireHardClash is still raised with OO API.""" + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(1) + + with pytest.raises(BusWireHardClash) as exc_info: + target.sda.drive(0) + + assert exc_info.value.snapshot.hard_clash is True + + +def test_bus_rejects_invalid_wire_name(): + """Test that Bus rejects wire names that are not valid Python identifiers.""" + sda_invalid = BusWire("sda-line") # Hyphen not allowed in identifiers + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="must be a valid Python identifier"): + Bus([sda_invalid], [controller]) + + +def test_bus_rejects_non_string_wire_name(): + """Test that Bus rejects non-string wire names with ValueError.""" + wire = BusWire(123) + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="must be a valid Python identifier"): + Bus([wire], [controller]) + + +def test_driver_rejects_duplicate_wire_binding(): + """Test that duplicate wire binding raises the intended error.""" + wire = BusWire("line") + controller = BusDriver("controller") + + controller._bind_wire(wire) + + with pytest.raises(ValueError, match="already bound"): + controller._bind_wire(wire) + + +def test_bus_rejects_wire_name_conflicting_with_driver_attribute(): + """Test that Bus rejects wire names that conflict with BusDriver attributes.""" + name_wire = BusWire("name") # Conflicts with BusDriver.name + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="conflicts with BusDriver attribute"): + Bus([name_wire], [controller]) + + +def test_driver_bound_wires_are_real_attributes(): + """Test that bound wires are real attributes, not __getattr__ magic.""" + scl = BusWire("scl") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([scl, sda], [controller]) + + # Attributes should be in __dict__ + assert "scl" in controller.__dict__ + assert "sda" in controller.__dict__ + + # Should be able to use hasattr + assert hasattr(controller, "scl") + assert hasattr(controller, "sda") + assert not hasattr(controller, "nonexistent") + + # Should be able to use getattr with default + assert getattr(controller, "scl") is not None + assert getattr(controller, "nonexistent", "default") == "default"