diff --git a/tests/dsn/fixtures/ripup_needed.dsn b/tests/dsn/fixtures/ripup_needed.dsn new file mode 100644 index 0000000..02818a6 --- /dev/null +++ b/tests/dsn/fixtures/ripup_needed.dsn @@ -0,0 +1,64 @@ +(pcb "ripup_needed.dsn" + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "freeroute-test") + (host_version "1.0") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type power) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 0 0 200000 0 200000 -80000 0 -80000 0 0) + ) + (rule + (width 2000) + (clearance 2000) + ) + ) + (library + (padstack Rect_Pad + (shape (rect F.Cu -1000 -1000 1000 1000)) + (attach off) + ) + (image PAD + (pin Rect_Pad 1 0 0) + ) + ) + (placement + (component PAD + (place A1 2000 -40000 front 0) + (place A2 198000 -40000 front 0) + (place B1 100000 -15000 front 0) + (place B2 100000 -65000 front 0) + ) + ) + (network + (net NET_A + (pins A1-1 A2-1) + ) + (net NET_B + (pins B1-1 B2-1) + ) + (class default + (rule + (width 2000) + (clearance 2000) + ) + ) + ) + (wiring + ) +) diff --git a/tests/route/test_router.py b/tests/route/test_router.py index adbeb18..856da00 100644 --- a/tests/route/test_router.py +++ b/tests/route/test_router.py @@ -26,6 +26,15 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" SIMPLE = FIXTURES / "simple_2net.dsn" CROSSING = FIXTURES / "crossing_2net.dsn" +RIPUP = FIXTURES / "ripup_needed.dsn" + + +def _wire_key(result): + """A hashable snapshot of a routing result for determinism checks.""" + return { + net: [(layer, [(p.x, p.y) for p in pts]) for layer, pts in segs] + for net, segs in result.wires.items() + } def _network_out(ses_text: str): @@ -96,9 +105,9 @@ def test_simple_trace_endpoints_sit_on_the_net_pads(): # --- multi-layer crossing case ---------------------------------------------- -def test_single_layer_cannot_route_the_crossing_board(): - result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()), layers=[0]) - # NET_A is a full-width wall; on one layer NET_B cannot cross it +def test_greedy_single_layer_strands_a_net_on_the_crossing_board(): + # greedy (no rip-up) on one layer: NET_A's straight wall strands NET_B + result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()), layers=[0], rip_up=False) assert len(result.routed_net_numbers) < 2 assert result.via_count() == 0 @@ -145,6 +154,57 @@ def test_via_endpoints_come_from_the_net_pads_across_layers(): assert endpoints == pins_by_net[net_no] +# --- rip-up-and-retry (single-layer congestion) ----------------------------- + + +def test_greedy_strands_a_net_on_the_congested_board(): + # ripup_needed has one signal layer (no vias); greedy net order strands NET_B + result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=False) + assert result.unrouted >= 1 + assert len(result.routed_net_numbers) < 2 + assert result.via_count() == 0 # single signal layer -> vias impossible + + +def test_ripup_connects_all_on_the_congested_board(): + result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True) + assert result.unrouted == 0 + assert result.routed_net_numbers == {1, 2} # rip-up reroutes to connect both + assert result.via_count() == 0 + + +def test_ripup_result_is_deterministic(): + a, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True) + b, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True) + assert _wire_key(a) == _wire_key(b) + + +def test_ripup_endpoints_still_on_pads_and_no_same_layer_crossing(): + result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True) + board = build_board(parse_dsn(RIPUP.read_text())) + pins_by_net: dict[int, set[tuple[int, int]]] = {} + for pin in board.get_pins(): + for net_no in pin.net_nos: + pins_by_net.setdefault(net_no, set()).add((pin.location.x, pin.location.y)) + per_layer: dict[int, list[tuple[int, list]]] = {} + for net_no, segments in result.wires.items(): + pts = [(p.x, p.y) for _, seg in segments for p in seg] + assert {pts[0], pts[-1]} == pins_by_net[net_no] # endpoints on pads + for layer, seg in segments: + per_layer.setdefault(layer, []).append((net_no, [(p.x, p.y) for p in seg])) + for entries in per_layer.values(): + for i in range(len(entries)): + net_i, poly_i = entries[i] + for j in range(i + 1, len(entries)): + net_j, poly_j = entries[j] + if net_i == net_j: + continue + for a in range(len(poly_i) - 1): + for b in range(len(poly_j) - 1): + assert not _segments_properly_cross( + poly_i[a], poly_i[a + 1], poly_j[b], poly_j[b + 1] + ) + + # --- cross-net invariants --------------------------------------------------- @@ -188,14 +248,35 @@ def test_traces_stay_on_valid_layers(): assert layer in {"Top", "Bottom"} -def test_router_does_not_crash_on_larger_board(): - result, _, _ = route_dsn_board(parse_dsn((FIXTURES / "kicad_routable.dsn").read_text())) - assert isinstance(result.routed_net_numbers, set) +def test_larger_board_still_connects_its_multipin_nets(): + # regression: rip-up must not lose coverage on the real board. It has 4 + # multi-pin nets, all of which the router connects. + dsn = parse_dsn((FIXTURES / "kicad_routable.dsn").read_text()) + result, _, _ = route_dsn_board(dsn) + multi_pin = sum(1 for n in dsn.nets if len(n.pins) >= 2) + assert len(result.routed_net_numbers) == multi_pin == 4 # --- oracle parity ---------------------------------------------------------- +@pytest.mark.oracle +def test_connectivity_parity_on_congested_board(): + from oracle import HAS_ORACLE, route_dsn, routed_net_set + + if not HAS_ORACLE: + pytest.skip("FreeRouting oracle unavailable") + our_nets = routed_net_set(route(RIPUP.read_text())) + assert our_nets == {"NET_A", "NET_B"} # freeroute connects both via rip-up + oracle_nets = routed_net_set(route_dsn(RIPUP, max_passes=3, timeout=300)) + if not oracle_nets: + # the reference JAR does not route this synthetic single-signal-layer + # board (it expects a routing/via layer); freeroute's rip-up connecting + # both nets is already asserted above and by the fast tests. + pytest.skip("reference JAR routed nothing on the synthetic single-layer board") + assert oracle_nets <= our_nets # rip-up matches the JAR's connectivity + + @pytest.mark.oracle def test_connectivity_parity_on_crossing_board(): from oracle import HAS_ORACLE, route_dsn, routed_net_set