diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index b2fc3da..7e3f1aa 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -1,7 +1,16 @@ """freeroute command-line interface: Specctra DSN in, routed SES out. -Flag-compatible with the FreeRouting JAR (``-de`` / ``-do``) so it can be -dropped in wherever ``java -jar freerouting.jar`` was invoked. +Flag-compatible with the FreeRouting JAR (``-de`` / ``-do`` / ``-mp``) so it can +be dropped in wherever ``java -jar freerouting.jar`` was invoked, plus the +freeroute-specific engine selection (``--engine``) and its opt-in passes. + +Not every engine supports every pass. The matrix below is the single source of +truth; an unsupported combination is an error, never a silent no-op:: + + engine --pack --shove --diagonal --no-rip-up -mp/--max-passes + grid no no no yes yes + exact no yes yes yes yes + room yes yes no no no """ from __future__ import annotations @@ -10,11 +19,48 @@ import argparse import sys from freeroute.dsn.reader import parse_dsn -from freeroute.route.pipeline import build_routing_result +from freeroute.route.pipeline import ( + build_exact_routing_result, + build_rooms_routing_result, + build_routing_result, +) from freeroute.ses import write_ses +#: which optional flags each engine actually implements. Anything not listed for +#: an engine is rejected by :func:`_validate` rather than quietly ignored. +ENGINE_OPTIONS: dict[str, frozenset[str]] = { + "grid": frozenset({"rip_up", "max_passes"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}), + "room": frozenset({"shove", "pack"}), +} -def main(argv: list[str] | None = None) -> int: +#: option key -> the CLI spelling to name in an error message +_FLAG_NAMES = { + "pack": "--pack", + "shove": "--shove", + "diagonal": "--diagonal", + "rip_up": "--no-rip-up", + "max_passes": "-mp/--max-passes", +} + + +def _supported_by(option: str) -> str: + engines = [e for e, opts in ENGINE_OPTIONS.items() if option in opts] + return " or ".join(f"--engine {e}" for e in engines) + + +def _validate(parser: argparse.ArgumentParser, engine: str, requested: list[str]) -> None: + """Reject any requested option the selected engine does not implement.""" + supported = ENGINE_OPTIONS[engine] + for option in requested: + if option not in supported: + parser.error( + f"{_FLAG_NAMES[option]} is not supported by --engine {engine} " + f"(supported by: {_supported_by(option)})" + ) + + +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="freeroute", description="Native PCB autorouter — route a Specctra DSN and write an SES session file.", @@ -31,17 +77,67 @@ def main(argv: list[str] | None = None) -> int: "--max-passes", type=int, default=None, - help="accepted for FreeRouting CLI compatibility (advisory; freeroute self-bounds passes)", + help="rip-up-and-retry pass bound (grid and exact engines; default 10)", + ) + parser.add_argument( + "--engine", + choices=sorted(ENGINE_OPTIONS), + default="grid", + help=( + "routing track: grid (default; multi-layer rip-up maze, highest coverage), " + "exact (orthogonal, exact-geometry DRC-verified output), " + "room (continuous expansion rooms, routes off-grid channels)" + ), + ) + parser.add_argument( + "--pack", + action="store_true", + help="coordinated multi-trace channel packing (room engine only)", + ) + parser.add_argument( + "--shove", + action="store_true", + help="shove committed traces aside to recover dropped nets (exact and room engines)", + ) + parser.add_argument( + "--diagonal", + action="store_true", + help="45-degree recovery pass for dropped 2-pin nets (exact engine only)", + ) + parser.add_argument( + "--no-rip-up", + action="store_true", + help="disable rip-up-and-retry (grid and exact engines)", ) - parser.add_argument("--no-rip-up", action="store_true", help="disable rip-up-and-retry") parser.add_argument("--layers", help="comma-separated signal layer indices to route on") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() args = parser.parse_args(argv) dsn_path = args.design or args.dsn if not dsn_path: parser.error("no input DSN (pass a path, or -de )") - layers = [int(x) for x in args.layers.split(",")] if args.layers else None + requested = [ + option + for option, asked in ( + ("pack", args.pack), + ("shove", args.shove), + ("diagonal", args.diagonal), + ("rip_up", args.no_rip_up), + ("max_passes", args.max_passes is not None), + ) + if asked + ] + _validate(parser, args.engine, requested) + + try: + layers = [int(x) for x in args.layers.split(",")] if args.layers else None + except ValueError: + parser.error(f"--layers must be comma-separated integers (got: {args.layers!r})") try: with open(dsn_path, encoding="utf-8") as f: @@ -51,7 +147,23 @@ def main(argv: list[str] | None = None) -> int: return 2 dsn = parse_dsn(dsn_text) - result = build_routing_result(dsn, layers=layers, rip_up=not args.no_rip_up) + rip_up = not args.no_rip_up + max_passes = args.max_passes if args.max_passes is not None else 10 + + if args.engine == "exact": + result = build_exact_routing_result( + dsn, + layers=layers, + shove=args.shove, + diagonal=args.diagonal, + rip_up=rip_up, + max_passes=max_passes, + ) + elif args.engine == "room": + result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack) + else: + result = build_routing_result(dsn, layers=layers, rip_up=rip_up, max_passes=max_passes) + ses_text = write_ses(dsn, result) if args.output: diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index f697a50..60bdc72 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -97,10 +97,16 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) def build_routing_result( - dsn: DsnBoard, *, layers: list[int] | None = None, rip_up: bool = True + dsn: DsnBoard, + *, + layers: list[int] | None = None, + rip_up: bool = True, + max_passes: int = 10, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" - route, scale, layer_names = route_dsn_board(dsn, layers=layers, rip_up=rip_up) + route, scale, layer_names = route_dsn_board( + dsn, layers=layers, rip_up=rip_up, max_passes=max_passes + ) return _to_routing_result(route, dsn, scale, layer_names) @@ -136,11 +142,22 @@ def route_dsn_board_exact( def build_exact_routing_result( - dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, diagonal: bool = False + dsn: DsnBoard, + *, + layers: list[int] | None = None, + shove: bool = False, + diagonal: bool = False, + rip_up: bool = True, + max_passes: int = 10, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( - dsn, layers=layers, shove=shove, diagonal=diagonal + dsn, + layers=layers, + shove=shove, + diagonal=diagonal, + rip_up=rip_up, + max_passes=max_passes, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1657207..1b96ba6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,9 @@ -"""Tests for the freeroute CLI (Specctra DSN in, SES out).""" +"""Tests for the freeroute CLI (Specctra DSN in, SES out). + +Covers the engine selection contract: every engine must route a fixture from the +command line, and every option the selected engine does not implement must fail +loudly rather than being silently dropped. +""" from __future__ import annotations @@ -6,9 +11,13 @@ from pathlib import Path import pytest -from freeroute.cli import main +from freeroute.cli import ENGINE_OPTIONS, main -FIXTURE = Path(__file__).parent / "dsn" / "fixtures" / "simple_2net.dsn" +FIXTURES = Path(__file__).parent / "dsn" / "fixtures" +FIXTURE = FIXTURES / "simple_2net.dsn" +CHANNEL_PACK = FIXTURES / "channel_pack.dsn" +SHOVE_NEEDED = FIXTURES / "shove_needed.dsn" +DIAGONAL_WIN = FIXTURES / "diagonal_win.dsn" def test_cli_jar_compatible_flags_write_ses(tmp_path): @@ -35,3 +44,114 @@ def test_cli_unreadable_input_returns_2(capsys): rc = main(["/nonexistent/board.dsn"]) assert rc == 2 assert "cannot read" in capsys.readouterr().err + + +def test_cli_bad_layers_errors(capsys): + with pytest.raises(SystemExit): + main([str(FIXTURE), "--layers", "top,bottom"]) + assert "--layers must be comma-separated integers" in capsys.readouterr().err + + +# --- engine selection ------------------------------------------------------- + + +@pytest.mark.parametrize("engine", sorted(ENGINE_OPTIONS)) +def test_cli_every_engine_routes_a_fixture(engine, tmp_path): + out = tmp_path / f"{engine}.ses" + rc = main([str(FIXTURE), "--engine", engine, "-do", str(out)]) + assert rc == 0 + text = out.read_text() + assert text.startswith("(session") + assert "(wire" in text + + +def test_cli_default_engine_is_grid(tmp_path): + """The default must stay grid — the drop-in JAR replacement behaviour.""" + default = tmp_path / "default.ses" + grid = tmp_path / "grid.ses" + assert main([str(FIXTURE), "-do", str(default)]) == 0 + assert main([str(FIXTURE), "--engine", "grid", "-do", str(grid)]) == 0 + assert default.read_text() == grid.read_text() + + +def test_cli_room_pack_routes(tmp_path): + out = tmp_path / "pack.ses" + rc = main([str(CHANNEL_PACK), "--engine", "room", "--pack", "-do", str(out)]) + assert rc == 0 + assert "(wire" in out.read_text() + + +def test_cli_room_shove_routes(tmp_path): + out = tmp_path / "shove.ses" + rc = main([str(SHOVE_NEEDED), "--engine", "room", "--shove", "-do", str(out)]) + assert rc == 0 + assert "(wire" in out.read_text() + + +def test_cli_exact_shove_routes(tmp_path): + out = tmp_path / "shove.ses" + rc = main([str(SHOVE_NEEDED), "--engine", "exact", "--shove", "-do", str(out)]) + assert rc == 0 + assert "(wire" in out.read_text() + + +def test_cli_exact_diagonal_routes(tmp_path): + out = tmp_path / "diag.ses" + rc = main([str(DIAGONAL_WIN), "--engine", "exact", "--diagonal", "-do", str(out)]) + assert rc == 0 + assert "(wire" in out.read_text() + + +def test_cli_max_passes_is_honoured_not_advisory(tmp_path): + out = tmp_path / "mp.ses" + rc = main([str(FIXTURE), "--engine", "exact", "-mp", "3", "-do", str(out)]) + assert rc == 0 + assert "(wire" in out.read_text() + + +# --- unsupported combinations must error, never be silently ignored --------- + + +@pytest.mark.parametrize( + ("argv", "flag"), + [ + (["--engine", "grid", "--pack"], "--pack"), + (["--engine", "grid", "--shove"], "--shove"), + (["--engine", "grid", "--diagonal"], "--diagonal"), + (["--engine", "exact", "--pack"], "--pack"), + (["--engine", "room", "--diagonal"], "--diagonal"), + (["--engine", "room", "--no-rip-up"], "--no-rip-up"), + (["--engine", "room", "-mp", "5"], "-mp/--max-passes"), + ], +) +def test_cli_unsupported_option_errors(argv, flag, capsys): + with pytest.raises(SystemExit) as excinfo: + main([str(FIXTURE), *argv]) + assert excinfo.value.code == 2 + err = capsys.readouterr().err + assert flag in err + assert "not supported by --engine" in err + assert "supported by:" in err + + +def test_cli_unknown_engine_errors(capsys): + with pytest.raises(SystemExit): + main([str(FIXTURE), "--engine", "quantum"]) + assert "invalid choice" in capsys.readouterr().err + + +def test_cli_supported_combinations_are_accepted(tmp_path): + """Every (engine, flag) pair the matrix claims to support must actually run.""" + argv_for = { + "pack": ["--pack"], + "shove": ["--shove"], + "diagonal": ["--diagonal"], + "rip_up": ["--no-rip-up"], + "max_passes": ["-mp", "4"], + } + for engine, options in ENGINE_OPTIONS.items(): + for option in options: + out = tmp_path / f"{engine}-{option}.ses" + rc = main([str(FIXTURE), "--engine", engine, *argv_for[option], "-do", str(out)]) + assert rc == 0, f"{engine} + {option} failed" + assert out.read_text().startswith("(session")