diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e390d9..b9bce23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,31 @@ # Changelog +## 0.1.6 + +### Fixed +- **SCR decode**: replace backward/brute-force topology block scanning with a + single forward pass, fixing false positive matches from flag-table tail bytes + that caused spurious empty rungs with inflated row counts + +## 0.1.5 + +### Changed +- **Encode**: remove AF summary block — Click accepts multi-AF rungs without + it, eliminating complex gating logic +- **CSV**: consolidate tall-instruction padding into shared hydrate/dehydrate + helpers in converter and writer + +### Added +- **Devtools**: `combine_coverage.py` — combine random coverage fixtures into + a single multi-row rung for paste smoke-testing +- **Devtools**: `coverage_golden.py` now regenerates all bins unconditionally + ## 0.1.4 ### Fixed - **CSV**: preserve multi-row AF round-trips, including pinned/tall AF blocks away from row 0, multiple tall blocks in one rung, and generic tall continuation rows -- **Encode**: suppress AF summary data when a rung contains multi-row AFs, - avoiding grid misalignment and Click paste crashes - **SCR decode**: restore omitted wiring for generic tall AF continuation rows - **CSV writer**: fail loudly when emitted CSV rows lose decoded rung semantics by reparsing writer output and checking for row, condition, or AF diff --git a/src/laddercodec/csv/converter.py b/src/laddercodec/csv/converter.py index cb4162d..960c87f 100644 --- a/src/laddercodec/csv/converter.py +++ b/src/laddercodec/csv/converter.py @@ -131,7 +131,12 @@ def _hydrate_wire_continuations(condition_rows: list[list[ConditionToken]]) -> N After block finalization and auto-padding, padding rows are all-blank. This pass restores vertical wire continuity: wherever row *R* has a ``T`` (junction-down) or ``|`` (vertical pass-through), row *R+1* - gets a ``|`` if the cell is currently blank. + gets a ``|`` if the cell is currently blank — unless a horizontal wire + from the left already connects to that junction. + + A vertical wire terminates when ``(R+1, C-1)`` carries a horizontal + path (anything other than ``""`` or ``"|"``), because the horizontal + wire auto-snaps to the vertical endpoint above. Iterates top-to-bottom so multi-row vertical runs propagate correctly. Stops one row short of the last row — vertical tokens on the last row @@ -143,6 +148,11 @@ def _hydrate_wire_continuations(condition_rows: list[list[ConditionToken]]) -> N for col_idx in range(len(condition_rows[row_idx])): token = condition_rows[row_idx][col_idx] if token in ("T", "|") and condition_rows[row_idx + 1][col_idx] == "": + # Check if a horizontal wire from the left already connects here. + if col_idx > 0: + left = condition_rows[row_idx + 1][col_idx - 1] + if left not in ("", "|"): + continue # horizontal path snaps to vertical — no | needed condition_rows[row_idx + 1][col_idx] = cast(ConditionToken, "|") diff --git a/src/laddercodec/decode_program.py b/src/laddercodec/decode_program.py index 107c57f..c983601 100644 --- a/src/laddercodec/decode_program.py +++ b/src/laddercodec/decode_program.py @@ -44,7 +44,6 @@ _SCR_MAGIC = b"SC-SCR " _ROW_TOPOLOGY_PREFIX = b"\x03\x00\x00" -_ROW_TOPOLOGY_FLAG_ENTRY_COUNTS = frozenset({31, 32}) _ROW_TOPOLOGY_PRELUDE_LEN_RANGE = range(8, 17) _MAX_SECTION_INSTRUCTIONS = 512 @@ -548,34 +547,8 @@ def _forward_parse_topology_block( return None -def _brute_force_topology_block( - data: bytes, - pos: int, - row_word: int, - marker_scan_limit: int, -) -> _ScrRowTopologyBlock | None: - """Find best topology block by trying every candidate *flags_start*.""" - best_block: _ScrRowTopologyBlock | None = None - best_score: tuple[int, int] | None = None - flags_scan_limit = min(len(data), pos + 0x40) - - for flags_start in range(pos + 8, flags_scan_limit): - block = _try_topology_at_flags_start(data, pos, row_word, flags_start, marker_scan_limit) - if block is not None: - score = (block.row0_flag_count, -flags_start) - if best_score is None or score > best_score: - best_block = block - best_score = score - - return best_block - - def _parse_row_topology_block(data: bytes, pos: int) -> _ScrRowTopologyBlock | None: - """Parse a row-topology block anchored by the ordered row-0 flag table. - - Uses deterministic forward parsing through leading-row wire blocks, - falling back to brute-force scanning if forward parsing fails. - """ + """Parse a row-topology block via deterministic forward parsing.""" if pos < 0 or pos + min(_ROW_TOPOLOGY_PRELUDE_LEN_RANGE) > len(data): return None @@ -587,44 +560,26 @@ def _parse_row_topology_block(data: bytes, pos: int) -> _ScrRowTopologyBlock | N return None marker_scan_limit = min(len(data), pos + 0x800) + return _forward_parse_topology_block(data, pos, row_word, marker_scan_limit) - # Try deterministic forward parse first. - block = _forward_parse_topology_block(data, pos, row_word, marker_scan_limit) - if block is not None: - return block - # Brute-force fallback — should never trigger for well-formed data. - return _brute_force_topology_block(data, pos, row_word, marker_scan_limit) - - -def _find_row_topology_block( - data: bytes, pos: int, max_lookback: int = 4096 -) -> _ScrRowTopologyBlock | None: - """Find the nearest row-topology block before ``pos``.""" - start = max(0, pos - max_lookback) - for i in range(pos - min(_ROW_TOPOLOGY_PRELUDE_LEN_RANGE), start - 1, -1): - block = _parse_row_topology_block(data, i) - if block is not None: - return block - return None - - -def _find_row_topology_blocks_between( +def _find_all_row_topology_blocks( data: bytes, start: int, - end: int, ) -> list[_ScrRowTopologyBlock]: - """Find row-topology blocks whose starts fall within ``[start, end)``.""" + """Forward-scan all topology blocks from ``start`` to end of ``data``. + + Skips past each found block's full extent (``continuation_start``), + preventing false positives from flag-table tail bytes. + """ blocks: list[_ScrRowTopologyBlock] = [] pos = max(0, start) - last_start = -1 + end = len(data) while pos < end: block = _parse_row_topology_block(data, pos) - if block is not None and block.start < end: - if block.start != last_start: - blocks.append(block) - last_start = block.start - pos = block.flags_start + if block is not None: + blocks.append(block) + pos = block.continuation_start continue pos += 1 return blocks @@ -1181,13 +1136,22 @@ def decode_program(data: bytes) -> Program: if not sections: return Program(name=name, prog_idx=prog_idx, rungs=[]) + # Forward-scan all topology blocks once. Skipping past each block's + # full extent (continuation_start) prevents false positives from + # flag-table tail bytes that coincidentally match the prefix. + all_topo_blocks = _find_all_row_topology_blocks(data, data_start) + + # Assign topology blocks to sections by position. For each section, + # blocks whose start falls in [prev_sec_end, sec_off) belong to it: + # earlier ones are empty rungs, the last one is the section's header. + topo_idx = 0 + rungs: list[Rung] = [] prev_sec_end = data_start for sec_off, count, sec_end in sections: section_instrs = _parse_section_instructions(data, sec_off, count) inferred_rows = 1 - has_condition_cells = False for ( row, col, @@ -1200,8 +1164,6 @@ def decode_program(data: bytes) -> Program: variant_string_tags, ) in section_instrs: inferred_rows = max(inferred_rows, row + 1) - if col < _CONDITION_COLUMNS: - has_condition_cells = True if col == _CONDITION_COLUMNS: visual_rows = _infer_af_visual_rows( class_name, @@ -1214,11 +1176,18 @@ def decode_program(data: bytes) -> Program: ) inferred_rows = max(inferred_rows, row + visual_rows) - # Find nearest row-topology block before this section. - topology_block = _find_row_topology_block(data, sec_off) - headerless = topology_block is None or ( - topology_block.start < prev_sec_end and inferred_rows == 1 and not has_condition_cells - ) + # Collect topology blocks in [prev_sec_end, sec_off). + region_blocks: list[_ScrRowTopologyBlock] = [] + while topo_idx < len(all_topo_blocks) and all_topo_blocks[topo_idx].start < sec_off: + if all_topo_blocks[topo_idx].start >= prev_sec_end: + region_blocks.append(all_topo_blocks[topo_idx]) + topo_idx += 1 + + # Last block in the region (if any) is the section's topology header. + topology_block = region_blocks[-1] if region_blocks else None + empty_blocks = region_blocks[:-1] if region_blocks else [] + + headerless = topology_block is None if headerless: # Headerless rung (e.g. next() after for()) — single row, all wired rtf_bytes, comment = _find_rtf_comment(data, prev_sec_end, sec_off) @@ -1237,13 +1206,10 @@ def decode_program(data: bytes) -> Program: assert topology_block is not None comment_start = prev_sec_end - empty_topology_blocks = _find_row_topology_blocks_between( - data, prev_sec_end, topology_block.start - ) - for idx, empty_block in enumerate(empty_topology_blocks): + for idx, empty_block in enumerate(empty_blocks): empty_end = ( - empty_topology_blocks[idx + 1].start - if idx + 1 < len(empty_topology_blocks) + empty_blocks[idx + 1].start + if idx + 1 < len(empty_blocks) else topology_block.start ) empty_rtf_bytes, empty_comment = _find_rtf_comment( diff --git a/tests/csv/test_converter.py b/tests/csv/test_converter.py index c46d77b..74a7ccc 100644 --- a/tests/csv/test_converter.py +++ b/tests/csv/test_converter.py @@ -19,6 +19,7 @@ from laddercodec.csv.contract import CONDITION_COLUMNS, CSV_HEADER from laddercodec.csv.converter import ( ConvertError, + _hydrate_wire_continuations, af_node_to_token, condition_node_to_token, convert_rung, @@ -660,3 +661,82 @@ def test_unsupported_af_with_pin_row_not_strict(self, tmp_path: Path) -> None: assert lr == 2 assert afs[0] == "" assert afs[1] == "" + + +class TestHydrateWireContinuations: + def test_pipe_added_under_T_non_A_column(self) -> None: + """T in column B propagates | to the blank cell below.""" + rows = [ + ["", "T"] + [""] * 29, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "|" + assert rows[2][1] == "" # last row never written to + + def test_pipe_propagates_multi_row(self) -> None: + """| from hydration continues propagating downward.""" + rows = [ + ["", "", "T"] + [""] * 28, + [""] * 31, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][2] == "|" + assert rows[2][2] == "|" # propagated from row 1's newly-added | + assert rows[3][2] == "" # last row untouched + + def test_does_not_overwrite_existing_content(self) -> None: + """Only blank cells get hydrated.""" + rows = [ + ["", "T"] + [""] * 29, + ["", "-"] + [""] * 29, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "-" # preserved, not overwritten + + def test_two_row_rung_is_noop(self) -> None: + """With only 2 rows, hydration does nothing (no safe target row).""" + rows = [ + ["", "T"] + [""] * 29, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "" # unchanged — last row, not eligible + + def test_existing_pipe_also_propagates(self) -> None: + """| in the source row also propagates downward, not just T.""" + rows = [ + ["", "|"] + [""] * 29, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "|" + + def test_timer_autopad_hydrates_T_wire(self, tmp_path: Path) -> None: + """Full converter round-trip: timer with T wire in non-A column.""" + csv_path = tmp_path / "main.csv" + row0 = [""] * len(CONDITION_COLUMNS) + row0[0] = "X001" + row0[1] = "T" + for i in range(2, len(CONDITION_COLUMNS)): + row0[i] = "-" + _write_csv( + csv_path, + [ + ("R", row0, "on_delay(T1,TD1,preset=1000,unit=Tms)"), + ("", _wire_row("X002"), "out(Y001)"), + ], + ) + + rung = parse_csv_file(csv_path).rungs[0] + lr, conds, afs, _ = convert_rung(rung) + # Timer auto-pads to 2 rows, plus the coil row = 3 total + assert lr == 3 + # T in col B (index 1) on row 0 should hydrate | on row 1 + assert conds[0][1] == "T" + assert conds[1][1] == "|" diff --git a/tests/csv/test_writer.py b/tests/csv/test_writer.py index cb39a67..6cec404 100644 --- a/tests/csv/test_writer.py +++ b/tests/csv/test_writer.py @@ -646,6 +646,65 @@ def test_later_tall_block_before_following_af_roundtrips( assert round_tripped.instructions == rung.instructions +class TestDehydrateHydrateWires: + def test_T_wire_pipe_dehydrate_hydrate_roundtrip(self, tmp_path: Path) -> None: + """Padding row with | under T is stripped by writer, restored by converter.""" + search = Search("DS72", "DS81", "DS71", "DS82", "C81", "==") + rung = Rung( + logical_rows=3, + conditions=[ + [Contact(InstructionType.CONTACT_NO, "C10"), "T"] + ["-"] * 29, + ["", "|"] + [""] * 29, + [Contact(InstructionType.CONTACT_NO, "C11")] + ["-"] * 30, + ], + instructions=[search, "", Coil(InstructionType.COIL_OUT, "Y001")], + comment_rtf=None, + comment=None, + ) + + # Writer dehydrates: padding row (only blank + |) is stripped + rows = decoded_rung_to_rows(rung) + assert len(rows) == 2 + assert rows[0][2] == "T" # T preserved in col B + + # Full round-trip: hydration restores | under T + out = tmp_path / "t-wire.csv" + write_csv(out, [rung]) + [rt] = read_csv(out) + + assert rt.logical_rows == 3 + assert rt.conditions[0][1] == "T" + assert rt.conditions[1][1] == "|" # hydrated back + + def test_multi_column_T_wires_roundtrip(self, tmp_path: Path) -> None: + """Multiple T wires in different non-A columns all hydrate correctly.""" + search = Search("DS72", "DS81", "DS71", "DS82", "C81", "==") + conds_row0 = [Contact(InstructionType.CONTACT_NO, "C20")] + ["-"] * 30 + conds_row0[5] = "T" # col F + conds_row0[15] = "T" # col P + rung = Rung( + logical_rows=3, + conditions=[ + conds_row0, + ["", "", "", "", "", "|"] + [""] * 9 + ["|"] + [""] * 15, + [Contact(InstructionType.CONTACT_NO, "C21")] + ["-"] * 30, + ], + instructions=[search, "", Coil(InstructionType.COIL_OUT, "Y002")], + comment_rtf=None, + comment=None, + ) + + out = tmp_path / "multi-t-wire.csv" + write_csv(out, [rung]) + [rt] = read_csv(out) + + assert rt.logical_rows == 3 + assert rt.conditions[0][5] == "T" + assert rt.conditions[0][15] == "T" + assert rt.conditions[1][5] == "|" # hydrated + assert rt.conditions[1][15] == "|" # hydrated + + class TestMultiPinnedRoundTrip: def test_count_up_then_retained_timer_roundtrip(self, tmp_path: Path) -> None: counter = Counter( diff --git a/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin b/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin new file mode 100644 index 0000000..ee83ce8 Binary files /dev/null and b/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin differ diff --git a/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.csv b/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.csv new file mode 100644 index 0000000..b34a2a7 --- /dev/null +++ b/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.csv @@ -0,0 +1,4 @@ +marker,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA,AB,AC,AD,AE,AF +R,C1,-,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"time_drum(outputs=[C2],presets=[1],unit=Tms,pattern=[[0]],current_step=DS1,accumulator=TD1,completion_flag=C1)" +,,,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,.reset() +,C2,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"copy(1,DS1)" diff --git a/tests/fixtures/ladder_captures/golden/verify_progress.log b/tests/fixtures/ladder_captures/golden/verify_progress.log index 5cd7752..2b0878f 100644 --- a/tests/fixtures/ladder_captures/golden/verify_progress.log +++ b/tests/fixtures/ladder_captures/golden/verify_progress.log @@ -43,3 +43,4 @@ instr-coil-timer-ret: worked instr-6row-multi-output: worked instr-copy-multirow-2af: worked instr-3row-branch: worked +instr-copy-multirow-2af-T: worked diff --git a/tests/ladder/test_decode_program.py b/tests/ladder/test_decode_program.py index 7e93d87..7715f20 100644 --- a/tests/ladder/test_decode_program.py +++ b/tests/ladder/test_decode_program.py @@ -8,7 +8,7 @@ from laddercodec.csv import read_csv from laddercodec.decode import inspect_cells from laddercodec.decode_program import ( - _find_row_topology_block, + _find_all_row_topology_blocks, _find_sections, _implied_modifier_row_offsets, _parse_extra_row_right_wires, @@ -38,6 +38,29 @@ _COL_IDX_BY_NAME = {name: idx for idx, name in _COL_NAMES.items()} +def _topology_blocks_by_section( + scr_data: bytes, +) -> dict[int, object]: + """Map section index to topology block using forward scanning.""" + _name, _prog_idx, data_start = _parse_header(scr_data) + sections = _find_sections(scr_data, start=data_start) + all_blocks = _find_all_row_topology_blocks(scr_data, data_start) + + result: dict[int, object] = {} + topo_idx = 0 + prev_sec_end = data_start + for sec_idx, (sec_off, _count, sec_end) in enumerate(sections): + last_block = None + while topo_idx < len(all_blocks) and all_blocks[topo_idx].start < sec_off: + if all_blocks[topo_idx].start >= prev_sec_end: + last_block = all_blocks[topo_idx] + topo_idx += 1 + if last_block is not None: + result[sec_idx] = last_block + prev_sec_end = sec_end + return result + + def _load_fixture_pair(name: str): scr_data = (_SCR_FIXTURE_DIR / f"{name}.scr").read_bytes() clip_data = (_SCR_FIXTURE_DIR / f"{name}.bin").read_bytes() @@ -516,13 +539,14 @@ def test_parse_extra_row_right_wires_matches_or_topology_clipboard_columns(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, clip_rung in enumerate(clip_rungs): if clip_rung.logical_rows < 2: continue sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None extra_rows_right_wires, marker_pos = _parse_extra_row_right_wires( @@ -552,6 +576,7 @@ def test_continuation_row_next_seg_matches_successor_segment_flags(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) saw_wrapped_order = False @@ -560,7 +585,7 @@ def test_continuation_row_next_seg_matches_successor_segment_flags(): continue sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None pos = block.continuation_start @@ -701,13 +726,8 @@ def test_decode_program_keeps_comments_for_consecutive_empty_topology_rungs(monk ) monkeypatch.setattr( decode_program_module, - "_find_row_topology_block", - lambda data, pos: main_block, - ) - monkeypatch.setattr( - decode_program_module, - "_find_row_topology_blocks_between", - lambda data, start, end: [empty_block_1, empty_block_2], + "_find_all_row_topology_blocks", + lambda data, start: [empty_block_1, empty_block_2, main_block], ) def fake_find_rtf_comment(data, start, end): @@ -733,10 +753,11 @@ def test_parse_31_entry_row_topology_blocks_in_coverage_fixture(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx in [58, 70, 71, 72, 75, 76]: sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.row0_flag_count == 31 assert block.prelude.endswith(b"\x1f\x00") @@ -763,6 +784,7 @@ def test_counter_row_topology_blocks_capture_variable_preludes(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, expected_prelude, expected_rows in ( (2, bytes.fromhex("0400030000000000012000"), [[]]), @@ -770,7 +792,7 @@ def test_counter_row_topology_blocks_capture_variable_preludes(): ): sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start > prev_sec_end assert block.row_word == 4 @@ -792,11 +814,12 @@ def test_counter_count_down_with_row0_data_uses_local_sparse_topology_block(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) rung_idx = 6 sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == 0xA58 @@ -822,11 +845,12 @@ def test_counter_count_down_with_row0_and_row1_data_uses_two_local_sparse_leadin _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) rung_idx = 7 sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == 0xC93 @@ -857,6 +881,7 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, expected_start, expected_prelude, expected_rows in ( ( @@ -874,7 +899,7 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi ): sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == expected_start @@ -892,41 +917,18 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi assert [sorted(cols) for cols in raw_rows] == expected_rows -def test_forward_parser_agrees_with_brute_force_for_all_topology_blocks(): - """The deterministic forward parser must agree with brute-force for every block.""" - _forward_parse = decode_program_module._forward_parse_topology_block - _brute_force = decode_program_module._brute_force_topology_block +def test_forward_scanner_finds_all_topology_blocks(): + """The forward scanner must find a block for every rung with topology.""" _PREFIX = decode_program_module._ROW_TOPOLOGY_PREFIX for scr_path in sorted(_SCR_FIXTURE_DIR.glob("*.scr")): scr_data = scr_path.read_bytes() - _name, _prog_idx, data_start = _parse_header(scr_data) - sections = _find_sections(scr_data, start=data_start) - - for rung_idx, (sec_off, _count, _sec_end) in enumerate(sections): - block = _find_row_topology_block(scr_data, sec_off) - if block is None: - continue + topo_map = _topology_blocks_by_section(scr_data) + for rung_idx, block in topo_map.items(): pos = block.start - row_word = struct.unpack_from("= 1, f"{scr_path.name} rung {rung_idx}: empty flag table" def test_tag_wire_type_covers_all_implicit_tags():