From d2e2693c2fd0abc581dc5dad7af690e1ac79110b Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 17 Apr 2026 20:48:25 +0300 Subject: [PATCH] test(cross-layer): enforce status word field positions match Verilog concat layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-1 had only one explicit per-field assertion (radar_mode at lsb=22). The Tier-2 round-trip uses the same Python parser as oracle for status word decoding, so a coupled Verilog+Python bit-position shift in status_words[0]/[3]/[4]/[5] passes Tier-2 silently — only [1] and [2] have an independent raw-byte check in tb_cross_layer_ft2232h.v. Add an independent static check that walks each Verilog status_words[N] concatenation MSB->LSB via count_concat_bits, computes (word_idx, lsb, width) for every named payload fragment, drops literal padding, and compares against every field parse_status_packet() extracts. Name match is status_ (current convention has zero exceptions). Mismatches accumulate into a single failure message so one run surfaces all drift at once. Parametrized over both usb_data_interface_ft2232h.v and usb_data_interface.v since both are live post PR #89. --- .../cross_layer/test_cross_layer_contract.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/9_Firmware/tests/cross_layer/test_cross_layer_contract.py b/9_Firmware/tests/cross_layer/test_cross_layer_contract.py index 780f15f..1b924e5 100644 --- a/9_Firmware/tests/cross_layer/test_cross_layer_contract.py +++ b/9_Firmware/tests/cross_layer/test_cross_layer_contract.py @@ -314,6 +314,68 @@ class TestTier1StatusFieldPositions: f"but Verilog status_words[0] has mode at bit {expected_shift}." ) + @pytest.mark.parametrize( + "usb_variant", + ["usb_data_interface_ft2232h.v", "usb_data_interface.v"], + ) + def test_status_field_positions_match_verilog_concat_layout(self, usb_variant): + """ + Independent static check: every Python field in parse_status_packet() + must sit at the (word_idx, lsb, width) where Verilog places it inside + status_words[0..5]. Walks each Verilog concat MSB->LSB and compares to + what the Python parser extracts. Exercised across both USB variants + because both are live post PR #89. + """ + rtl_path = cp.FPGA_DIR / usb_variant + if not rtl_path.exists(): + pytest.skip(f"{usb_variant} not present") + + concats = cp.parse_verilog_status_word_concats(rtl_path) + port_widths = cp.get_usb_interface_port_widths(rtl_path) + + # Build verilog_layout: signal_name -> (word_idx, lsb, width) + verilog_layout: dict[str, tuple[int, int, int]] = {} + literal_re = re.compile(r"^\d+'[bdhoBDHO]") + for word_idx, concat_expr in concats.items(): + result = cp.count_concat_bits(concat_expr, port_widths) + # Skip malformed words; total-width assertion belongs to + # TestTier1StatusWordTruncation, not this test. + if result.total_bits != 32: + continue + running_lsb = result.total_bits # MSB-first walk + for name_or_literal, width in result.fragments: + running_lsb -= width + if literal_re.match(name_or_literal): + continue + verilog_layout[name_or_literal] = (word_idx, running_lsb, width) + + py_fields = cp.parse_python_status_fields() + + mismatches: list[str] = [] + for f in py_fields: + v_name = f"status_{f.name}" + v_pos = verilog_layout.get(v_name) + if v_pos is None: + mismatches.append( + f" {f.name}: py=(word={f.word_index}, lsb={f.lsb}, " + f"width={f.width}) v=" + ) + continue + v_word, v_lsb, v_width = v_pos + if (v_word, v_lsb, v_width) != (f.word_index, f.lsb, f.width): + mismatches.append( + f" {f.name}: py=(word={f.word_index}, lsb={f.lsb}, " + f"width={f.width}) v=(word={v_word}, lsb={v_lsb}, " + f"width={v_width})" + ) + + if mismatches: + pytest.fail( + f"Status field layout drift between Python parse_status_packet() " + f"and Verilog status_words[] in {usb_variant}:\n" + + "\n".join(mismatches) + ) + class TestTier1PacketConstants: """Verify packet header/footer/size constants match across layers."""