diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da5de738..4e391b29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: # run: uv run pyright chipcompiler - name: Pytest - run: uv run --no-sync pytest test/ --ignore=test/test_harden.py --ignore=test/test_rcx.py --ignore=test/examples/test_soc.py --cov=chipcompiler --cov-report= + run: uv run --no-sync pytest test/ --ignore=test/integration/test_harden_flow.py --ignore=test/integration/test_rcx_flow.py --ignore=test/examples/test_soc.py --cov=chipcompiler --cov-report= - name: Publish coverage summary if: always() diff --git a/docs/development.md b/docs/development.md index 2712e831..5515a20e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,7 +95,7 @@ uv run isort chipcompiler/ test/ ```bash uv run pytest test/ -uv run pytest test/test_tools_yosys_utility.py -v +uv run pytest test/tools/yosys/test_utility.py -v uv run pytest test/ --cov=chipcompiler --cov-report=term-missing uv run pytest test/formal/ -v ``` @@ -349,7 +349,7 @@ For the ICS55 GCD tool integration test: nix develop export PATH=/path/to/ecc-sizer/build/src:$PATH export CHIPCOMPILER_ICS55_PDK_ROOT=/path/to/ics55-pdk -.venv/bin/python -m pytest test/test_tools.py::test_ics55_gcd -q -s +.venv/bin/python -m pytest test/integration/test_rtl2gds_flow.py::test_ics55_gcd -q -s ``` ### PDK diff --git a/docs/specification/filelist-grammar.md b/docs/specification/filelist-grammar.md index 2e7d424a..6f53ab68 100644 --- a/docs/specification/filelist-grammar.md +++ b/docs/specification/filelist-grammar.md @@ -73,7 +73,7 @@ any_char = ? any Unicode character ? ; ## Testing -See `test/test_filelist.py` for test coverage: +See `test/utility/filelist/` and `test/data/test_workspace_filelist.py` for test coverage: - Basic filelist parsing - `+incdir` directive parsing @@ -88,4 +88,4 @@ See `test/test_filelist.py` for test coverage: ## References -- VCS User Guide - [User Guide](https://picture.iczhiku.com/resource/eetop/WhKDeOKWsJfQibVv.pdf) \ No newline at end of file +- VCS User Guide - [User Guide](https://picture.iczhiku.com/resource/eetop/WhKDeOKWsJfQibVv.pdf) diff --git a/pyproject.toml b/pyproject.toml index 0c75e029..7c11feeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,8 +104,13 @@ extend-include = ["*.spec"] [tool.pytest.ini_options] testpaths = ["test"] norecursedirs = [".venv"] +markers = [ + "integration: tests that run an ECC flow or large workspace orchestration", + "pdk: tests that require a complete external PDK installation", + "slow: tests that are expected to be expensive on a normal developer shell", +] addopts = [ - "--deselect=test/test_tools.py::test_sg13g2_gcd", + "--deselect=test/integration/test_rtl2gds_flow.py::test_sg13g2_gcd", ] [tool.ty] diff --git a/test/cli/commands/test_check.py b/test/cli/commands/test_check.py new file mode 100644 index 00000000..6604b709 --- /dev/null +++ b/test/cli/commands/test_check.py @@ -0,0 +1,237 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestCheck: + def test_check_passes_valid_config(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "checked" in out + + def test_check_from_inside_project_dir(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.chdir(project_dir) + rc = cli_main.run(["check"]) + assert rc == 0 + out = capsys.readouterr().out + assert "checked" in out + + def test_check_fails_missing_ecc_toml(self, tmp_path): + rc = cli_main.run(["check", "--project", str(tmp_path)]) + assert rc == 1 + + def test_check_fails_malformed_toml(self, tmp_path, capsys): + project_dir = tmp_path / "bad" + project_dir.mkdir() + (project_dir / "ecc.toml").write_text("[design\ninvalid {{{") + rc = cli_main.run(["check", "--project", str(project_dir)]) + assert rc == 1 + + def test_check_fails_missing_rtl(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path, "w") as f: + f.write( + '[design]\nname="gcd"\ntop="gcd"\nrtl=["rtl/missing.v"]\n' + 'clock_port="clk"\nfrequency_mhz=100\n' + '[pdk]\nname="ics55"\nroot=""\n' + '[flow]\npreset="rtl2gds"\nrun="default"\n', + ) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_empty_pdk_root(self, tmp_path, create_cli_project): + project_dir = create_cli_project(pdk_root="") + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_non_directory_pdk_root(self, tmp_path, create_cli_project): + pdk_root = tmp_path / "ics55.txt" + pdk_root.write_text("not a dir") + project_dir = create_cli_project(pdk_root=str(pdk_root)) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_unsupported_pdk(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace('name = "ics55"', 'name = "unsupported"') + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_unsupported_preset(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace('preset = "rtl2gds"', 'preset = "unknown"') + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_non_positive_frequency(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace("frequency_mhz = 100.0", "frequency_mhz = -10") + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_multiple_rtl(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace( + 'rtl = ["rtl/gcd.v"]', + 'rtl = ["rtl/a.v", "rtl/b.v"]', + ) + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_fails_non_numeric_frequency(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace("frequency_mhz = 100.0", 'frequency_mhz = "fast"') + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 1 + + def test_check_json_output(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 0 + out = capsys.readouterr().out + data = json.loads(out) + assert "records" in data + assert data["records"][0]["status"] == "checked" + assert data["records"][0]["project"] == "gcd" + + +class TestCheckFilelistValidation: + def test_check_fails_filelist_with_missing_sources(self, tmp_path, monkeypatch): + from chipcompiler.cli.project.config import _validate_pdk_contents + + monkeypatch.setattr( + _validate_pdk_contents, "__wrapped__", lambda *a, **k: None, raising=False + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", lambda *a, **k: None + ) + + project_dir = tmp_path / "flproj" + project_dir.mkdir() + (project_dir / "rtl").mkdir() + (project_dir / "rtl" / "gcd.v").write_text("module gcd; endmodule") + + filelist = project_dir / "rtl" / "files.f" + filelist.write_text("gcd.v\nmissing.v\nother_missing.v\n") + + pdk_root = tmp_path / "ics55" + pdk_root.mkdir() + + toml = f'''[design] +name = "gcd" +top = "gcd" +rtl = ["rtl/files.f"] +clock_port = "clk" +frequency_mhz = 100.0 + +[pdk] +name = "ics55" +root = "{pdk_root}" + +[flow] +preset = "rtl2gds" +run = "default" +''' + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["check", "--project", str(project_dir)]) + assert rc == 1 + + def test_check_fails_invalid_filelist_directive(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", lambda *a, **k: None + ) + + project_dir = tmp_path / "flproj2" + project_dir.mkdir() + (project_dir / "rtl").mkdir() + + filelist = project_dir / "rtl" / "files.f" + filelist.write_text("gcd.v\n-f other.f\n") + + pdk_root = tmp_path / "ics55" + pdk_root.mkdir() + + toml = f'''[design] +name = "gcd" +top = "gcd" +rtl = ["rtl/files.f"] +clock_port = "clk" +frequency_mhz = 100.0 + +[pdk] +name = "ics55" +root = "{pdk_root}" + +[flow] +preset = "rtl2gds" +run = "default" +''' + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["check", "--project", str(project_dir)]) + assert rc == 1 + + +class TestMissingConfigErrorRecord: + def test_check_missing_config_has_kind_error_json(self, tmp_path, capsys): + rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert record["kind"] == "error" + assert record["error"] == "missing_config" + + def test_check_missing_config_has_kind_error_text(self, tmp_path, capsys): + rc = cli_main.run(["check", "--project", str(tmp_path)]) + assert rc == 1 + out = capsys.readouterr().out + assert "[error]" in out + assert "missing_config" in out + + def test_check_missing_config_has_disclosure_command(self, tmp_path, capsys): + rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert "inspect" in record or "inspect_cmd" in record diff --git a/test/cli/commands/test_disclosure.py b/test/cli/commands/test_disclosure.py new file mode 100644 index 00000000..baa11441 --- /dev/null +++ b/test/cli/commands/test_disclosure.py @@ -0,0 +1,96 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestDisclosureCommands: + def test_init_lines_have_disclosure(self, tmp_path, capsys, has_disclosure): + project_path = str(tmp_path / "disctest") + rc = cli_main.run(["init", project_path]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_check_lines_have_disclosure( + self, tmp_path, monkeypatch, capsys, create_cli_project, has_disclosure + ): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + rc = cli_main.run(["check", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_status_lines_have_disclosure( + self, tmp_path, capsys, create_cli_project, create_flow_json, has_disclosure + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_metrics_lines_have_disclosure( + self, tmp_path, capsys, create_cli_project, has_disclosure + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: + json.dump({"Cell number": 312}, f) + + rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_log_error_lines_have_disclosure(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: something failed\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "ecc log synthesis" in out + + def test_project_arg_propagated_to_disclosure( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert f"--project {project_dir}" in out + + def test_output_lowercase_tokens(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:01"}, + ], + ) + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "synthesis" in out + assert "success" in out diff --git a/test/cli/commands/test_init.py b/test/cli/commands/test_init.py new file mode 100644 index 00000000..b2fda996 --- /dev/null +++ b/test/cli/commands/test_init.py @@ -0,0 +1,41 @@ + +from chipcompiler.cli import main as cli_main + + +class TestInit: + def test_init_creates_skeleton(self, tmp_path): + project_path = str(tmp_path / "gcd") + rc = cli_main.run(["init", project_path]) + assert rc == 0 + + assert (tmp_path / "gcd" / "ecc.toml").exists() + assert (tmp_path / "gcd" / "rtl").is_dir() + assert (tmp_path / "gcd" / "constraints").is_dir() + assert (tmp_path / "gcd" / "runs").is_dir() + + def test_init_output_has_disclosure_commands(self, tmp_path, capsys): + project_path = str(tmp_path / "myproj") + rc = cli_main.run(["init", project_path]) + assert rc == 0 + out = capsys.readouterr().out + assert "ecc check" in out + assert "ecc run" in out + + def test_init_fails_if_ecc_toml_exists(self, tmp_path): + project_dir = tmp_path / "gcd" + project_dir.mkdir() + (project_dir / "ecc.toml").write_text("[design]\n") + rc = cli_main.run(["init", str(project_dir)]) + assert rc == 1 + + def test_init_rejects_empty_name(self): + rc = cli_main.run(["init", ""]) + assert rc == 1 + + def test_init_uses_basename_for_design_name(self, tmp_path): + project_path = str(tmp_path / "subdir" / "mydesign") + rc = cli_main.run(["init", project_path]) + assert rc == 0 + toml = (tmp_path / "subdir" / "mydesign" / "ecc.toml").read_text() + assert 'name = "mydesign"' in toml + assert "rtl/mydesign.v" in toml diff --git a/test/cli/commands/test_log.py b/test/cli/commands/test_log.py new file mode 100644 index 00000000..eda44862 --- /dev/null +++ b/test/cli/commands/test_log.py @@ -0,0 +1,980 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestLog: + def test_log_step_errors(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Info: running\nError: bad thing\nWarning: meh\nTraceback: crash\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "Error: bad thing" in out + assert "Traceback: crash" in out + assert "Warning: meh" in out + assert "Info: running" in out + + def test_log_step_errors_jsonl(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Info: running\nError: bad thing\n") + + rc = cli_main.run(["log", "synthesis", "--errors", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert any("Error" in obj["line"] for obj in objects) + + def test_log_no_step_shows_locations(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + log_dir = os.path.join(run_dir, "log") + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "flow.log"), "w") as f: + f.write("log content\n") + + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "ecc log" in out + + def test_log_no_step_discovers_step_logs(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\n") + + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "synthesis" in out + assert "Synthesis_yosys/log/synthesis.log" in out + assert "ecc log synthesis" in out + + def test_log_no_step_global_logs_have_disclosure(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + log_dir = os.path.join(run_dir, "log") + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "flow.log"), "w") as f: + f.write("content\n") + + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "ecc log" in out + + def test_log_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) + + rc = cli_main.run(["log", "nonexistent", "--project", project_dir]) + assert rc == 1 + + def test_log_missing_step_logs(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 1 + + +class TestLogDefaultShowsAllContent: + """AC-1: Default ecc log renders complete log content.""" + + def test_default_shows_all_lines(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("INFO: starting\nsome output\nError: bad\nWarning: meh\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "INFO: starting" in out + assert "some output" in out + assert "Error: bad" in out + assert "Warning: meh" in out + + def test_default_includes_header(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("ok\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "[log]" in out + assert "step=synthesis" in out + assert "source:" in out + + def test_blank_lines_preserved(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("line1\n\nline3\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "line1" in out + assert "line3" in out + + +class TestLogTracebackComplete: + """AC-2: Python traceback blocks remain complete and contiguous.""" + + def test_traceback_complete_in_default_output(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write( + "INFO: before\n" + "Traceback (most recent call last):\n" + ' File "app.py", line 42, in run\n' + " result = compute()\n" + " ^^^^^^^^^\n" + "ValueError: invalid value\n" + "INFO: after\n" + ) + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "Traceback (most recent call last):" in out + assert 'File "app.py", line 42' in out + assert "result = compute()" in out + assert "^^^^^^^^^" in out + assert "ValueError: invalid value" in out + + def test_traceback_complete_in_jsonl(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write('Traceback (most recent call last):\n File "a.py", line 1\nValueError: fail\n') + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert objects[0]["kind"] == "traceback" + assert objects[1]["kind"] == "traceback" + assert objects[2]["kind"] == "error" + + def test_keyboard_interrupt_jsonl_classified_as_error( + self, tmp_path, capsys, create_cli_project + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write( + 'Traceback (most recent call last):\n File "a.py", line 1\nKeyboardInterrupt\n' + ) + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert objects[0]["kind"] == "traceback" + assert objects[1]["kind"] == "traceback" + assert objects[2]["kind"] == "error" + assert objects[2]["line"] == "KeyboardInterrupt" + + +class TestLogPlainMode: + """AC-5: --plain emits full-content stable line records.""" + + def test_plain_has_all_fields(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\nINFO: ok\n") + + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + lines = [line for line in out.strip().split("\n") if line.strip()] + assert len(lines) == 2 + assert "step=synthesis" in lines[0] + assert "line_no=1" in lines[0] + assert "kind=error" in lines[0] + assert "line_no=2" in lines[1] + assert "kind=info" in lines[1] + + def test_plain_no_ansi(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\n") + + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "\x1b[" not in out + + def test_plain_stable_quoting_for_special_chars(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write('key=value path\\to\\file "quoted text"\n') + + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "\x1b[" not in out + lines = [line for line in out.strip().split("\n") if line.strip()] + assert len(lines) == 1 + assert 'line="key=value' in lines[0] + assert "inspect_cmd=" in lines[0] + + +class TestLogJsonlMode: + """AC-6: --jsonl emits full-content structured log objects.""" + + def test_jsonl_per_line_objects(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\nINFO: ok\nplain\n") + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert len(objects) == 3 + for obj in objects: + assert "step" in obj + assert "source" in obj + assert "line_no" in obj + assert "kind" in obj + assert "line" in obj + assert "inspect_cmd" in obj + + def test_jsonl_no_ansi(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\n") + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "\x1b[" not in out + + +class TestLogJsonMode: + """ecc log --json must produce JSON envelope output.""" + + def test_json_step_output(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("Error: bad\nINFO: ok\n") + + rc = cli_main.run(["log", "synthesis", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert len(data["records"]) == 2 + + def test_json_listing_output(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("content\n") + + rc = cli_main.run(["log", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + + +class TestLogListingMode: + """AC-7: ecc log without step lists available logs.""" + + def test_listing_shows_logs(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("content\n") + + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "synthesis" in out + assert "ecc log synthesis" in out + + def test_listing_no_logs_returns_no_log_status(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "no_logs" in out + + def test_listing_jsonl_records(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("content\n") + + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + assert any("step" in o for o in objects) + + def test_listing_plain_step_logs(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("content\n") + + rc = cli_main.run(["log", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "step=synthesis" in out + assert "source=" in out + assert "inspect_cmd=" in out + assert "line_no=" not in out + + def test_listing_plain_run_level_logs(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + log_dir = os.path.join(run_dir, "log") + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "flow.log"), "w") as f: + f.write("log content\n") + + rc = cli_main.run(["log", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "log=" in out + assert "inspect_cmd=" in out + assert "line_no=" not in out + assert "kind=" not in out + + +class TestLogErrorCases: + """AC-9: Error cases are structured and readable.""" + + def test_unknown_step_returns_nonzero(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["log", "nonexistent", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "unknown_step" in out + + def test_unknown_step_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["log", "nonexistent", "--jsonl", "--project", project_dir]) + assert rc == 1 + record = json.loads(capsys.readouterr().out.strip()) + assert record["status"] == "unknown_step" + + def test_known_step_no_logs_returns_nonzero(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing" in out + + def test_known_step_no_logs_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 1 + record = json.loads(capsys.readouterr().out.strip()) + assert record["log_status"] == "missing" + + def test_empty_log_returns_zero(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "empty" in out + + +class TestLogNoErrorsInDisclosure: + """AC-8: Disclosure commands do not include --errors.""" + + def test_listing_disclosure_no_errors(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("ok\n") + + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--errors" not in out + + def test_step_log_inspect_no_errors(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("ok\n") + + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--errors" not in out + + def test_status_disclosure_no_errors( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--errors" not in out + + def test_metrics_disclosure_no_errors(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: + json.dump({"Cell number": 100}, f) + + rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--errors" not in out + + def test_artifacts_log_disclosure_no_errors( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + log_dir = os.path.join(run_dir, "CTS_ecc", "log") + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "cts.log"), "w") as f: + f.write("log content\n") + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--errors" not in out + + +class TestLogUnreadableFile: + """AC-9: Unreadable log files return non-zero with OS error.""" + + def test_unreadable_log_returns_nonzero(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + with open(log_path, "w") as f: + f.write("content\n") + os.chmod(log_path, 0o000) + + try: + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "unreadable" in out + finally: + os.chmod(log_path, 0o644) + + def test_unreadable_log_jsonl(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + with open(log_path, "w") as f: + f.write("content\n") + os.chmod(log_path, 0o000) + + try: + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 1 + record = json.loads(capsys.readouterr().out.strip()) + assert record["log_status"] == "unreadable" + assert "source" in record + assert "error" in record + finally: + os.chmod(log_path, 0o644) + + +class TestLogMultiSource: + """AC-1: Multiple log files per step shown with separate source headers.""" + + def test_multi_source_pretty(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "a.log"), "w") as f: + f.write("from A\n") + with open(os.path.join(step_dir, "b.log"), "w") as f: + f.write("from B\n") + + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "a.log" in out + assert "b.log" in out + assert "from A" in out + assert "from B" in out + + +class TestLogErrorsDeprecation: + """AC-8: --errors is deprecated with visible notice.""" + + def test_errors_hidden_from_help(self, tmp_path, capsys): + rc = cli_main.run(["log", "--help"]) + assert rc == 0 + assert "--errors" not in capsys.readouterr().out + + def test_errors_emits_deprecation_warning(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("ok\n") + + rc = cli_main.run(["log", "synthesis", "--errors", "--project", project_dir]) + assert rc == 0 + err = capsys.readouterr().err + assert "deprecated" in err + + def test_errors_jsonl_still_full_records(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("INFO: running\nError: bad\n") + + rc = cli_main.run(["log", "synthesis", "--errors", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert len(objects) == 2 + assert objects[0]["kind"] == "info" + assert objects[1]["kind"] == "error" + assert "\x1b[" not in capsys.readouterr().out + + +class TestLogListingFlowOrder: + """Listing step logs follow flow.json order, not alphabetical.""" + + def _setup_steps_with_flow( + self, tmp_path, create_cli_project, create_flow_json, step_names, extra_dirs=None + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, steps=[{"name": n, "tool": "ecc", "state": "Success"} for n in step_names] + ) + all_dirs = list(step_names) + (extra_dirs or []) + tool_map = { + "Synthesis": "yosys", + "Floorplan": "ecc", + "fixFanout": "ecc", + "place": "ecc", + "CTS": "ecc", + "legalization": "ecc", + "route": "ecc", + "drc": "ecc", + "filler": "ecc", + } + for name in all_dirs: + tool = tool_map.get(name, "ecc") + step_dir = os.path.join(run_dir, f"{name}_{tool}", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, f"{name.lower()}.log"), "w") as f: + f.write(f"log from {name}\n") + return project_dir + + def test_steps_follow_flow_json_order( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = self._setup_steps_with_flow( + tmp_path, + create_cli_project, + create_flow_json, + ["Synthesis", "Floorplan", "CTS"], + ) + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + steps = [r.get("step") for r in records if "step" in r] + assert steps == ["synthesis", "floorplan", "cts"] + + def test_run_level_logs_before_step_logs( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = self._setup_steps_with_flow( + tmp_path, + create_cli_project, + create_flow_json, + ["Synthesis", "CTS"], + ) + run_dir = os.path.join(project_dir, "runs", "default") + log_dir = os.path.join(run_dir, "log") + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "flow.log"), "w") as f: + f.write("run-level log\n") + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + run_indices = [i for i, r in enumerate(records) if "log" in r and "step" not in r] + step_indices = [i for i, r in enumerate(records) if "step" in r] + assert run_indices, "expected at least one run-level record" + assert step_indices, "expected at least one step record" + assert max(run_indices) < min(step_indices) + + def test_extra_steps_after_flow_steps( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = self._setup_steps_with_flow( + tmp_path, + create_cli_project, + create_flow_json, + ["Synthesis", "CTS"], + extra_dirs=["Floorplan"], + ) + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + steps = [r.get("step") for r in records if "step" in r] + synth_idx = steps.index("synthesis") + cts_idx = steps.index("cts") + fp_idx = steps.index("floorplan") + assert synth_idx < cts_idx + assert cts_idx < fp_idx + + def test_extra_steps_sorted_alphabetically( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = self._setup_steps_with_flow( + tmp_path, + create_cli_project, + create_flow_json, + ["Synthesis"], + extra_dirs=["Floorplan", "CTS"], + ) + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + steps = [r.get("step") for r in records if "step" in r] + extras = [s for s in steps if s != "synthesis"] + assert extras == sorted(extras) + + def test_missing_flow_json_falls_back_to_alphabetical( + self, tmp_path, capsys, create_cli_project + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + for name in ["CTS_ecc", "Floorplan_ecc", "Synthesis_yosys"]: + step_dir = os.path.join(run_dir, name, "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "test.log"), "w") as f: + f.write("content\n") + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + steps = [r.get("step") for r in records if "step" in r] + assert steps == sorted(steps) + + def test_corrupt_flow_json_falls_back_to_alphabetical( + self, tmp_path, capsys, create_cli_project + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + f.write("not valid json{{{") + for name in ["CTS_ecc", "Floorplan_ecc", "Synthesis_yosys"]: + step_dir = os.path.join(run_dir, name, "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "test.log"), "w") as f: + f.write("content\n") + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + steps = [r.get("step") for r in records if "step" in r] + assert steps == sorted(steps) + + +class TestLogListingTailPreview: + """Tail preview shows up to 10 lines in default pretty text mode.""" + + def test_listing_shows_tail_lines(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + lines = [f"log line {i}" for i in range(15)] + with open(log_path, "w") as f: + f.write("\n".join(lines) + "\n") + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "log line 14" in out + assert "tail:" in out + + def test_listing_tail_max_10_lines(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + lines = [f"line {i}" for i in range(20)] + with open(log_path, "w") as f: + f.write("\n".join(lines) + "\n") + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + output_lines = out.split("\n") + tail_header_idx = next( + index for index, line in enumerate(output_lines) if line.strip() == "tail:" + ) + tail_content = [ + line + for line in output_lines[tail_header_idx + 1 :] + if line.startswith(" ") and "inspect:" not in line + ] + assert len(tail_content) == 10 + + def test_empty_log_no_tail_block(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + with open(log_path, "w") as f: + f.write("") + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "tail:" not in out + assert "inspect:" in out + + def test_inspect_visible_below_tail(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + with open(log_path, "w") as f: + f.write("content line\n") + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + tail_pos = out.find("tail:") + inspect_pos = out.find("inspect:") + assert tail_pos < inspect_pos + + +class TestLogListingMachineModeNoTail: + """Machine modes must not include tail data.""" + + def test_plain_no_tail(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("line 1\nline 2\nline 3\n") + rc = cli_main.run(["log", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "tail=" not in out + + def test_json_no_tail(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("line 1\nline 2\n") + rc = cli_main.run(["log", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + for rec in data["records"]: + assert "tail" not in rec + + def test_jsonl_no_tail(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("line 1\nline 2\n") + rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + for rec in records: + assert "tail" not in rec + + +class TestLogStepUnchanged: + """ecc log full output must remain unchanged.""" + + def test_step_shows_all_lines_not_tail(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + lines = [f"line {i}" for i in range(20)] + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("\n".join(lines) + "\n") + rc = cli_main.run(["log", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "line 0" in out + assert "line 19" in out + + def test_step_plain_unchanged(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("a\nb\nc\n") + rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "line_no=1" in out + assert "line_no=2" in out + assert "line_no=3" in out + assert "tail" not in out + + def test_step_jsonl_unchanged(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + with open(os.path.join(step_dir, "synthesis.log"), "w") as f: + f.write("a\nb\n") + rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + records = [ + json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() + ] + assert len(records) == 2 + for rec in records: + assert "tail" not in rec + + +class TestLogListingUnreadable: + """Unreadable logs in listing mode must omit tail, keep path+inspect, no traceback.""" + + def test_unreadable_step_log_in_listing(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") + os.makedirs(step_dir, exist_ok=True) + log_path = os.path.join(step_dir, "synthesis.log") + with open(log_path, "w") as f: + f.write("content\n") + os.chmod(log_path, 0o000) + + try: + rc = cli_main.run(["log", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "tail:" not in out + assert "Synthesis_yosys" in out + assert "inspect:" in out + assert "Traceback" not in out + finally: + os.chmod(log_path, 0o644) diff --git a/test/cli/commands/test_metrics.py b/test/cli/commands/test_metrics.py new file mode 100644 index 00000000..f39100cd --- /dev/null +++ b/test/cli/commands/test_metrics.py @@ -0,0 +1,147 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestMetrics: + def test_metrics_reads_step_metrics(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: + json.dump({"Cell number": 312, "Cell area": 1840.2}, f) + + rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "cell_number: 312" in out + + def test_metrics_all_steps(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + for step_dir_name in ["Synthesis_yosys", "Floorplan_ecc"]: + analysis = os.path.join(run_dir, step_dir_name, "analysis") + os.makedirs(analysis, exist_ok=True) + metrics_name = step_dir_name.split("_")[0] + "_metrics.json" + with open(os.path.join(analysis, metrics_name), "w") as f: + json.dump({"Cell number": 100}, f) + + rc = cli_main.run(["metrics", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "synthesis" in out + assert "floorplan" in out + + def test_metrics_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: + json.dump({"Cell number": 312}, f) + + rc = cli_main.run(["metrics", "synthesis", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert len(data["records"]) == 1 + assert data["records"][0]["metric"] == "cell_number" + + def test_metrics_jsonl(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: + json.dump({"Cell number": 312, "Cell area": 1840.2}, f) + + rc = cli_main.run(["metrics", "synthesis", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert len(objects) == 2 + + def test_metrics_normalizes_known_keys(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + + analysis_dir = os.path.join(run_dir, "CTS_ecc", "analysis") + os.makedirs(analysis_dir, exist_ok=True) + with open(os.path.join(analysis_dir, "CTS_metrics.json"), "w") as f: + json.dump({"Frequency [MHz]": 450.0, "Die area [μm^2]": "10000.000"}, f) + + rc = cli_main.run(["metrics", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "frequency_mhz: 450.0" in out + assert "die_area_um2" in out + + def test_metrics_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) + + rc = cli_main.run(["metrics", "nonexistent", "--project", project_dir]) + assert rc == 1 + + def test_metrics_missing_file(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "CTS_ecc", "analysis"), exist_ok=True) + + rc = cli_main.run(["metrics", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing" in out + assert "ecc log cts" in out + + def test_metrics_json_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) + + rc = cli_main.run(["metrics", "nonexistent", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["status"] == "unknown_step" + assert data["records"][0]["step"] == "nonexistent" + + def test_metrics_json_missing_file(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "CTS_ecc", "analysis"), exist_ok=True) + + rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["status"] == "missing" + + def test_metrics_jsonl_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) + + rc = cli_main.run(["metrics", "nonexistent", "--jsonl", "--project", project_dir]) + assert rc == 1 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert objects[0]["status"] == "unknown_step" + + +class TestFlowOnlyStepMetrics: + """Step in flow.json but no step directory should report missing, not unknown.""" + + def test_metrics_flow_only_step_is_missing(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": [{"name": "CTS", "state": "unstart"}]}, f) + + rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("status") == "missing" + assert data["records"][0].get("status") != "unknown_step" diff --git a/test/cli/commands/test_metrics_errors.py b/test/cli/commands/test_metrics_errors.py new file mode 100644 index 00000000..cf36acaf --- /dev/null +++ b/test/cli/commands/test_metrics_errors.py @@ -0,0 +1,52 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestCorruptMetricsJson: + def test_malformed_metrics_reports_corrupt_text( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["analysis"], + files={"analysis/CTS_metrics.json": "NOT JSON{{{"}, + ) + rc = cli_main.run(["metrics", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "corrupt" in out + + def test_malformed_metrics_reports_corrupt_json( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["analysis"], + files={"analysis/CTS_metrics.json": "NOT JSON{{{"}, + ) + rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["status"] == "corrupt" diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py new file mode 100644 index 00000000..c7869d28 --- /dev/null +++ b/test/cli/commands/test_run.py @@ -0,0 +1,191 @@ +import json +import os +from types import SimpleNamespace + +from chipcompiler.cli import main as cli_main + + +class DummyFlow: + has_init_value = False + run_steps_value = True + instances = [] + + def __init__(self, workspace): + self.workspace = workspace + self.added_steps = [] + self.create_called = False + self.run_called = False + self.workspace_steps = [] + DummyFlow.instances.append(self) + + def has_init(self): + return self.has_init_value + + def add_step(self, step, tool, state): + self.added_steps.append((step, tool, state)) + + def create_step_workspaces(self): + self.create_called = True + + def run_steps(self): + self.run_called = True + return self.run_steps_value + + def run_step(self, workspace_step): + from chipcompiler.data import StateEnum + + self.run_called = True + return StateEnum.Success if self.run_steps_value else StateEnum.Imcomplete + + +def _install_flow_mocks(monkeypatch): + capture = {"create_kwargs": None} + workspace_obj = SimpleNamespace(name="workspace") + + DummyFlow.instances = [] + DummyFlow.has_init_value = False + DummyFlow.run_steps_value = True + + def fake_create_workspace(**kwargs): + capture["create_kwargs"] = kwargs + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + + return capture + + +class TestRun: + def test_run_calls_create_workspace(self, tmp_path, monkeypatch, create_cli_project): + project_dir = create_cli_project() + capture = _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 0 + assert capture["create_kwargs"]["directory"] == os.path.join(project_dir, "runs", "default") + + def test_run_adds_flow_steps_when_no_init(self, tmp_path, monkeypatch, create_cli_project): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 0 + assert len(DummyFlow.instances[0].added_steps) > 0 + + def test_run_calls_create_and_run(self, tmp_path, monkeypatch, create_cli_project): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 0 + assert DummyFlow.instances[0].create_called + assert DummyFlow.instances[0].run_called + + def test_run_overwrite_removes_existing( + self, tmp_path, monkeypatch, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir, "--overwrite"]) + assert rc == 0 + + def test_run_fails_if_flow_json_exists(self, tmp_path, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 1 + + def test_run_fails_on_config_error(self, tmp_path): + project_dir = tmp_path / "bad" + project_dir.mkdir() + (project_dir / "ecc.toml").write_text("[design]\n") + rc = cli_main.run(["run", "--project", str(project_dir)]) + assert rc == 1 + + def test_run_fails_when_create_workspace_returns_none( + self, tmp_path, monkeypatch, create_cli_project + ): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + def fake_create(**kwargs): + return None + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 1 + + def test_run_fails_when_run_steps_false(self, tmp_path, monkeypatch, create_cli_project): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + DummyFlow.run_steps_value = False + + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 1 + + def test_run_json_uses_non_progress_path( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir, "--json"]) + assert rc == 0 + out = capsys.readouterr().out + data = json.loads(out) + assert "records" in data + assert data["records"][0]["status"] == "success" + assert DummyFlow.instances[0].run_called + + def test_run_jsonl_uses_non_progress_path( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir, "--jsonl"]) + assert rc == 0 + out = capsys.readouterr().out + objects = [json.loads(ln) for ln in out.strip().split("\n")] + assert any("status" in obj for obj in objects) + assert DummyFlow.instances[0].run_called + + def test_run_json_no_progress_on_stderr( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir, "--json"]) + assert rc == 0 + err = capsys.readouterr().err + assert "step=" not in err + + def test_run_preserves_final_records(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() + _install_flow_mocks(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir, "--json"]) + assert rc == 0 + out = capsys.readouterr().out + data = json.loads(out) + record = data["records"][0] + assert record["run"] == "default" + assert record["status"] == "success" + assert "inspect_cmd" in record + assert "metrics_cmd" in record + assert "log_cmd" in record diff --git a/test/cli/commands/test_status.py b/test/cli/commands/test_status.py new file mode 100644 index 00000000..97c95cf5 --- /dev/null +++ b/test/cli/commands/test_status.py @@ -0,0 +1,114 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestStatus: + def test_status_reads_flow_json(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "[status]" in out + assert "synthesis" in out + assert "floorplan" in out + + def test_status_json(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + records = data["records"] + assert records[0]["run"] == "default" + assert records[0]["status"] == "success" + step_records = [r for r in records if "step" in r] + assert len(step_records) == 2 + + def test_status_jsonl(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir, profile="main") + + rc = cli_main.run(["status", "--project", project_dir, "--jsonl"]) + assert rc == 0 + lines = capsys.readouterr().out.strip().split("\n") + objects = [json.loads(ln) for ln in lines] + assert "run" in objects[0] + assert "step" in objects[1] + + def test_status_normalizes_step_names( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:18"}, + {"name": "place", "tool": "dreamplace", "state": "Success", "runtime": "0:01:12"}, + ], + ) + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "synthesis" in out + assert "placement" in out + + def test_status_missing_run(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing" in out + assert "ecc run" in out + + def test_status_invalid_flow_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + f.write("not valid json{{{") + + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 1 + + +class TestCorruptFlowJson: + """Non-dict flow.json must be reported as corrupt, not missing.""" + + def test_array_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump([], f) + + rc = cli_main.run(["status", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("status") == "corrupt" + + def test_string_flow_json_is_corrupt(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump("bad", f) + + rc = cli_main.run(["status", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("status") == "corrupt" diff --git a/test/cli/conftest.py b/test/cli/conftest.py new file mode 100644 index 00000000..77142e27 --- /dev/null +++ b/test/cli/conftest.py @@ -0,0 +1,178 @@ +import json +import os +import re + +import pytest + + +def create_cli_project(tmp_path, name="gcd", pdk_root=None, freq=100.0): + project_dir = tmp_path / name + project_dir.mkdir(exist_ok=True) + (project_dir / "rtl").mkdir(exist_ok=True) + (project_dir / "constraints").mkdir(exist_ok=True) + (project_dir / "runs").mkdir(exist_ok=True) + + rtl_file = project_dir / "rtl" / "gcd.v" + rtl_file.write_text("module gcd(input clk); endmodule\n") + + if pdk_root is None: + pdk_root = tmp_path / "ics55" + pdk_root.mkdir(exist_ok=True) + + toml = f'''[design] +name = "{name}" +top = "{name}" +rtl = ["rtl/gcd.v"] +clock_port = "clk" +frequency_mhz = {freq} + +[pdk] +name = "ics55" +root = "{pdk_root}" + +[flow] +preset = "rtl2gds" +run = "default" +''' + (project_dir / "ecc.toml").write_text(toml) + return str(project_dir) + + +FLOW_JSON_DEFAULTS = { + "main": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:18"}, + {"name": "Floorplan", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + "inspect": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, + {"name": "Floorplan", "tool": "ecc", "state": "Success", "runtime": "0:00:03"}, + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + "pretty": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, + ], +} + + +def create_flow_json(run_dir, steps=None, profile="inspect"): + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + if steps is None: + steps = FLOW_JSON_DEFAULTS[profile] + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": steps}, f) + + +def create_step_dir(run_dir, step_name, tool, subdirs=None, files=None): + step_dir = os.path.join(run_dir, f"{step_name}_{tool}") + os.makedirs(step_dir, exist_ok=True) + if subdirs: + for subdir in subdirs: + os.makedirs(os.path.join(step_dir, subdir), exist_ok=True) + if files: + for relpath, content in files.items(): + file_path = os.path.join(step_dir, relpath) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w") as f: + f.write(content) + return step_dir + + +def create_workspace_config(run_dir, files): + config_dir = os.path.join(run_dir, "config") + os.makedirs(config_dir, exist_ok=True) + for name, content in files.items(): + with open(os.path.join(config_dir, name), "w") as f: + f.write(content) + + +def create_cts_workspace_config(run_dir): + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "cts_default_config.json": "{}", + }, + ) + + +def create_dreamplace_workspace_config(run_dir): + create_workspace_config(run_dir, {"dreamplace.json": "{}"}) + + +def create_ecc_workspace_config(run_dir, step_config): + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + step_config: "{}", + }, + ) + + +def has_disclosure(line): + return bool( + re.search(r"ecc (?:check|run|status|log|metrics|artifacts|config|diagnose|param)\b", line) + or '"ecc ' in line + or "=ecc " in line + ) + + +def mock_pdk_validation(monkeypatch): + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + + +@pytest.fixture(name="create_cli_project") +def create_cli_project_fixture(tmp_path): + def factory(name="gcd", pdk_root=None, freq=100.0): + return create_cli_project(tmp_path, name=name, pdk_root=pdk_root, freq=freq) + + return factory + + +@pytest.fixture(name="create_flow_json") +def create_flow_json_fixture(): + return create_flow_json + + +@pytest.fixture(name="create_step_dir") +def create_step_dir_fixture(): + return create_step_dir + + +@pytest.fixture(name="create_workspace_config") +def create_workspace_config_fixture(): + return create_workspace_config + + +@pytest.fixture(name="create_cts_workspace_config") +def create_cts_workspace_config_fixture(): + return create_cts_workspace_config + + +@pytest.fixture(name="create_dreamplace_workspace_config") +def create_dreamplace_workspace_config_fixture(): + return create_dreamplace_workspace_config + + +@pytest.fixture(name="create_ecc_workspace_config") +def create_ecc_workspace_config_fixture(): + return create_ecc_workspace_config + + +@pytest.fixture(name="has_disclosure") +def has_disclosure_fixture(): + return has_disclosure + + +@pytest.fixture(name="mock_pdk_validation") +def mock_pdk_validation_fixture(monkeypatch): + def factory(): + mock_pdk_validation(monkeypatch) + + return factory diff --git a/test/cli/inspect/test_artifacts.py b/test/cli/inspect/test_artifacts.py new file mode 100644 index 00000000..04f4d142 --- /dev/null +++ b/test/cli/inspect/test_artifacts.py @@ -0,0 +1,217 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestArtifacts: + def test_artifacts_all_steps( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["output", "log"], + files={"output/design.def": "def content", "log/cts.log": "log content"}, + ) + + rc = cli_main.run(["artifacts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "cts" in out + assert "(output)" in out + assert "(log)" in out + + def test_artifacts_single_step( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "cts" in out + assert "(output)" in out + + def test_artifacts_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["artifacts", "nonexistent", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "unknown_step" in out + + def test_artifacts_empty_known_step( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "No artifacts found" in out + + def test_artifacts_json( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run(["artifacts", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert len(data["records"]) > 0 + assert data["records"][0]["artifact"] == "design.def" + + def test_artifacts_jsonl( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["output", "log"], + files={"output/design.def": "def content", "log/cts.log": "log content"}, + ) + + rc = cli_main.run(["artifacts", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert len(objects) == 2 + assert all("artifact" in o for o in objects) + + def test_artifacts_with_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run( + ["artifacts", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "cts" in out + + def test_artifacts_derives_roles_from_dirs( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["config", "output", "report", "log", "analysis"], + files={ + "config/cts_config.json": "{}", + "output/design.def": "def", + "report/timing.rpt": "rpt", + "log/cts.log": "log", + "analysis/CTS_metrics.json": "{}", + }, + ) + + rc = cli_main.run(["artifacts", "cts", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + roles = {a["role"] for a in data["records"]} + assert roles == {"config", "output", "report", "log", "analysis"} + + +class TestArtifactPaths: + def test_nested_run_artifact_paths( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run( + [ + "artifacts", + "--run-id", + "sweeps/sweep_001/run_004", + "--json", + "--project", + project_dir, + ] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert len(data["records"]) == 1 + path = data["records"][0]["path"] + assert path.startswith("sweeps/") + + def test_nested_run_step_config_paths( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "cts_default_config.json": "{}", + }, + ) + + rc = cli_main.run( + [ + "config", + "cts", + "--resolved", + "--run-id", + "sweeps/sweep_001/run_004", + "--json", + "--project", + project_dir, + ] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert [item["path"] for item in data["records"]] == [ + "sweeps/sweep_001/run_004/config/flow_config.json", + "sweeps/sweep_001/run_004/config/db_default_config.json", + "sweeps/sweep_001/run_004/config/cts_default_config.json", + ] diff --git a/test/cli/inspect/test_config.py b/test/cli/inspect/test_config.py new file mode 100644 index 00000000..74701f35 --- /dev/null +++ b/test/cli/inspect/test_config.py @@ -0,0 +1,712 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestConfigResolved: + def test_config_resolved_project( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "design.name" in out + assert "project:" in out + assert "pdk.name" in out + assert "run_dir" in out + + def test_config_resolved_json( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + keys = [item["config"] for item in data["records"]] + assert "design.name" in keys + assert "pdk.name" in keys + assert "run_dir" in keys + + def test_config_resolved_default_run_dir_value( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + run_item = next(i for i in data["records"] if i["config"] == "run_dir") + assert run_item["value"] == "runs/default" + + def test_config_resolved_jsonl( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--jsonl", "--project", project_dir]) + assert rc == 0 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + keys = [o["config"] for o in objects] + assert "design.name" in keys + + def test_config_resolved_pdk_root_from_env( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + pdk_root = tmp_path / "ics55_env" + pdk_root.mkdir() + monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(pdk_root)) + + project_dir = create_cli_project(pdk_root="") + + rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + pdk_item = next(i for i in data["records"] if i["config"] == "pdk.root") + assert pdk_item["source"] == "env" + + def test_config_resolved_run_id( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run( + [ + "config", + "--resolved", + "--run-id", + "sweeps/sweep_001/run_004", + "--json", + "--project", + project_dir, + ] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + run_item = next(i for i in data["records"] if i["config"] == "run_dir") + assert run_item["value"] == "sweeps/sweep_001/run_004" + + def test_config_missing_config(self, tmp_path, capsys): + project_dir = tmp_path / "empty_project" + project_dir.mkdir() + + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + + def test_config_missing_config_json_has_kind_error(self, tmp_path, capsys): + project_dir = tmp_path / "empty_project" + project_dir.mkdir() + + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir), "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert record["kind"] == "error" + assert record["error"] == "missing_config" + + def test_config_missing_config_jsonl_has_kind_error(self, tmp_path, capsys): + project_dir = tmp_path / "empty_project" + project_dir.mkdir() + + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir), "--jsonl"]) + assert rc == 1 + record = json.loads(capsys.readouterr().out.strip()) + assert record["kind"] == "error" + assert record["error"] == "missing_config" + + def test_config_missing_config_text_has_kind_error(self, tmp_path, capsys): + project_dir = tmp_path / "empty_project" + project_dir.mkdir() + + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + out = capsys.readouterr().out + assert "[error]" in out + assert "missing_config" in out + assert "ecc check" in out + assert str(project_dir) in out + + def test_config_requires_resolved(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--project", project_dir]) + assert rc != 0 + assert "--resolved" in capsys.readouterr().err + + +class TestConfigStepResolved: + def test_config_step_lists_files( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "cts_default_config.json": "{}", + }, + ) + + rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "step:" in out or "cts" in out + assert "step:" in out or "step:" in out + assert "runs/default/config/flow_config.json" in out + assert "runs/default/config/db_default_config.json" in out + assert "cts_default_config.json" in out + + def test_config_step_json( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "cts_default_config.json": "{}", + }, + ) + + rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + records = data["records"] + assert all(item["scope"] == "step" for item in records) + assert all(item["step"] == "cts" for item in records) + assert all(item["source"] == "workspace_config" for item in records) + assert [item["path"] for item in records] == [ + "runs/default/config/flow_config.json", + "runs/default/config/db_default_config.json", + "runs/default/config/cts_default_config.json", + ] + + def test_config_step_workspace_records_inspect_with_config_command( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert all( + item["inspect"] == f"ecc config cts --resolved --json --project {project_dir}" + for item in data["records"] + ) + + def test_config_step_unknown_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["config", "nonexistent", "--resolved", "--project", project_dir]) + assert rc == 1 + + def test_config_step_no_config_files( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + + rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) + assert rc == 0 + + def test_config_dreamplace_legalization_uses_dreamplace_config( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + create_step_dir, + create_dreamplace_workspace_config, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "legalization", + "tool": "dreamplace", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir(run_dir, "legalization", "dreamplace", subdirs=["output"]) + create_dreamplace_workspace_config(run_dir) + + rc = cli_main.run( + ["config", "legalization", "--resolved", "--json", "--project", project_dir] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert [item["path"] for item in data["records"]] == [ + "runs/default/config/dreamplace.json", + ] + assert data["records"][0]["source"] == "workspace_config" + + def test_config_workspace_backed_ecc_steps( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_ecc_workspace_config, + ): + cases = [ + ("PNP", "pnp", "pnp_default_config.json"), + ("optDrv", "optdrv", "to_default_config_drv.json"), + ("optHold", "opthold", "to_default_config_hold.json"), + ("optSetup", "optsetup", "to_default_config_setup.json"), + ] + for step_name, step_token, step_config in cases: + project_dir = create_cli_project(name=f"gcd_{step_token}") + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": step_name, + "tool": "ecc", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir(run_dir, step_name, "ecc", subdirs=["output"]) + create_ecc_workspace_config(run_dir, step_config) + + rc = cli_main.run( + ["config", step_token, "--resolved", "--json", "--project", project_dir] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert [item["path"] for item in data["records"]] == [ + "runs/default/config/flow_config.json", + "runs/default/config/db_default_config.json", + f"runs/default/config/{step_config}", + ] + assert all(item["source"] == "workspace_config" for item in data["records"]) + + def test_config_sta_uses_rcx_and_sta_workspace_configs( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "STA", + "tool": "ecc", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir(run_dir, "STA", "ecc", subdirs=["output"]) + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "rcx.json": "{}", + "sta.json": "{}", + }, + ) + + rc = cli_main.run(["config", "sta", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert [item["path"] for item in data["records"]] == [ + "runs/default/config/flow_config.json", + "runs/default/config/db_default_config.json", + "runs/default/config/rcx.json", + "runs/default/config/sta.json", + ] + assert all(item["source"] == "workspace_config" for item in data["records"]) + + def test_config_yosys_synthesis_does_not_report_ieda_flow_config( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "Synthesis", + "tool": "yosys", + "state": "Success", + "runtime": "0:00:05", + }, + ], + ) + create_step_dir(run_dir, "Synthesis", "yosys", subdirs=["output"]) + create_workspace_config(run_dir, {"flow_config.json": "{}"}) + + rc = cli_main.run(["config", "synthesis", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert len(data["records"]) == 1 + assert data["records"][0]["step"] == "synthesis" + assert data["records"][0]["config_status"] == "none" + assert "path" not in data["records"][0] + + +class TestEmptyStepConfigSentinel: + def test_step_no_config_emits_sentinel_text( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + + rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "cts" in out + assert "No configuration" in out + assert "artifacts:" in out + + def test_step_no_config_emits_sentinel_json( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + + rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["step"] == "cts" + assert data["records"][0]["config_status"] == "none" + + +class TestDirectoryOnlyStepConfig: + def test_dir_only_step_config_infers_tool_from_step_dir( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, + ], + ) + create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert [item["path"] for item in data["records"]] == [ + "runs/default/config/flow_config.json", + "runs/default/config/db_default_config.json", + "runs/default/config/cts_default_config.json", + ] + + def test_dir_only_step_diagnose_uses_inferred_tool_for_config( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" not in out + assert "clean" in out + + +class TestConfigRoleDisclosure: + def test_config_artifact_has_disclosure( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + has_disclosure, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["config"], files={"config/cts_config.json": "{}"} + ) + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + +class TestAbsoluteRunIdConfig: + def test_absolute_run_id_preserves_run_dir_value( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + external_run = tmp_path / "external_run" + create_flow_json(str(external_run)) + + rc = cli_main.run( + [ + "config", + "--resolved", + "--run-id", + str(external_run), + "--json", + "--project", + project_dir, + ] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + run_item = next(i for i in data["records"] if i["config"] == "run_dir") + assert run_item["value"] == str(external_run) + + +class TestConfigTextUsesItemInspectCmd: + def test_run_dir_text_uses_status_command( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "run_dir" in out + assert "ecc status" in out + + +class TestConfigJsonDisclosure: + def test_project_config_json_has_inspect_cmd( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + for item in data["records"]: + assert "inspect" in item, f"Missing inspect in item: {item['config']}" + + +class TestIsolatedConfigValidation: + @staticmethod + def _valid_toml(tmp_path, **overrides): + pdk_dir = tmp_path / "pdk" + pdk_dir.mkdir(exist_ok=True) + rtl_dir = tmp_path / "rtl" + rtl_dir.mkdir(exist_ok=True) + (rtl_dir / "gcd.v").write_text("module gcd; endmodule") + defaults = { + "name": "gcd", + "top": "gcd", + "rtl": '["rtl/gcd.v"]', + "clock_port": "clk", + "frequency_mhz": "100.0", + "pdk_name": "ics55", + "pdk_root": str(pdk_dir), + "flow_preset": "rtl2gds", + "flow_run": "default", + } + defaults.update(overrides) + return f'''[design] +name = "{defaults["name"]}" +top = "{defaults["top"]}" +rtl = {defaults["rtl"]} +clock_port = "{defaults["clock_port"]}" +frequency_mhz = {defaults["frequency_mhz"]} + +[pdk] +name = "{defaults["pdk_name"]}" +root = "{defaults["pdk_root"]}" + +[flow] +preset = "{defaults["flow_preset"]}" +run = "{defaults["flow_run"]}" +''' + + def test_unsupported_flow_run_rejected( + self, tmp_path, capsys, monkeypatch, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = tmp_path / "bad_run" + project_dir.mkdir() + toml = self._valid_toml(tmp_path, flow_run="custom") + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + + def test_empty_clock_port_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_validation): + mock_pdk_validation() + project_dir = tmp_path / "bad_clock" + project_dir.mkdir() + toml = self._valid_toml(tmp_path, clock_port="") + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + + def test_zero_frequency_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_validation): + mock_pdk_validation() + project_dir = tmp_path / "bad_freq" + project_dir.mkdir() + toml = self._valid_toml(tmp_path, frequency_mhz="0") + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + + def test_empty_rtl_rejected(self, tmp_path, capsys, monkeypatch, mock_pdk_validation): + mock_pdk_validation() + project_dir = tmp_path / "bad_rtl" + project_dir.mkdir() + toml = self._valid_toml(tmp_path, rtl="[]") + (project_dir / "ecc.toml").write_text(toml) + rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) + assert rc == 1 + + +class TestRtlPathResolution: + def test_absolute_rtl_resolved_correctly( + self, tmp_path, capsys, monkeypatch, mock_pdk_validation + ): + mock_pdk_validation() + project_dir = tmp_path / "proj" + project_dir.mkdir() + rtl_dir = tmp_path / "external_rtl" + rtl_dir.mkdir() + (rtl_dir / "gcd.v").write_text("module gcd; endmodule") + (project_dir / "ecc.toml").write_text(f'''[design] +name = "gcd" +top = "gcd" +rtl = ["{rtl_dir / "gcd.v"}"] +clock_port = "clk" +frequency_mhz = 100.0 + +[pdk] +name = "ics55" +root = "{tmp_path / "pdk"}" + +[flow] +preset = "rtl2gds" +run = "default" +''') + (tmp_path / "pdk").mkdir(exist_ok=True) + rc = cli_main.run(["config", "--resolved", "--json", "--project", str(project_dir)]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + rtl_item = next(i for i in data["records"] if i["config"] == "design.rtl.0") + assert rtl_item["resolved"] == str(rtl_dir / "gcd.v") diff --git a/test/cli/inspect/test_diagnose.py b/test/cli/inspect/test_diagnose.py new file mode 100644 index 00000000..d7fdc99a --- /dev/null +++ b/test/cli/inspect/test_diagnose.py @@ -0,0 +1,1156 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestDiagnose: + def test_diagnose_missing_run(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing_run" in out + assert "error:" in out + + def test_diagnose_invalid_flow_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + f.write("NOT VALID JSON{{{") + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "invalid_flow_json" in out + + def test_diagnose_failed_step( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "analysis"], + files={"log/cts.log": "Error: failed\n", "analysis/CTS_metrics.json": "{}"}, + ) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "failed_step" in out + assert "error:" in out + + def test_diagnose_ongoing_step_warning( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Ongoing", "runtime": ""}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "running\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "ongoing_step" in out + assert "warning:" in out + + def test_diagnose_unstarted_step_info( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Unstart", "runtime": ""}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "unstarted_step" in out + assert "info:" in out + + def test_diagnose_log_errors_count( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "Error: bad thing\nError: other bad\nok line\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "log_errors" in out + assert "count: 2" in out + + def test_diagnose_missing_metrics_warning( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "missing_metrics" in out + assert "warning:" in out + + def test_diagnose_missing_artifacts_warning( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "analysis"], + files={ + "log/cts.log": "ok\n", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + # Remove investigation role dirs to trigger missing_artifacts + import shutil + + shutil.rmtree(os.path.join(run_dir, "CTS_ecc", "analysis")) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "missing_artifacts" in out + assert "warning:" in out + + def test_diagnose_config_unavailable_info( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" in out + assert "info:" in out + + def test_diagnose_clean_run( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": json.dumps({"Frequency [MHz]": 450.0}), + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "clean" in out + + def test_diagnose_uses_workspace_config_without_step_config( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" not in out + assert "clean" in out + + def test_diagnose_dreamplace_legalization_uses_dreamplace_config( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_dreamplace_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "legalization", + "tool": "dreamplace", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir( + run_dir, + "legalization", + "dreamplace", + subdirs=["log", "output", "analysis"], + files={ + "log/legalization.log": "ok\n", + "output/design.def": "def", + "analysis/legalization_metrics.json": "{}", + }, + ) + create_dreamplace_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "legalization", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" not in out + assert "clean" in out + + def test_diagnose_workspace_backed_ecc_steps( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_ecc_workspace_config, + ): + cases = [ + ("PNP", "pnp", "pnp_default_config.json"), + ("optDrv", "optdrv", "to_default_config_drv.json"), + ("optHold", "opthold", "to_default_config_hold.json"), + ("optSetup", "optsetup", "to_default_config_setup.json"), + ] + for step_name, step_token, step_config in cases: + project_dir = create_cli_project(name=f"gcd_{step_token}") + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": step_name, + "tool": "ecc", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir( + run_dir, + step_name, + "ecc", + subdirs=["log", "output", "analysis"], + files={ + f"log/{step_name}.log": "ok\n", + "output/design.def": "def", + f"analysis/{step_name}_metrics.json": "{}", + }, + ) + create_ecc_workspace_config(run_dir, step_config) + + rc = cli_main.run(["diagnose", step_token, "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" not in out + assert "clean" in out + + def test_diagnose_sta_uses_rcx_workspace_config( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "STA", + "tool": "ecc", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir( + run_dir, + "STA", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/STA.log": "ok\n", + "output/design.def": "def", + "analysis/STA_metrics.json": "{}", + }, + ) + create_workspace_config( + run_dir, + { + "flow_config.json": "{}", + "db_default_config.json": "{}", + "rcx.json": "{}", + "sta.json": "{}", + }, + ) + + rc = cli_main.run(["diagnose", "sta", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" not in out + assert "clean" in out + + def test_diagnose_yosys_synthesis_reports_config_unavailable( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "Synthesis", + "tool": "yosys", + "state": "Success", + "runtime": "0:00:05", + }, + ], + ) + create_step_dir( + run_dir, + "Synthesis", + "yosys", + subdirs=["log", "output", "analysis"], + files={ + "log/Synthesis.log": "ok\n", + "output/design.v": "verilog", + "analysis/Synthesis_metrics.json": "{}", + }, + ) + create_workspace_config(run_dir, {"flow_config.json": "{}"}) + + rc = cli_main.run(["diagnose", "synthesis", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" in out + assert "info:" in out + + def test_diagnose_step_filter( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "Synthesis", + "yosys", + subdirs=["output", "log", "analysis", "config"], + files={ + "output/synth.v": "verilog", + "log/synthesis.log": "ok\n", + "analysis/Synthesis_metrics.json": "{}", + "config/config.json": "{}", + }, + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} + ) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "failed_step" in out + assert "cts" in out + assert "synthesis" not in out + + def test_diagnose_unknown_step(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + + rc = cli_main.run(["diagnose", "nonexistent", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "unknown_step" in out + + def test_diagnose_no_repair_suggestions( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} + ) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "suggest" not in out.lower() + assert "fix" not in out.lower() + assert "recommend" not in out.lower() + + def test_diagnose_json( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} + ) + + rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert any(i["issue"] == "failed_step" for i in data["records"]) + + def test_diagnose_jsonl( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} + ) + + rc = cli_main.run(["diagnose", "--jsonl", "--project", project_dir]) + assert rc == 1 + objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] + assert any(o["issue"] == "failed_step" for o in objects) + + def test_diagnose_with_run_id( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "run_007") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--run-id", "run_007", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "clean" in out + + +class TestDiagnoseExitCodes: + def test_error_issue_returns_nonzero( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} + ) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + + def test_warning_only_returns_zero( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Ongoing", "runtime": ""}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "running\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + + def test_clean_run_returns_zero( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + + def test_failed_step_not_zero( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir(run_dir, "CTS", "ecc") + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc != 0 + + +class TestDiagnoseFlowOnlySteps: + def test_flow_step_without_directory_emits_issues( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "failed_step" in out + assert "cts" in out + assert "unknown_step" not in out + + def test_flow_step_without_dir_reports_missing_artifacts( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "missing_artifacts" in out + assert "missing_metrics" in out + assert "config_unavailable" in out + + +class TestDiagnoseIssueSpecificEvidence: + def test_log_errors_uses_log_command( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "Error: bad thing\nError: other\nok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "log_errors" in out + assert "ecc log cts" in out + + def test_missing_metrics_uses_metrics_command( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "missing_metrics" in out + assert "ecc metrics cts" in out + + def test_missing_artifacts_uses_artifacts_command( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log"], + files={"log/cts.log": "ok\n"}, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "missing_artifacts" in out + assert "ecc artifacts cts" in out + + def test_config_unavailable_uses_config_command( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "config_unavailable" in out + assert "ecc config cts --resolved" in out + + def test_invalid_flow_json_has_evidence(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: + f.write("NOT VALID JSON{{{") + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "invalid_flow_json" in out + assert "evidence:" in out + assert "ecc status" in out + + def test_invalid_flow_json_json_has_evidence(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: + f.write("NOT VALID JSON{{{") + + rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + data = json.loads(out) + issue = data["records"][0] + assert issue["issue"] == "invalid_flow_json" + assert "evidence" in issue + assert "start_cmd" in issue + + +class TestCleanDiagnoseOutput: + def test_clean_has_status_and_disclosure_commands( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "clean" in out + assert "inspect:" in out + assert "artifacts:" in out + assert "config:" in out + + def test_clean_json_has_disclosure_metadata( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": "{}", + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["status"] == "clean" + assert "inspect_cmd" in data["records"][0] + assert "artifacts" in data["records"][0] + assert "config" in data["records"][0] + + +class TestPendingStepDiagnose: + def test_pending_step_creates_issue( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Pending", "runtime": ""}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": "ok\n", + "output/design.def": "def", + "analysis/CTS_metrics.json": '{"freq": 100}', + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "pending_step" in out + assert "pending" in out + + +class TestLogErrorMatching: + def test_clean_summary_not_counted_as_error( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": ( + "CTS completed successfully\n0 errors\nNo errors found\n0 failed checks\n" + ), + "output/design.def": "def", + "analysis/CTS_metrics.json": '{"freq": 100}', + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "log_errors" not in out + + def test_real_errors_still_detected( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_cts_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["log", "output", "analysis"], + files={ + "log/cts.log": ( + "CTS completed\nError: bad thing\nTraceback (most recent call):\n0 errors\n" + ), + "output/design.def": "def", + "analysis/CTS_metrics.json": '{"freq": 100}', + }, + ) + create_cts_workspace_config(run_dir) + + rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "log_errors" in out + assert "count: 2" in out diff --git a/test/cli/inspect/test_inspect_disclosure.py b/test/cli/inspect/test_inspect_disclosure.py new file mode 100644 index 00000000..ac3609a7 --- /dev/null +++ b/test/cli/inspect/test_inspect_disclosure.py @@ -0,0 +1,82 @@ +import os + +from chipcompiler.cli import main as cli_main + + +class TestDisclosure: + def test_artifacts_lines_have_disclosure( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + has_disclosure, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_config_resolved_lines_have_disclosure( + self, tmp_path, capsys, monkeypatch, create_cli_project, mock_pdk_validation, has_disclosure + ): + mock_pdk_validation() + project_dir = create_cli_project() + + rc = cli_main.run(["config", "--resolved", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_diagnose_lines_have_disclosure( + self, tmp_path, capsys, create_cli_project, has_disclosure + ): + project_dir = create_cli_project() + + rc = cli_main.run(["diagnose", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert has_disclosure(out) + + def test_phase2_disclosure_preserves_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "run_008") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: fail\n"} + ) + + rc = cli_main.run(["diagnose", "--run-id", "run_008", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "--run-id run_008" in out + + def test_artifacts_disclosure_preserves_project( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert f"--project {project_dir}" in out diff --git a/test/cli/inspect/test_read_only.py b/test/cli/inspect/test_read_only.py new file mode 100644 index 00000000..ac2e8b83 --- /dev/null +++ b/test/cli/inspect/test_read_only.py @@ -0,0 +1,51 @@ +import os + +from chipcompiler.cli import main as cli_main + + +class TestReadOnly: + def test_artifacts_does_not_modify_files( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "original"} + ) + + before_mtime = os.path.getmtime(os.path.join(run_dir, "CTS_ecc", "output", "design.def")) + + rc = cli_main.run(["artifacts", "--project", project_dir]) + assert rc == 0 + + after_mtime = os.path.getmtime(os.path.join(run_dir, "CTS_ecc", "output", "design.def")) + assert before_mtime == after_mtime + + def test_no_persistent_metadata_files( + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + create_flow_json, + create_step_dir, + mock_pdk_validation, + ): + mock_pdk_validation() + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + create_step_dir( + run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} + ) + + cli_main.run(["artifacts", "--project", project_dir]) + cli_main.run(["config", "--resolved", "--project", project_dir]) + cli_main.run(["diagnose", "--project", project_dir]) + + assert not os.path.exists(os.path.join(project_dir, "issues.json")) + assert not os.path.exists(os.path.join(project_dir, "artifacts.json")) + assert not os.path.exists(os.path.join(project_dir, "resolved_config.json")) + assert not os.path.exists(os.path.join(run_dir, "issues.json")) + assert not os.path.exists(os.path.join(run_dir, "artifacts.json")) diff --git a/test/cli/inspect/test_run_id.py b/test/cli/inspect/test_run_id.py new file mode 100644 index 00000000..95e14bb0 --- /dev/null +++ b/test/cli/inspect/test_run_id.py @@ -0,0 +1,183 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestRunIdResolution: + def test_status_default_run_id(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + + rc = cli_main.run(["status", "--run-id", "default", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "default" in out + + def test_status_simple_token_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "run_004") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + create_flow_json(run_dir) + + rc = cli_main.run(["status", "--run-id", "run_004", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "run_004" in out + + def test_status_relative_path_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") + create_flow_json(run_dir) + + rc = cli_main.run( + ["status", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "sweeps/sweep_001/run_004" in out + + def test_status_absolute_path_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = tmp_path / "ecc-run-004" + create_flow_json(str(run_dir)) + + rc = cli_main.run(["status", "--run-id", str(run_dir), "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "run:" in out + + def test_status_missing_run_id(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + + rc = cli_main.run(["status", "--run-id", "nonexistent", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing" in out + + def test_log_preserves_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "run_005") + create_flow_json(run_dir) + create_step_dir( + run_dir, + "Synthesis", + "yosys", + subdirs=["log"], + files={"log/synthesis.log": "Error: something failed\n"}, + ) + + rc = cli_main.run( + ["log", "synthesis", "--errors", "--run-id", "run_005", "--project", project_dir] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "--run-id run_005" in out + + def test_metrics_preserves_run_id( + self, tmp_path, capsys, create_cli_project, create_flow_json, create_step_dir + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "run_006") + create_flow_json( + run_dir, + [ + {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, + ], + ) + create_step_dir( + run_dir, + "CTS", + "ecc", + subdirs=["analysis"], + files={"analysis/CTS_metrics.json": json.dumps({"Frequency [MHz]": 450.0})}, + ) + + rc = cli_main.run(["metrics", "cts", "--run-id", "run_006", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--run-id run_006" in out + + +class TestRunIdDisclosure: + def test_explicit_default_preserved_in_disclosure( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json(run_dir) + + rc = cli_main.run(["status", "--run-id", "default", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "--run-id default" in out + + def test_project_relative_run_id_resolves( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") + create_flow_json(run_dir) + + rc = cli_main.run( + ["status", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "sweeps/sweep_001/run_004" in out + + +class TestCorruptFlowJson: + def test_corrupt_flow_json_status_reports_corrupt(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: + f.write("BROKEN{{{") + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "corrupt" in out + + def test_missing_flow_json_status_reports_missing(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + rc = cli_main.run(["status", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + assert "missing" in out + + def test_corrupt_flow_json_json_reports_corrupt(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: + f.write("BROKEN{{{") + rc = cli_main.run(["status", "--json", "--project", project_dir]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["status"] == "corrupt" + + +class TestMissingRunJsonlKind: + def test_missing_run_jsonl_has_kind(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(run_dir, exist_ok=True) + + rc = cli_main.run(["status", "--jsonl", "--project", project_dir]) + assert rc == 1 + out = capsys.readouterr().out + data = [json.loads(line) for line in out.strip().split("\n") if line.strip()] + assert data[0]["run"] == "default" + assert data[0]["status"] == "missing" diff --git a/test/cli/params/test_commands.py b/test/cli/params/test_commands.py new file mode 100644 index 00000000..dc78db28 --- /dev/null +++ b/test/cli/params/test_commands.py @@ -0,0 +1,603 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestParamList: + def test_param_list_text_output(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "place.target_density" in out + + def test_param_list_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + params = [r["param"] for r in data["records"]] + assert "place.target_density" in params + + def test_param_list_jsonl(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) + assert rc == 0 + lines = capsys.readouterr().out.strip().split("\n") + objects = [json.loads(ln) for ln in lines] + assert len(objects) == 12 + + def test_param_list_plain(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) + assert rc == 0 + out = capsys.readouterr().out + assert "\033[" not in out + assert "place.target_density" in out + + +class TestParamShow: + def test_param_show_known_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "place.target_density" in out + + def test_param_show_json(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert record["param"] == "place.target_density" + assert record["default"] == 0.2 + assert "source" in record + assert "maps_to" in record + + def test_param_show_unknown_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "show", "unknown.key", "--project", project_dir]) + assert rc == 1 + + +class TestParamSet: + def test_param_set_writes_toml(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + ["param", "set", "place.target_density", "0.65", "--project", project_dir] + ) + assert rc == 0 + + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + assert "target_density" in content + assert "0.65" in content + + def test_param_set_then_show(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() # flush set output + + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert record["value"] == 0.65 + assert record["source"] == "ecc.toml" + + def test_param_set_rejects_unknown_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "set", "bogus.key", "5", "--project", project_dir]) + assert rc == 1 + + def test_param_set_rejects_invalid_value(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "set", "place.target_density", "1.5", "--project", project_dir]) + assert rc == 1 + + def test_param_set_preserves_other_sections(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) + + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + assert "[design]" in content + assert "[pdk]" in content + assert "[flow]" in content + + +class TestParamUnset: + def test_param_unset_removes_override(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() # flush set output + + rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) + assert rc == 0 + capsys.readouterr() # flush unset output + + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert record["source"] == "default" + + def test_param_unset_noop_when_absent(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "no override" in out + + +class TestParamDiff: + def test_param_diff_shows_overrides(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() # flush set output + + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + records = data["records"] + assert len(records) == 1 + assert records[0]["param"] == "place.target_density" + + def test_param_diff_clean_when_no_overrides(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("diff_status") == "clean" + + +class TestRunSet: + def test_run_set_override(self, tmp_path, monkeypatch, capsys, create_cli_project): + from types import SimpleNamespace + + project_dir = create_cli_project() + workspace_obj = SimpleNamespace(name="workspace") + capture = {"kwargs": None} + + def fake_create(**kwargs): + capture["kwargs"] = kwargs + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + monkeypatch.setattr( + "chipcompiler.engine.EngineFlow", + type( + "DummyFlow", + (), + { + "__init__": lambda self, workspace: None, + "has_init": lambda self: False, + "add_step": lambda self, **kw: None, + "create_step_workspaces": lambda self: None, + "run_steps": lambda self: True, + }, + ), + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.setattr( + "chipcompiler.cli.rendering.progress.should_enable_run_progress", + lambda *a, **kw: False, + ) + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "place.target_density=0.65", + ] + ) + assert rc == 0 + + params = capture["kwargs"]["parameters"] + assert params.get("DreamPlace", {}).get("target_density") == 0.65 + + def test_run_set_rejects_unknown_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "bogus.key=5", + ] + ) + assert rc == 1 + + def test_run_set_rejects_invalid_value(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "place.target_density=1.5", + ] + ) + assert rc == 1 + + def test_run_set_does_not_modify_toml(self, tmp_path, monkeypatch, capsys, create_cli_project): + from types import SimpleNamespace + + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + original_toml = f.read() + + workspace_obj = SimpleNamespace(name="workspace") + + def fake_create(**kwargs): + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + monkeypatch.setattr( + "chipcompiler.engine.EngineFlow", + type( + "DummyFlow", + (), + { + "__init__": lambda self, workspace: None, + "has_init": lambda self: False, + "add_step": lambda self, **kw: None, + "create_step_workspaces": lambda self: None, + "run_steps": lambda self: True, + }, + ), + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.setattr( + "chipcompiler.cli.rendering.progress.should_enable_run_progress", + lambda *a, **kw: False, + ) + + cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "place.target_density=0.65", + ] + ) + + with open(toml_path) as f: + current_toml = f.read() + assert current_toml == original_toml + + +class TestOutputContracts: + def test_plain_no_ansi(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) + assert rc == 0 + out = capsys.readouterr().out + assert "\033[" not in out + + def test_json_no_ansi(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + out = capsys.readouterr().out + assert "\033[" not in out + + def test_jsonl_no_ansi(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) + assert rc == 0 + out = capsys.readouterr().out + assert "\033[" not in out + + def test_json_uses_records_envelope(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "records" in data + assert isinstance(data["records"], list) + + def test_plain_is_line_oriented(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) + assert rc == 0 + out = capsys.readouterr().out + lines = [line for line in out.strip().split("\n") if line.strip()] + assert len(lines) == 12 + + +class TestConfigResolved: + def test_config_resolved_includes_param_records( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": []}, f) + + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + records = data["records"] + param_records = [r for r in records if r.get("kind") == "param"] + assert len(param_records) == 12 + first_param = param_records[0] + assert "source" in first_param + assert "maps_to" in first_param + + def test_config_resolved_shows_toml_source( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project() + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() # flush set output + + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": []}, f) + + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + param_records = [r for r in data["records"] if r.get("kind") == "param"] + density = next(r for r in param_records if r["key"] == "place.target_density") + assert density["value"] == 0.65 + assert density["source"] == "ecc.toml" + + def test_config_resolved_seeds_design_frequency( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + project_dir = create_cli_project(freq=200.0) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + run_dir = os.path.join(project_dir, "runs", "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": []}, f) + + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + param_records = [r for r in data["records"] if r.get("kind") == "param"] + freq = next(r for r in param_records if r["key"] == "design.frequency_mhz") + assert freq["value"] == 200.0 + + +class TestPrettyOutput: + def test_param_list_default_is_grouped_text(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "place" in out + assert "place.target_density" in out + + def test_param_list_plain_is_one_line_per_record(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) + assert rc == 0 + out = capsys.readouterr().out + lines = [line for line in out.strip().split("\n") if line.strip()] + assert len(lines) == 12 + assert "\033[" not in out + + def test_param_show_default_is_pretty(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "place.target_density" in out + assert "source" in out + assert "default" in out + + def test_param_set_default_is_pretty(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + ["param", "set", "place.target_density", "0.65", "--project", project_dir] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "0.65" in out + + def test_param_diff_default_is_pretty(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() + rc = cli_main.run(["param", "diff", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "place.target_density" in out + + +class TestResolvedListValues: + def test_param_list_json_has_value_and_source(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + records = data["records"] + density = next(r for r in records if r["param"] == "place.target_density") + assert density["value"] == 0.65 + assert density["source"] == "ecc.toml" + assert "default" in density + assert "maps_to" in density + assert "inspect" in density + + def test_param_list_default_source_when_no_overrides( + self, tmp_path, capsys, create_cli_project + ): + project_dir = create_cli_project() + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + for r in data["records"]: + if r["param"] == "design.frequency_mhz": + assert r["source"] == "ecc.toml" + else: + assert r["source"] == "default" + + +class TestDiffFiltering: + def test_diff_only_shows_values_that_differ(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + records = data["records"] + assert len(records) == 1 + assert records[0]["param"] == "place.target_density" + assert records[0]["value"] == 0.65 + assert records[0]["default"] != 0.65 + + def test_diff_clean_when_set_to_default(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + schema_default = 0.2 + cli_main.run( + ["param", "set", "place.target_density", str(schema_default), "--project", project_dir] + ) + capsys.readouterr() + + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("diff_status") == "clean" + + +class TestParamShowDisclosureCommands: + """param show must include disclosure command fields.""" + + def test_show_json_has_disclosure_commands(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + record = data["records"][0] + assert "inspect" in record + assert "set" in record + assert "run" in record + assert "ecc param show place.target_density" in record["inspect"] + + def test_show_text_has_disclosure_commands(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) + assert rc == 0 + out = capsys.readouterr().out + assert "ecc param show place.target_density" in out + assert "ecc param set place.target_density" in out + assert "ecc run --set place.target_density" in out + + +class TestListDefaultDiffFiltering: + """param diff must not report list values equal to defaults.""" + + def test_list_default_not_in_diff(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "floorplan.core_margin", "[2,2]", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0].get("diff_status") == "clean" + + def test_list_changed_value_in_diff(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + cli_main.run(["param", "set", "floorplan.core_margin", "[4,4]", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert len(data["records"]) >= 1 + margin = next( + (r for r in data["records"] if r.get("param") == "floorplan.core_margin"), None + ) + assert margin is not None + assert margin["value"] == [4, 4] + + +class TestDesignFrequencySeeded: + """ecc param list/show must reflect [design] frequency_mhz.""" + + def test_list_shows_design_frequency(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project(freq=200.0) + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + freq = next(r for r in data["records"] if r["param"] == "design.frequency_mhz") + assert freq["value"] == 200.0 + + def test_show_shows_design_frequency(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project(freq=200.0) + rc = cli_main.run( + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == 200.0 + + def test_param_override_beats_design_frequency(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project(freq=200.0) + cli_main.run(["param", "set", "design.frequency_mhz", "300", "--project", project_dir]) + capsys.readouterr() + rc = cli_main.run( + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == 300.0 + assert data["records"][0]["source"] == "ecc.toml" diff --git a/test/cli/params/test_provenance.py b/test/cli/params/test_provenance.py new file mode 100644 index 00000000..31ed8ccc --- /dev/null +++ b/test/cli/params/test_provenance.py @@ -0,0 +1,197 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestCliProvenance: + def test_run_set_reports_cli_source_in_config( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + from types import SimpleNamespace + + project_dir = create_cli_project() + workspace_obj = SimpleNamespace(name="workspace") + + def fake_create(**kwargs): + run_dir = kwargs["directory"] + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + monkeypatch.setattr( + "chipcompiler.engine.EngineFlow", + type( + "DummyFlow", + (), + { + "__init__": lambda self, workspace: None, + "has_init": lambda self: False, + "add_step": lambda self, **kw: None, + "create_step_workspaces": lambda self: None, + "run_steps": lambda self: True, + }, + ), + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.setattr( + "chipcompiler.cli.rendering.progress.should_enable_run_progress", + lambda *a, **kw: False, + ) + + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "synth.max_fanout=16", + ] + ) + assert rc == 0 + capsys.readouterr() + + # Verify provenance file was written + provenance = os.path.join( + project_dir, "runs", "default", "home", "cli-param-overrides.json" + ) + assert os.path.isfile(provenance) + with open(provenance) as f: + data = json.load(f) + assert data["synth.max_fanout"] == 16 + + def test_config_resolved_shows_cli_source( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + from types import SimpleNamespace + + project_dir = create_cli_project() + workspace_obj = SimpleNamespace(name="workspace") + + def fake_create(**kwargs): + run_dir = kwargs["directory"] + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + monkeypatch.setattr( + "chipcompiler.engine.EngineFlow", + type( + "DummyFlow", + (), + { + "__init__": lambda self, workspace: None, + "has_init": lambda self: False, + "add_step": lambda self, **kw: None, + "create_step_workspaces": lambda self: None, + "run_steps": lambda self: True, + }, + ), + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.setattr( + "chipcompiler.cli.rendering.progress.should_enable_run_progress", + lambda *a, **kw: False, + ) + + # Run with --set + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "synth.max_fanout=16", + ] + ) + assert rc == 0 + capsys.readouterr() + + # Now inspect config --resolved + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + param_records = [r for r in data["records"] if r.get("kind") == "param"] + fanout = next(r for r in param_records if r["key"] == "synth.max_fanout") + assert fanout["value"] == 16 + assert fanout["source"] == "cli" + + def test_config_resolved_toml_plus_cli_precedence( + self, tmp_path, monkeypatch, capsys, create_cli_project + ): + from types import SimpleNamespace + + project_dir = create_cli_project() + workspace_obj = SimpleNamespace(name="workspace") + + # Set a TOML override first + cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) + capsys.readouterr() + + def fake_create(**kwargs): + run_dir = kwargs["directory"] + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + return workspace_obj + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) + monkeypatch.setattr( + "chipcompiler.engine.EngineFlow", + type( + "DummyFlow", + (), + { + "__init__": lambda self, workspace: None, + "has_init": lambda self: False, + "add_step": lambda self, **kw: None, + "create_step_workspaces": lambda self: None, + "run_steps": lambda self: True, + }, + ), + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda name, root: None, + ) + monkeypatch.setattr( + "chipcompiler.cli.rendering.progress.should_enable_run_progress", + lambda *a, **kw: False, + ) + + # Run with different CLI override + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "synth.max_fanout=32", + ] + ) + assert rc == 0 + capsys.readouterr() + + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + param_records = [r for r in data["records"] if r.get("kind") == "param"] + fanout = next(r for r in param_records if r["key"] == "synth.max_fanout") + assert fanout["value"] == 32 + assert fanout["source"] == "cli" diff --git a/test/cli/test_params.py b/test/cli/params/test_registry.py similarity index 100% rename from test/cli/test_params.py rename to test/cli/params/test_registry.py diff --git a/test/cli/params/test_toml_editing.py b/test/cli/params/test_toml_editing.py new file mode 100644 index 00000000..6cd9e506 --- /dev/null +++ b/test/cli/params/test_toml_editing.py @@ -0,0 +1,344 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestScopedTomlEdit: + def test_set_preserves_unrelated_sections(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + original = f.read() + + cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + design_section = original[original.index("[design]") : original.index("[pdk]")] + assert design_section in after + + def test_set_preserves_comments(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace("[design]", "[design]\n# my design") + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "# my design" in after + + def test_set_same_key_twice_has_one_assignment(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + content = f.read() + assert content.count("target_density") == 1 + assert "0.7" in content + assert "0.65" not in content + + def test_set_then_show_still_works(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + + cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) + capsys.readouterr() + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == 0.7 + + +class TestIndentedTomlKeys: + """Scoped TOML edit must handle indented assignment lines.""" + + def test_set_replaces_indented_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.place]\n target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert after.count("target_density") == 1 + assert "0.7" in after + + def test_set_then_show_indented(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.place]\n target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == 0.7 + + def test_unset_removes_indented_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.place]\n target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "target_density" not in after + + def test_set_indented_preserves_other_sections(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += '\n[params.place]\n target_density = 0.65\n\n[flow]\npreset = "rtl2gds"\n' + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert 'preset = "rtl2gds"' in after + assert after.count("target_density") == 1 + + +class TestMultilineTomlValues: + """Scoped TOML edit must handle multiline array values.""" + + def test_set_replaces_multiline_array(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "2," not in after + assert after.count("core_margin") == 1 + assert "[4, 4]" in after + + def test_unset_removes_multiline_array(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "unset", "floorplan.core_margin", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "core_margin" not in after + + def test_set_multiline_then_show(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run( + ["param", "show", "floorplan.core_margin", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == [4, 4] + + def test_set_preserves_adjacent_key_after_multiline(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n core_util = 0.5\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "core_util = 0.5" in after + assert after.count("core_margin") == 1 + for line in after.splitlines(): + assert "core_margin" not in line or "core_util" not in line, ( + f"multiline replacement concatenated keys on one line: {line!r}" + ) + + """config --resolved must error on malformed/invalid CLI provenance.""" + + def _setup_run_dir(self, project_dir): + run_dir = os.path.join(project_dir, "runs", "default") + os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) + return run_dir + + def test_malformed_json_provenance_fails( + self, tmp_path, capsys, monkeypatch, create_cli_project + ): + project_dir = create_cli_project() + run_dir = self._setup_run_dir(project_dir) + with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: + f.write("not valid json{") + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda pdk_root, pdk_name: [], + ) + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_config" + + def test_non_dict_provenance_fails(self, tmp_path, capsys, monkeypatch, create_cli_project): + project_dir = create_cli_project() + run_dir = self._setup_run_dir(project_dir) + with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: + json.dump([1, 2, 3], f) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda pdk_root, pdk_name: [], + ) + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 1 + + def test_unknown_key_in_provenance_fails( + self, tmp_path, capsys, monkeypatch, create_cli_project + ): + project_dir = create_cli_project() + run_dir = self._setup_run_dir(project_dir) + with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: + json.dump({"nonexistent.param": 42}, f) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda pdk_root, pdk_name: [], + ) + rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_config" + + +class TestSafeTomlSectionParsing: + """Scoped TOML edits must handle comments and indented headers safely.""" + + def test_set_ignores_commented_section_header(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n# [params.place]\n# target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert "[params.place]" in after + assert "target_density = 0.7" in after + + def test_set_ignores_indented_next_section_header(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += '\n[params.place]\ntarget_density = 0.65\n\n [flow]\npreset = "rtl2gds"\n' + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + with open(toml_path) as f: + after = f.read() + assert after.count("target_density") == 1 + assert "0.7" in after + assert 'preset = "rtl2gds"' in after + + def test_set_then_show_after_commented_header(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n# [params.place]\n# target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) + capsys.readouterr() + + rc = cli_main.run( + ["param", "show", "place.target_density", "--project", project_dir, "--json"] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["value"] == 0.7 + + def test_unset_ignores_commented_section_header(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n# [params.place]\n# target_density = 0.65\n" + with open(toml_path, "w") as f: + f.write(content) + + rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) + assert rc == 0 + capsys.readouterr() + with open(toml_path) as f: + after = f.read() + assert "target_density" in after diff --git a/test/cli/params/test_validation.py b/test/cli/params/test_validation.py new file mode 100644 index 00000000..e37985a2 --- /dev/null +++ b/test/cli/params/test_validation.py @@ -0,0 +1,179 @@ +import json +import os + +from chipcompiler.cli import main as cli_main + + +class TestTomlValidationErrors: + def _create_project_with_invalid_param(self, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += '\n[params.synth]\nmax_fanout = "not_an_int"\n' + with open(toml_path, "w") as f: + f.write(content) + return project_dir + + def test_check_fails_invalid_param_type(self, tmp_path, capsys, create_cli_project): + project_dir = self._create_project_with_invalid_param(create_cli_project) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + reasons = [r.get("reason", "") for r in data["records"]] + assert any("params" in r for r in reasons) + + def test_check_fails_unknown_param_key(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.bogus]\nkey = 5\n" + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 1 + + def test_run_fails_invalid_param_type(self, tmp_path, create_cli_project): + project_dir = self._create_project_with_invalid_param(create_cli_project) + rc = cli_main.run(["run", "--project", project_dir]) + assert rc == 1 + + +class TestNativeTomlTypeValidation: + def test_check_rejects_float_for_int(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.synth]\nmax_fanout = 16.5\n" + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 1 + + def test_check_rejects_bool_for_int(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.synth]\nmax_fanout = true\n" + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 1 + + def test_check_rejects_float_in_list_int(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.floorplan]\ncore_margin = [2.5, 3]\n" + with open(toml_path, "w") as f: + f.write(content) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 1 + + def test_check_accepts_valid_int(self, tmp_path, capsys, monkeypatch, create_cli_project): + project_dir = create_cli_project() + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.synth]\nmax_fanout = 16\n" + with open(toml_path, "w") as f: + f.write(content) + monkeypatch.setattr( + "chipcompiler.cli.project.config._validate_pdk_contents", + lambda pdk_root, pdk_name: [], + ) + rc = cli_main.run(["check", "--project", project_dir, "--json"]) + assert rc == 0 + + +class TestParamHandlersRejectInvalidToml: + """Param list/show/diff must return errors when ecc.toml has invalid [params.*].""" + + def _write_invalid_toml(self, project_dir): + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content += "\n[params.synth]\nmax_fanout = 16.5\n" + with open(toml_path, "w") as f: + f.write(content) + + def test_param_list_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_invalid_toml(project_dir) + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_param_config" + + def test_param_show_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_invalid_toml(project_dir) + rc = cli_main.run(["param", "show", "synth.max_fanout", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_param_config" + + def test_param_diff_rejects_invalid_toml(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_invalid_toml(project_dir) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_param_config" + + +class TestZeroFrequencyRejected: + """ecc param set design.frequency_mhz 0 must be rejected.""" + + def test_set_zero_rejected(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run(["param", "set", "design.frequency_mhz", "0", "--project", project_dir]) + assert rc == 1 + + def test_cli_set_zero_rejected(self, tmp_path, create_cli_project): + project_dir = create_cli_project() + rc = cli_main.run( + [ + "run", + "--project", + project_dir, + "--set", + "design.frequency_mhz=0", + ] + ) + assert rc == 1 + + +class TestMalformedTomlRejected: + """ecc param list/show/diff must reject syntactically malformed ecc.toml.""" + + def _write_malformed_toml(self, project_dir): + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path, "w") as f: + f.write('[design\nname = "gcd"\n') + + def test_param_list_rejects_malformed(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_malformed_toml(project_dir) + rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) + assert rc == 1 + data = json.loads(capsys.readouterr().out) + assert data["records"][0]["error"] == "invalid_param_config" + + def test_param_show_rejects_malformed(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_malformed_toml(project_dir) + rc = cli_main.run( + ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] + ) + assert rc == 1 + + def test_param_diff_rejects_malformed(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() + self._write_malformed_toml(project_dir) + rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) + assert rc == 1 diff --git a/test/cli/test_log_view.py b/test/cli/rendering/test_log_view.py similarity index 100% rename from test/cli/test_log_view.py rename to test/cli/rendering/test_log_view.py diff --git a/test/cli/test_pretty.py b/test/cli/rendering/test_pretty.py similarity index 84% rename from test/cli/test_pretty.py rename to test/cli/rendering/test_pretty.py index 6f97827e..8855ff6a 100644 --- a/test/cli/test_pretty.py +++ b/test/cli/rendering/test_pretty.py @@ -18,57 +18,6 @@ ) from chipcompiler.cli.rendering.render import _plain_value, render_plain -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _create_valid_project(tmp_path, name="gcd", pdk_root=None): - project_dir = tmp_path / name - project_dir.mkdir(exist_ok=True) - (project_dir / "rtl").mkdir(exist_ok=True) - (project_dir / "constraints").mkdir(exist_ok=True) - (project_dir / "runs").mkdir(exist_ok=True) - - rtl_file = project_dir / "rtl" / "gcd.v" - rtl_file.write_text("module gcd(input clk); endmodule\n") - - if pdk_root is None: - pdk_root = tmp_path / "ics55" - pdk_root.mkdir(exist_ok=True) - - toml = f'''[design] -name = "{name}" -top = "{name}" -rtl = ["rtl/gcd.v"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - return str(project_dir) - - -def _create_flow_json(run_dir, steps=None): - import json as j - - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - if steps is None: - steps = [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, - ] - with open(os.path.join(home, "flow.json"), "w") as f: - j.dump({"steps": steps}, f) - - # --------------------------------------------------------------------------- # Plain key-value stability tests # --------------------------------------------------------------------------- @@ -188,8 +137,8 @@ def test_init_plain(self, tmp_path, capsys): assert "\x1b[" not in out assert "=" in out - def test_check_plain(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) + def test_check_plain(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root: None, @@ -200,19 +149,19 @@ def test_check_plain(self, tmp_path, monkeypatch, capsys): assert "\x1b[" not in out assert "=" in out - def test_status_plain(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - _create_flow_json(os.path.join(project_dir, "runs", "default")) + def test_status_plain(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + create_flow_json(os.path.join(project_dir, "runs", "default"), profile="pretty") rc = cli_main.run(["status", "--project", project_dir, "--plain"]) assert rc == 0 out = capsys.readouterr().out assert "\x1b[" not in out assert "status=" in out - def test_metrics_plain(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_metrics_plain(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) + create_flow_json(run_dir, profile="pretty") analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") os.makedirs(analysis_dir, exist_ok=True) with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: @@ -223,10 +172,10 @@ def test_metrics_plain(self, tmp_path, capsys): assert "\x1b[" not in out assert "metric=" in out - def test_artifacts_plain(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_artifacts_plain(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) + create_flow_json(run_dir, profile="pretty") step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") os.makedirs(step_dir, exist_ok=True) with open(os.path.join(step_dir, "synthesis.log"), "w") as f: @@ -236,14 +185,14 @@ def test_artifacts_plain(self, tmp_path, capsys): out = capsys.readouterr().out assert "\x1b[" not in out - def test_diagnose_plain(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_diagnose_plain(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() cli_main.run(["diagnose", "--plain", "--project", project_dir]) out = capsys.readouterr().out assert "\x1b[" not in out - def test_config_plain(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) + def test_config_plain(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root: None, @@ -266,8 +215,8 @@ def test_init_has_header(self, tmp_path, capsys): out = capsys.readouterr().out assert "[init]" in out - def test_check_has_header(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) + def test_check_has_header(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root: None, @@ -277,17 +226,17 @@ def test_check_has_header(self, tmp_path, monkeypatch, capsys): out = capsys.readouterr().out assert "[check]" in out - def test_status_has_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - _create_flow_json(os.path.join(project_dir, "runs", "default")) + def test_status_has_header(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + create_flow_json(os.path.join(project_dir, "runs", "default"), profile="pretty") rc = cli_main.run(["status", "--project", project_dir]) assert rc == 0 out = capsys.readouterr().out assert "[status]" in out - def test_status_groups_steps(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - _create_flow_json( + def test_status_groups_steps(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + create_flow_json( os.path.join(project_dir, "runs", "default"), [ {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, @@ -300,8 +249,8 @@ def test_status_groups_steps(self, tmp_path, capsys): assert "synthesis (yosys)" in out assert "cts (ecc)" in out - def test_metrics_groups_by_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_metrics_groups_by_step(self, tmp_path, capsys, create_cli_project): + project_dir = create_cli_project() run_dir = os.path.join(project_dir, "runs", "default") for step_dir_name in ["Synthesis_yosys", "CTS_ecc"]: analysis = os.path.join(run_dir, step_dir_name, "analysis") @@ -316,10 +265,12 @@ def test_metrics_groups_by_step(self, tmp_path, capsys): assert "synthesis:" in out assert "cts:" in out - def test_diagnose_clean_has_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_diagnose_clean_has_header( + self, tmp_path, capsys, create_cli_project, create_flow_json + ): + project_dir = create_cli_project() run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( + create_flow_json( run_dir, [ {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, @@ -341,8 +292,8 @@ def test_error_output_has_error_header(self, tmp_path, capsys): out = capsys.readouterr().out assert "[error]" in out - def test_run_summary_has_header(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) + def test_run_summary_has_header(self, tmp_path, monkeypatch, capsys, create_cli_project): + project_dir = create_cli_project() from types import SimpleNamespace DummyFlow_instances = [] @@ -399,19 +350,19 @@ def run_steps(self): class TestJsonUnchanged: - def test_status_json_unchanged(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - _create_flow_json(os.path.join(project_dir, "runs", "default")) + def test_status_json_unchanged(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() + create_flow_json(os.path.join(project_dir, "runs", "default"), profile="pretty") rc = cli_main.run(["status", "--project", project_dir, "--json"]) assert rc == 0 data = json.loads(capsys.readouterr().out) assert "records" in data assert data["records"][0]["run"] == "default" - def test_metrics_jsonl_unchanged(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) + def test_metrics_jsonl_unchanged(self, tmp_path, capsys, create_cli_project, create_flow_json): + project_dir = create_cli_project() run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) + create_flow_json(run_dir, profile="pretty") analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") os.makedirs(analysis_dir, exist_ok=True) with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: diff --git a/test/cli/test_progress.py b/test/cli/rendering/test_progress.py similarity index 100% rename from test/cli/test_progress.py rename to test/cli/rendering/test_progress.py diff --git a/test/cli/rendering/test_render.py b/test/cli/rendering/test_render.py new file mode 100644 index 00000000..c9a84f42 --- /dev/null +++ b/test/cli/rendering/test_render.py @@ -0,0 +1,42 @@ +import json + + +class TestRendererCmdStripping: + def test_text_strips_cmd_suffix(self): + from io import StringIO + + from chipcompiler.cli.rendering.render import render_text + + buf = StringIO() + render_text(({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},), file=buf) + line = buf.getvalue().strip() + assert "inspect=" in line + assert "log=" in line + assert "inspect_cmd=" not in line + assert "log_cmd=" not in line + + def test_json_preserves_cmd_keys(self): + from io import StringIO + + from chipcompiler.cli.core.types import CommandResult + from chipcompiler.cli.rendering.render import render_json + + buf = StringIO() + result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) + render_json(result, file=buf) + data = json.loads(buf.getvalue()) + assert "inspect_cmd" in data["records"][0] + assert "log_cmd" in data["records"][0] + + def test_jsonl_preserves_cmd_keys(self): + from io import StringIO + + from chipcompiler.cli.core.types import CommandResult + from chipcompiler.cli.rendering.render import render_jsonl + + buf = StringIO() + result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) + render_jsonl(result, file=buf) + record = json.loads(buf.getvalue().strip()) + assert "inspect_cmd" in record + assert "log_cmd" in record diff --git a/test/cli/test_cli_inspect.py b/test/cli/test_cli_inspect.py deleted file mode 100644 index 2154aaea..00000000 --- a/test/cli/test_cli_inspect.py +++ /dev/null @@ -1,2237 +0,0 @@ -import json -import os - -from chipcompiler.cli import main as cli_main - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _create_valid_project(tmp_path, name="gcd", pdk_root=None): - project_dir = tmp_path / name - project_dir.mkdir(exist_ok=True) - (project_dir / "rtl").mkdir(exist_ok=True) - (project_dir / "constraints").mkdir(exist_ok=True) - (project_dir / "runs").mkdir(exist_ok=True) - - rtl_file = project_dir / "rtl" / "gcd.v" - rtl_file.write_text("module gcd(input clk); endmodule\n") - - if pdk_root is None: - pdk_root = tmp_path / "ics55" - pdk_root.mkdir(exist_ok=True) - - toml = f'''[design] -name = "{name}" -top = "{name}" -rtl = ["rtl/gcd.v"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - return str(project_dir) - - -def _create_flow_json(run_dir, steps=None): - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - if steps is None: - steps = [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, - {"name": "Floorplan", "tool": "ecc", "state": "Success", "runtime": "0:00:03"}, - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ] - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": steps}, f) - - -def _create_step_dir(run_dir, step_name, tool, subdirs=None, files=None): - step_dir = os.path.join(run_dir, f"{step_name}_{tool}") - os.makedirs(step_dir, exist_ok=True) - if subdirs: - for sd in subdirs: - d = os.path.join(step_dir, sd) - os.makedirs(d, exist_ok=True) - if files: - for relpath, content in files.items(): - fpath = os.path.join(step_dir, relpath) - os.makedirs(os.path.dirname(fpath), exist_ok=True) - with open(fpath, "w") as f: - f.write(content) - return step_dir - - -def _create_workspace_config(run_dir, files): - config_dir = os.path.join(run_dir, "config") - os.makedirs(config_dir, exist_ok=True) - for name, content in files.items(): - with open(os.path.join(config_dir, name), "w") as f: - f.write(content) - - -def _create_cts_workspace_config(run_dir): - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "cts_default_config.json": "{}", - }, - ) - - -def _create_dreamplace_workspace_config(run_dir): - _create_workspace_config(run_dir, {"dreamplace.json": "{}"}) - - -def _create_ecc_workspace_config(run_dir, step_config): - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - step_config: "{}", - }, - ) - - -def _has_disclosure(line: str) -> bool: - return bool( - '"ecc ' in line - or "=ecc " in line - or " ecc check" in line - or " ecc run" in line - or " ecc status" in line - or " ecc log" in line - or " ecc metrics" in line - or " ecc artifacts" in line - or " ecc config" in line - or " ecc diagnose" in line - or " ecc param" in line - ) - - -def _mock_pdk_validation(monkeypatch): - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - - -# =========================================================================== -# AC-1: Run-id resolution -# =========================================================================== - - -class TestRunIdResolution: - def test_status_default_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--run-id", "default", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "default" in out - - def test_status_simple_token_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "run_004") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--run-id", "run_004", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "run_004" in out - - def test_status_relative_path_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") - _create_flow_json(run_dir) - - rc = cli_main.run( - ["status", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] - ) - assert rc == 0 - out = capsys.readouterr().out - assert "sweeps/sweep_001/run_004" in out - - def test_status_absolute_path_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = tmp_path / "ecc-run-004" - _create_flow_json(str(run_dir)) - - rc = cli_main.run(["status", "--run-id", str(run_dir), "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "run:" in out - - def test_status_missing_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["status", "--run-id", "nonexistent", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing" in out - - def test_log_preserves_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "run_005") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, - "Synthesis", - "yosys", - subdirs=["log"], - files={"log/synthesis.log": "Error: something failed\n"}, - ) - - rc = cli_main.run( - ["log", "synthesis", "--errors", "--run-id", "run_005", "--project", project_dir] - ) - assert rc == 0 - out = capsys.readouterr().out - assert "--run-id run_005" in out - - def test_metrics_preserves_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "run_006") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["analysis"], - files={"analysis/CTS_metrics.json": json.dumps({"Frequency [MHz]": 450.0})}, - ) - - rc = cli_main.run(["metrics", "cts", "--run-id", "run_006", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--run-id run_006" in out - - -# =========================================================================== -# AC-2: ecc artifacts -# =========================================================================== - - -class TestArtifacts: - def test_artifacts_all_steps(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["output", "log"], - files={"output/design.def": "def content", "log/cts.log": "log content"}, - ) - - rc = cli_main.run(["artifacts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "cts" in out - assert "(output)" in out - assert "(log)" in out - - def test_artifacts_single_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "cts" in out - assert "(output)" in out - - def test_artifacts_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["artifacts", "nonexistent", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "unknown_step" in out - - def test_artifacts_empty_known_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "No artifacts found" in out - - def test_artifacts_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run(["artifacts", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert len(data["records"]) > 0 - assert data["records"][0]["artifact"] == "design.def" - - def test_artifacts_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["output", "log"], - files={"output/design.def": "def content", "log/cts.log": "log content"}, - ) - - rc = cli_main.run(["artifacts", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert len(objects) == 2 - assert all("artifact" in o for o in objects) - - def test_artifacts_with_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run( - ["artifacts", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] - ) - assert rc == 0 - out = capsys.readouterr().out - assert "cts" in out - - def test_artifacts_derives_roles_from_dirs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["config", "output", "report", "log", "analysis"], - files={ - "config/cts_config.json": "{}", - "output/design.def": "def", - "report/timing.rpt": "rpt", - "log/cts.log": "log", - "analysis/CTS_metrics.json": "{}", - }, - ) - - rc = cli_main.run(["artifacts", "cts", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - roles = {a["role"] for a in data["records"]} - assert roles == {"config", "output", "report", "log", "analysis"} - - -# =========================================================================== -# AC-3: ecc config --resolved (project level) -# =========================================================================== - - -class TestConfigResolved: - def test_config_resolved_project(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "design.name" in out - assert "project:" in out - assert "pdk.name" in out - assert "run_dir" in out - - def test_config_resolved_json(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - keys = [item["config"] for item in data["records"]] - assert "design.name" in keys - assert "pdk.name" in keys - assert "run_dir" in keys - - def test_config_resolved_default_run_dir_value(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - run_item = next(i for i in data["records"] if i["config"] == "run_dir") - assert run_item["value"] == "runs/default" - - def test_config_resolved_jsonl(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - keys = [o["config"] for o in objects] - assert "design.name" in keys - - def test_config_resolved_pdk_root_from_env(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - pdk_root = tmp_path / "ics55_env" - pdk_root.mkdir() - monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(pdk_root)) - - project_dir = _create_valid_project(tmp_path, pdk_root="") - - rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - pdk_item = next(i for i in data["records"] if i["config"] == "pdk.root") - assert pdk_item["source"] == "env" - - def test_config_resolved_run_id(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run( - [ - "config", - "--resolved", - "--run-id", - "sweeps/sweep_001/run_004", - "--json", - "--project", - project_dir, - ] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - run_item = next(i for i in data["records"] if i["config"] == "run_dir") - assert run_item["value"] == "sweeps/sweep_001/run_004" - - def test_config_missing_config(self, tmp_path, capsys): - project_dir = tmp_path / "empty_project" - project_dir.mkdir() - - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - - def test_config_missing_config_json_has_kind_error(self, tmp_path, capsys): - project_dir = tmp_path / "empty_project" - project_dir.mkdir() - - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir), "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["kind"] == "error" - assert record["error"] == "missing_config" - - def test_config_missing_config_jsonl_has_kind_error(self, tmp_path, capsys): - project_dir = tmp_path / "empty_project" - project_dir.mkdir() - - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir), "--jsonl"]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["kind"] == "error" - assert record["error"] == "missing_config" - - def test_config_missing_config_text_has_kind_error(self, tmp_path, capsys): - project_dir = tmp_path / "empty_project" - project_dir.mkdir() - - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - out = capsys.readouterr().out - assert "[error]" in out - assert "missing_config" in out - assert "ecc check" in out - assert str(project_dir) in out - - def test_config_requires_resolved(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--project", project_dir]) - assert rc != 0 - assert "--resolved" in capsys.readouterr().err - - -# =========================================================================== -# AC-4: ecc config --resolved -# =========================================================================== - - -class TestConfigStepResolved: - def test_config_step_lists_files(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "cts_default_config.json": "{}", - }, - ) - - rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "step:" in out or "cts" in out - assert "step:" in out or "step:" in out - assert "runs/default/config/flow_config.json" in out - assert "runs/default/config/db_default_config.json" in out - assert "cts_default_config.json" in out - - def test_config_step_json(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "cts_default_config.json": "{}", - }, - ) - - rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - records = data["records"] - assert all(item["scope"] == "step" for item in records) - assert all(item["step"] == "cts" for item in records) - assert all(item["source"] == "workspace_config" for item in records) - assert [item["path"] for item in records] == [ - "runs/default/config/flow_config.json", - "runs/default/config/db_default_config.json", - "runs/default/config/cts_default_config.json", - ] - - def test_config_step_workspace_records_inspect_with_config_command( - self, tmp_path, capsys, monkeypatch - ): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert all( - item["inspect"] == f"ecc config cts --resolved --json --project {project_dir}" - for item in data["records"] - ) - - def test_config_step_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["config", "nonexistent", "--resolved", "--project", project_dir]) - assert rc == 1 - - def test_config_step_no_config_files(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - - rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) - assert rc == 0 - - def test_config_dreamplace_legalization_uses_dreamplace_config( - self, tmp_path, capsys, monkeypatch - ): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "legalization", - "tool": "dreamplace", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir(run_dir, "legalization", "dreamplace", subdirs=["output"]) - _create_dreamplace_workspace_config(run_dir) - - rc = cli_main.run( - ["config", "legalization", "--resolved", "--json", "--project", project_dir] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ - "runs/default/config/dreamplace.json", - ] - assert data["records"][0]["source"] == "workspace_config" - - def test_config_workspace_backed_ecc_steps(self, tmp_path, capsys): - cases = [ - ("PNP", "pnp", "pnp_default_config.json"), - ("optDrv", "optdrv", "to_default_config_drv.json"), - ("optHold", "opthold", "to_default_config_hold.json"), - ("optSetup", "optsetup", "to_default_config_setup.json"), - ] - for step_name, step_token, step_config in cases: - project_dir = _create_valid_project(tmp_path, name=f"gcd_{step_token}") - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": step_name, - "tool": "ecc", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir(run_dir, step_name, "ecc", subdirs=["output"]) - _create_ecc_workspace_config(run_dir, step_config) - - rc = cli_main.run( - ["config", step_token, "--resolved", "--json", "--project", project_dir] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ - "runs/default/config/flow_config.json", - "runs/default/config/db_default_config.json", - f"runs/default/config/{step_config}", - ] - assert all(item["source"] == "workspace_config" for item in data["records"]) - - def test_config_sta_uses_rcx_and_sta_workspace_configs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "STA", - "tool": "ecc", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir(run_dir, "STA", "ecc", subdirs=["output"]) - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "rcx.json": "{}", - "sta.json": "{}", - }, - ) - - rc = cli_main.run(["config", "sta", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ - "runs/default/config/flow_config.json", - "runs/default/config/db_default_config.json", - "runs/default/config/rcx.json", - "runs/default/config/sta.json", - ] - assert all(item["source"] == "workspace_config" for item in data["records"]) - - def test_config_yosys_synthesis_does_not_report_ieda_flow_config(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "Synthesis", - "tool": "yosys", - "state": "Success", - "runtime": "0:00:05", - }, - ], - ) - _create_step_dir(run_dir, "Synthesis", "yosys", subdirs=["output"]) - _create_workspace_config(run_dir, {"flow_config.json": "{}"}) - - rc = cli_main.run( - ["config", "synthesis", "--resolved", "--json", "--project", project_dir] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert len(data["records"]) == 1 - assert data["records"][0]["step"] == "synthesis" - assert data["records"][0]["config_status"] == "none" - assert "path" not in data["records"][0] - - -# =========================================================================== -# AC-5: ecc diagnose -# =========================================================================== - - -class TestDiagnose: - def test_diagnose_missing_run(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing_run" in out - assert "error:" in out - - def test_diagnose_invalid_flow_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - f.write("NOT VALID JSON{{{") - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "invalid_flow_json" in out - - def test_diagnose_failed_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "analysis"], - files={"log/cts.log": "Error: failed\n", "analysis/CTS_metrics.json": "{}"}, - ) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "failed_step" in out - assert "error:" in out - - def test_diagnose_ongoing_step_warning(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Ongoing", "runtime": ""}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "running\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "ongoing_step" in out - assert "warning:" in out - - def test_diagnose_unstarted_step_info(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Unstart", "runtime": ""}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "unstarted_step" in out - assert "info:" in out - - def test_diagnose_log_errors_count(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "Error: bad thing\nError: other bad\nok line\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "log_errors" in out - assert "count: 2" in out - - def test_diagnose_missing_metrics_warning(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "missing_metrics" in out - assert "warning:" in out - - def test_diagnose_missing_artifacts_warning(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "analysis"], - files={ - "log/cts.log": "ok\n", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - # Remove investigation role dirs to trigger missing_artifacts - import shutil - - shutil.rmtree(os.path.join(run_dir, "CTS_ecc", "analysis")) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "missing_artifacts" in out - assert "warning:" in out - - def test_diagnose_config_unavailable_info(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" in out - assert "info:" in out - - def test_diagnose_clean_run(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": json.dumps({"Frequency [MHz]": 450.0}), - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "clean" in out - - def test_diagnose_uses_workspace_config_without_step_config(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" not in out - assert "clean" in out - - def test_diagnose_dreamplace_legalization_uses_dreamplace_config(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "legalization", - "tool": "dreamplace", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir( - run_dir, - "legalization", - "dreamplace", - subdirs=["log", "output", "analysis"], - files={ - "log/legalization.log": "ok\n", - "output/design.def": "def", - "analysis/legalization_metrics.json": "{}", - }, - ) - _create_dreamplace_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "legalization", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" not in out - assert "clean" in out - - def test_diagnose_workspace_backed_ecc_steps(self, tmp_path, capsys): - cases = [ - ("PNP", "pnp", "pnp_default_config.json"), - ("optDrv", "optdrv", "to_default_config_drv.json"), - ("optHold", "opthold", "to_default_config_hold.json"), - ("optSetup", "optsetup", "to_default_config_setup.json"), - ] - for step_name, step_token, step_config in cases: - project_dir = _create_valid_project(tmp_path, name=f"gcd_{step_token}") - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": step_name, - "tool": "ecc", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir( - run_dir, - step_name, - "ecc", - subdirs=["log", "output", "analysis"], - files={ - f"log/{step_name}.log": "ok\n", - "output/design.def": "def", - f"analysis/{step_name}_metrics.json": "{}", - }, - ) - _create_ecc_workspace_config(run_dir, step_config) - - rc = cli_main.run(["diagnose", step_token, "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" not in out - assert "clean" in out - - def test_diagnose_sta_uses_rcx_workspace_config(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "STA", - "tool": "ecc", - "state": "Success", - "runtime": "0:00:04", - }, - ], - ) - _create_step_dir( - run_dir, - "STA", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/STA.log": "ok\n", - "output/design.def": "def", - "analysis/STA_metrics.json": "{}", - }, - ) - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "rcx.json": "{}", - "sta.json": "{}", - }, - ) - - rc = cli_main.run(["diagnose", "sta", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" not in out - assert "clean" in out - - def test_diagnose_yosys_synthesis_reports_config_unavailable(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - { - "name": "Synthesis", - "tool": "yosys", - "state": "Success", - "runtime": "0:00:05", - }, - ], - ) - _create_step_dir( - run_dir, - "Synthesis", - "yosys", - subdirs=["log", "output", "analysis"], - files={ - "log/Synthesis.log": "ok\n", - "output/design.v": "verilog", - "analysis/Synthesis_metrics.json": "{}", - }, - ) - _create_workspace_config(run_dir, {"flow_config.json": "{}"}) - - rc = cli_main.run(["diagnose", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" in out - assert "info:" in out - - def test_diagnose_step_filter(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "Synthesis", - "yosys", - subdirs=["output", "log", "analysis", "config"], - files={ - "output/synth.v": "verilog", - "log/synthesis.log": "ok\n", - "analysis/Synthesis_metrics.json": "{}", - "config/config.json": "{}", - }, - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} - ) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "failed_step" in out - assert "cts" in out - assert "synthesis" not in out - - def test_diagnose_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["diagnose", "nonexistent", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "unknown_step" in out - - def test_diagnose_no_repair_suggestions(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} - ) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "suggest" not in out.lower() - assert "fix" not in out.lower() - assert "recommend" not in out.lower() - - def test_diagnose_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} - ) - - rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert any(i["issue"] == "failed_step" for i in data["records"]) - - def test_diagnose_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} - ) - - rc = cli_main.run(["diagnose", "--jsonl", "--project", project_dir]) - assert rc == 1 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert any(o["issue"] == "failed_step" for o in objects) - - def test_diagnose_with_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "run_007") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--run-id", "run_007", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "clean" in out - - -# =========================================================================== -# AC-6: Diagnose exit codes -# =========================================================================== - - -class TestDiagnoseExitCodes: - def test_error_issue_returns_nonzero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: failed\n"} - ) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - - def test_warning_only_returns_zero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Ongoing", "runtime": ""}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "running\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - - def test_clean_run_returns_zero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - - def test_failed_step_not_zero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir(run_dir, "CTS", "ecc") - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc != 0 - - -# =========================================================================== -# AC-7: Disclosure commands in Phase 2 output -# =========================================================================== - - -class TestDisclosure: - def test_artifacts_lines_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_config_resolved_lines_have_disclosure(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_diagnose_lines_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_phase2_disclosure_preserves_run_id(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "run_008") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["log"], files={"log/cts.log": "Error: fail\n"} - ) - - rc = cli_main.run(["diagnose", "--run-id", "run_008", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "--run-id run_008" in out - - def test_artifacts_disclosure_preserves_project(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert f"--project {project_dir}" in out - - -# =========================================================================== -# AC-8: Read-only and CLI-local -# =========================================================================== - - -class TestReadOnly: - def test_artifacts_does_not_modify_files(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "original"} - ) - - before_mtime = os.path.getmtime(os.path.join(run_dir, "CTS_ecc", "output", "design.def")) - - rc = cli_main.run(["artifacts", "--project", project_dir]) - assert rc == 0 - - after_mtime = os.path.getmtime(os.path.join(run_dir, "CTS_ecc", "output", "design.def")) - assert before_mtime == after_mtime - - def test_no_persistent_metadata_files(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - cli_main.run(["artifacts", "--project", project_dir]) - cli_main.run(["config", "--resolved", "--project", project_dir]) - cli_main.run(["diagnose", "--project", project_dir]) - - assert not os.path.exists(os.path.join(project_dir, "issues.json")) - assert not os.path.exists(os.path.join(project_dir, "artifacts.json")) - assert not os.path.exists(os.path.join(project_dir, "resolved_config.json")) - assert not os.path.exists(os.path.join(run_dir, "issues.json")) - assert not os.path.exists(os.path.join(run_dir, "artifacts.json")) - - -# =========================================================================== -# Regression tests for Codex review findings (Round 1) -# =========================================================================== - - -class TestRunIdDisclosure: - def test_explicit_default_preserved_in_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--run-id", "default", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--run-id default" in out - - def test_project_relative_run_id_resolves(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") - _create_flow_json(run_dir) - - rc = cli_main.run( - ["status", "--run-id", "sweeps/sweep_001/run_004", "--project", project_dir] - ) - assert rc == 0 - out = capsys.readouterr().out - assert "sweeps/sweep_001/run_004" in out - - -class TestArtifactPaths: - def test_nested_run_artifact_paths(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["output"], files={"output/design.def": "def content"} - ) - - rc = cli_main.run( - [ - "artifacts", - "--run-id", - "sweeps/sweep_001/run_004", - "--json", - "--project", - project_dir, - ] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert len(data["records"]) == 1 - path = data["records"][0]["path"] - assert path.startswith("sweeps/") - - def test_nested_run_step_config_paths(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "sweeps", "sweep_001", "run_004") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - _create_workspace_config( - run_dir, - { - "flow_config.json": "{}", - "db_default_config.json": "{}", - "cts_default_config.json": "{}", - }, - ) - - rc = cli_main.run( - [ - "config", - "cts", - "--resolved", - "--run-id", - "sweeps/sweep_001/run_004", - "--json", - "--project", - project_dir, - ] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert [item["path"] for item in data["records"]] == [ - "sweeps/sweep_001/run_004/config/flow_config.json", - "sweeps/sweep_001/run_004/config/db_default_config.json", - "sweeps/sweep_001/run_004/config/cts_default_config.json", - ] - - -class TestEmptyStepConfigSentinel: - def test_step_no_config_emits_sentinel_text(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - - rc = cli_main.run(["config", "cts", "--resolved", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "cts" in out - assert "No configuration" in out - assert "artifacts:" in out - - def test_step_no_config_emits_sentinel_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - - rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["step"] == "cts" - assert data["records"][0]["config_status"] == "none" - - -class TestDirectoryOnlyStepConfig: - def test_dir_only_step_config_infers_tool_from_step_dir(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, - ], - ) - _create_step_dir(run_dir, "CTS", "ecc", subdirs=["output"]) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["config", "cts", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert [item["path"] for item in data["records"]] == [ - "runs/default/config/flow_config.json", - "runs/default/config/db_default_config.json", - "runs/default/config/cts_default_config.json", - ] - - def test_dir_only_step_diagnose_uses_inferred_tool_for_config(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:05"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" not in out - assert "clean" in out - - -class TestDiagnoseFlowOnlySteps: - def test_flow_step_without_directory_emits_issues(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Incomplete", "runtime": "0:00:04"}, - ], - ) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "failed_step" in out - assert "cts" in out - assert "unknown_step" not in out - - def test_flow_step_without_dir_reports_missing_artifacts(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "missing_artifacts" in out - assert "missing_metrics" in out - assert "config_unavailable" in out - - -class TestConfigRoleDisclosure: - def test_config_artifact_has_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _create_step_dir( - run_dir, "CTS", "ecc", subdirs=["config"], files={"config/cts_config.json": "{}"} - ) - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - -# =========================================================================== -# Regression tests for Codex Round 2 findings (Round 3) -# =========================================================================== - - -class TestAbsoluteRunIdConfig: - def test_absolute_run_id_preserves_run_dir_value(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - external_run = tmp_path / "external_run" - _create_flow_json(str(external_run)) - - rc = cli_main.run( - [ - "config", - "--resolved", - "--run-id", - str(external_run), - "--json", - "--project", - project_dir, - ] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - run_item = next(i for i in data["records"] if i["config"] == "run_dir") - assert run_item["value"] == str(external_run) - - -class TestConfigTextUsesItemInspectCmd: - def test_run_dir_text_uses_status_command(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "run_dir" in out - assert "ecc status" in out - - -class TestDiagnoseIssueSpecificEvidence: - def test_log_errors_uses_log_command(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "Error: bad thing\nError: other\nok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "log_errors" in out - assert "ecc log cts" in out - - def test_missing_metrics_uses_metrics_command(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "missing_metrics" in out - assert "ecc metrics cts" in out - - def test_missing_artifacts_uses_artifacts_command(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log"], - files={"log/cts.log": "ok\n"}, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "missing_artifacts" in out - assert "ecc artifacts cts" in out - - def test_config_unavailable_uses_config_command(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "config_unavailable" in out - assert "ecc config cts --resolved" in out - - def test_invalid_flow_json_has_evidence(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: - f.write("NOT VALID JSON{{{") - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "invalid_flow_json" in out - assert "evidence:" in out - assert "ecc status" in out - - def test_invalid_flow_json_json_has_evidence(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: - f.write("NOT VALID JSON{{{") - - rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - data = json.loads(out) - issue = data["records"][0] - assert issue["issue"] == "invalid_flow_json" - assert "evidence" in issue - assert "start_cmd" in issue - - -class TestCleanDiagnoseOutput: - def test_clean_has_status_and_disclosure_commands(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "clean" in out - assert "inspect:" in out - assert "artifacts:" in out - assert "config:" in out - - def test_clean_json_has_disclosure_metadata(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": "{}", - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "clean" - assert "inspect_cmd" in data["records"][0] - assert "artifacts" in data["records"][0] - assert "config" in data["records"][0] - - -class TestConfigJsonDisclosure: - def test_project_config_json_has_inspect_cmd(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["config", "--resolved", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - for item in data["records"]: - assert "inspect" in item, f"Missing inspect in item: {item['config']}" - - -class TestIsolatedConfigValidation: - @staticmethod - def _valid_toml(tmp_path, **overrides): - pdk_dir = tmp_path / "pdk" - pdk_dir.mkdir(exist_ok=True) - rtl_dir = tmp_path / "rtl" - rtl_dir.mkdir(exist_ok=True) - (rtl_dir / "gcd.v").write_text("module gcd; endmodule") - defaults = { - "name": "gcd", - "top": "gcd", - "rtl": '["rtl/gcd.v"]', - "clock_port": "clk", - "frequency_mhz": "100.0", - "pdk_name": "ics55", - "pdk_root": str(pdk_dir), - "flow_preset": "rtl2gds", - "flow_run": "default", - } - defaults.update(overrides) - return f'''[design] -name = "{defaults["name"]}" -top = "{defaults["top"]}" -rtl = {defaults["rtl"]} -clock_port = "{defaults["clock_port"]}" -frequency_mhz = {defaults["frequency_mhz"]} - -[pdk] -name = "{defaults["pdk_name"]}" -root = "{defaults["pdk_root"]}" - -[flow] -preset = "{defaults["flow_preset"]}" -run = "{defaults["flow_run"]}" -''' - - def test_unsupported_flow_run_rejected(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = tmp_path / "bad_run" - project_dir.mkdir() - toml = self._valid_toml(tmp_path, flow_run="custom") - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - - def test_empty_clock_port_rejected(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = tmp_path / "bad_clock" - project_dir.mkdir() - toml = self._valid_toml(tmp_path, clock_port="") - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - - def test_zero_frequency_rejected(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = tmp_path / "bad_freq" - project_dir.mkdir() - toml = self._valid_toml(tmp_path, frequency_mhz="0") - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - - def test_empty_rtl_rejected(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = tmp_path / "bad_rtl" - project_dir.mkdir() - toml = self._valid_toml(tmp_path, rtl="[]") - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["config", "--resolved", "--project", str(project_dir)]) - assert rc == 1 - - -# =========================================================================== -# Regression tests for Codex Round 4 code review (Round 5) -# =========================================================================== - - -class TestCorruptFlowJson: - def test_corrupt_flow_json_status_reports_corrupt(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: - f.write("BROKEN{{{") - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "corrupt" in out - - def test_missing_flow_json_status_reports_missing(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing" in out - - def test_corrupt_flow_json_json_reports_corrupt(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - with open(os.path.join(run_dir, "home", "flow.json"), "w") as f: - f.write("BROKEN{{{") - rc = cli_main.run(["status", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "corrupt" - - -class TestCorruptMetricsJson: - def test_malformed_metrics_reports_corrupt_text(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["analysis"], - files={"analysis/CTS_metrics.json": "NOT JSON{{{"}, - ) - rc = cli_main.run(["metrics", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "corrupt" in out - - def test_malformed_metrics_reports_corrupt_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["analysis"], - files={"analysis/CTS_metrics.json": "NOT JSON{{{"}, - ) - rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "corrupt" - - -class TestRtlPathResolution: - def test_absolute_rtl_resolved_correctly(self, tmp_path, capsys, monkeypatch): - _mock_pdk_validation(monkeypatch) - project_dir = tmp_path / "proj" - project_dir.mkdir() - rtl_dir = tmp_path / "external_rtl" - rtl_dir.mkdir() - (rtl_dir / "gcd.v").write_text("module gcd; endmodule") - (project_dir / "ecc.toml").write_text(f'''[design] -name = "gcd" -top = "gcd" -rtl = ["{rtl_dir / "gcd.v"}"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{tmp_path / "pdk"}" - -[flow] -preset = "rtl2gds" -run = "default" -''') - (tmp_path / "pdk").mkdir(exist_ok=True) - rc = cli_main.run(["config", "--resolved", "--json", "--project", str(project_dir)]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - rtl_item = next(i for i in data["records"] if i["config"] == "design.rtl.0") - assert rtl_item["resolved"] == str(rtl_dir / "gcd.v") - - -# =========================================================================== -# Regression tests for Codex Round 5 code review (Round 6) -# =========================================================================== - - -class TestPendingStepDiagnose: - def test_pending_step_creates_issue(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Pending", "runtime": ""}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": "ok\n", - "output/design.def": "def", - "analysis/CTS_metrics.json": '{"freq": 100}', - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "pending_step" in out - assert "pending" in out - - -class TestMissingRunJsonlKind: - def test_missing_run_jsonl_has_kind(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["status", "--jsonl", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - data = [json.loads(line) for line in out.strip().split("\n") if line.strip()] - assert data[0]["run"] == "default" - assert data[0]["status"] == "missing" - - -class TestLogErrorMatching: - def test_clean_summary_not_counted_as_error(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": ( - "CTS completed successfully\n" - "0 errors\n" - "No errors found\n" - "0 failed checks\n" - ), - "output/design.def": "def", - "analysis/CTS_metrics.json": '{"freq": 100}', - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "log_errors" not in out - - def test_real_errors_still_detected(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "CTS", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ], - ) - _create_step_dir( - run_dir, - "CTS", - "ecc", - subdirs=["log", "output", "analysis"], - files={ - "log/cts.log": ( - "CTS completed\n" - "Error: bad thing\n" - "Traceback (most recent call):\n" - "0 errors\n" - ), - "output/design.def": "def", - "analysis/CTS_metrics.json": '{"freq": 100}', - }, - ) - _create_cts_workspace_config(run_dir) - - rc = cli_main.run(["diagnose", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "log_errors" in out - assert "count: 2" in out diff --git a/test/cli/test_cli_main.py b/test/cli/test_cli_main.py deleted file mode 100644 index a02b7544..00000000 --- a/test/cli/test_cli_main.py +++ /dev/null @@ -1,1911 +0,0 @@ -import json -import os -import re -from types import SimpleNamespace - -from chipcompiler.cli import main as cli_main - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class DummyFlow: - has_init_value = False - run_steps_value = True - instances = [] - - def __init__(self, workspace): - self.workspace = workspace - self.added_steps = [] - self.create_called = False - self.run_called = False - self.workspace_steps = [] - DummyFlow.instances.append(self) - - def has_init(self): - return self.has_init_value - - def add_step(self, step, tool, state): - self.added_steps.append((step, tool, state)) - - def create_step_workspaces(self): - self.create_called = True - - def run_steps(self): - self.run_called = True - return self.run_steps_value - - def run_step(self, workspace_step): - from chipcompiler.data import StateEnum - - self.run_called = True - return StateEnum.Success if self.run_steps_value else StateEnum.Imcomplete - - -def _install_flow_mocks(monkeypatch): - capture = {"create_kwargs": None} - workspace_obj = SimpleNamespace(name="workspace") - - DummyFlow.instances = [] - DummyFlow.has_init_value = False - DummyFlow.run_steps_value = True - - def fake_create_workspace(**kwargs): - capture["create_kwargs"] = kwargs - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) - monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - - return capture - - -def _create_valid_project(tmp_path, name="gcd", pdk_root=None): - project_dir = tmp_path / name - project_dir.mkdir(exist_ok=True) - (project_dir / "rtl").mkdir(exist_ok=True) - (project_dir / "constraints").mkdir(exist_ok=True) - (project_dir / "runs").mkdir(exist_ok=True) - - rtl_file = project_dir / "rtl" / "gcd.v" - rtl_file.write_text("module gcd(input clk); endmodule\n") - - if pdk_root is None: - pdk_root = tmp_path / "ics55" - pdk_root.mkdir(exist_ok=True) - - toml = f'''[design] -name = "{name}" -top = "{name}" -rtl = ["rtl/gcd.v"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - return str(project_dir) - - -def _create_flow_json(run_dir, steps=None): - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - if steps is None: - steps = [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:18"}, - {"name": "Floorplan", "tool": "ecc", "state": "Success", "runtime": "0:00:04"}, - ] - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": steps}, f) - - -def _has_disclosure(line): - return bool( - re.search(r"ecc (?:check|run|status|log|metrics|artifacts|config|diagnose|param)\b", line) - ) - - -def _is_structural_line(line): - s = line.strip() - if not s: - return True - if re.match(r"^\[.+\]$", s): - return True - if s.startswith("steps:"): - return True - return bool(re.match(r"^\s+\w+:$", s)) - - -# =========================================================================== -# AC-1: ecc init -# =========================================================================== - - -class TestInit: - def test_init_creates_skeleton(self, tmp_path): - project_path = str(tmp_path / "gcd") - rc = cli_main.run(["init", project_path]) - assert rc == 0 - - assert (tmp_path / "gcd" / "ecc.toml").exists() - assert (tmp_path / "gcd" / "rtl").is_dir() - assert (tmp_path / "gcd" / "constraints").is_dir() - assert (tmp_path / "gcd" / "runs").is_dir() - - def test_init_output_has_disclosure_commands(self, tmp_path, capsys): - project_path = str(tmp_path / "myproj") - rc = cli_main.run(["init", project_path]) - assert rc == 0 - out = capsys.readouterr().out - assert "ecc check" in out - assert "ecc run" in out - - def test_init_fails_if_ecc_toml_exists(self, tmp_path): - project_dir = tmp_path / "gcd" - project_dir.mkdir() - (project_dir / "ecc.toml").write_text("[design]\n") - rc = cli_main.run(["init", str(project_dir)]) - assert rc == 1 - - def test_init_rejects_empty_name(self): - rc = cli_main.run(["init", ""]) - assert rc == 1 - - def test_init_uses_basename_for_design_name(self, tmp_path): - project_path = str(tmp_path / "subdir" / "mydesign") - rc = cli_main.run(["init", project_path]) - assert rc == 0 - toml = (tmp_path / "subdir" / "mydesign" / "ecc.toml").read_text() - assert 'name = "mydesign"' in toml - assert "rtl/mydesign.v" in toml - - -# =========================================================================== -# AC-2: ecc check -# =========================================================================== - - -class TestCheck: - def test_check_passes_valid_config(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "checked" in out - - def test_check_from_inside_project_dir(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.chdir(project_dir) - rc = cli_main.run(["check"]) - assert rc == 0 - out = capsys.readouterr().out - assert "checked" in out - - def test_check_fails_missing_ecc_toml(self, tmp_path): - rc = cli_main.run(["check", "--project", str(tmp_path)]) - assert rc == 1 - - def test_check_fails_malformed_toml(self, tmp_path, capsys): - project_dir = tmp_path / "bad" - project_dir.mkdir() - (project_dir / "ecc.toml").write_text("[design\ninvalid {{{") - rc = cli_main.run(["check", "--project", str(project_dir)]) - assert rc == 1 - - def test_check_fails_missing_rtl(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path, "w") as f: - f.write( - '[design]\nname="gcd"\ntop="gcd"\nrtl=["rtl/missing.v"]\n' - 'clock_port="clk"\nfrequency_mhz=100\n' - '[pdk]\nname="ics55"\nroot=""\n' - '[flow]\npreset="rtl2gds"\nrun="default"\n', - ) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_empty_pdk_root(self, tmp_path): - project_dir = _create_valid_project(tmp_path, pdk_root="") - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_non_directory_pdk_root(self, tmp_path): - pdk_root = tmp_path / "ics55.txt" - pdk_root.write_text("not a dir") - project_dir = _create_valid_project(tmp_path, pdk_root=str(pdk_root)) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_unsupported_pdk(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace('name = "ics55"', 'name = "unsupported"') - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_unsupported_preset(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace('preset = "rtl2gds"', 'preset = "unknown"') - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_non_positive_frequency(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace("frequency_mhz = 100.0", "frequency_mhz = -10") - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_multiple_rtl(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace( - 'rtl = ["rtl/gcd.v"]', - 'rtl = ["rtl/a.v", "rtl/b.v"]', - ) - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_fails_non_numeric_frequency(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace("frequency_mhz = 100.0", 'frequency_mhz = "fast"') - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 1 - - def test_check_json_output(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - assert "records" in data - assert data["records"][0]["status"] == "checked" - assert data["records"][0]["project"] == "gcd" - - -# =========================================================================== -# AC-3: ecc run -# =========================================================================== - - -class TestRun: - def test_run_calls_create_workspace(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - capture = _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 0 - assert capture["create_kwargs"]["directory"] == os.path.join(project_dir, "runs", "default") - - def test_run_adds_flow_steps_when_no_init(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 0 - assert len(DummyFlow.instances[0].added_steps) > 0 - - def test_run_calls_create_and_run(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 0 - assert DummyFlow.instances[0].create_called - assert DummyFlow.instances[0].run_called - - def test_run_overwrite_removes_existing(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir, "--overwrite"]) - assert rc == 0 - - def test_run_fails_if_flow_json_exists(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 1 - - def test_run_fails_on_config_error(self, tmp_path): - project_dir = tmp_path / "bad" - project_dir.mkdir() - (project_dir / "ecc.toml").write_text("[design]\n") - rc = cli_main.run(["run", "--project", str(project_dir)]) - assert rc == 1 - - def test_run_fails_when_create_workspace_returns_none(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - def fake_create(**kwargs): - return None - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 1 - - def test_run_fails_when_run_steps_false(self, tmp_path, monkeypatch): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - DummyFlow.run_steps_value = False - - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 1 - - def test_run_json_uses_non_progress_path(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir, "--json"]) - assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - assert "records" in data - assert data["records"][0]["status"] == "success" - assert DummyFlow.instances[0].run_called - - def test_run_jsonl_uses_non_progress_path(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir, "--jsonl"]) - assert rc == 0 - out = capsys.readouterr().out - objects = [json.loads(ln) for ln in out.strip().split("\n")] - assert any("status" in obj for obj in objects) - assert DummyFlow.instances[0].run_called - - def test_run_json_no_progress_on_stderr(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir, "--json"]) - assert rc == 0 - err = capsys.readouterr().err - assert "step=" not in err - - def test_run_preserves_final_records(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - _install_flow_mocks(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir, "--json"]) - assert rc == 0 - out = capsys.readouterr().out - data = json.loads(out) - record = data["records"][0] - assert record["run"] == "default" - assert record["status"] == "success" - assert "inspect_cmd" in record - assert "metrics_cmd" in record - assert "log_cmd" in record - - -# =========================================================================== -# AC-4: ecc status -# =========================================================================== - - -class TestStatus: - def test_status_reads_flow_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "[status]" in out - assert "synthesis" in out - assert "floorplan" in out - - def test_status_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - records = data["records"] - assert records[0]["run"] == "default" - assert records[0]["status"] == "success" - step_records = [r for r in records if "step" in r] - assert len(step_records) == 2 - - def test_status_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir, "--jsonl"]) - assert rc == 0 - lines = capsys.readouterr().out.strip().split("\n") - objects = [json.loads(ln) for ln in lines] - assert "run" in objects[0] - assert "step" in objects[1] - - def test_status_normalizes_step_names(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:18"}, - {"name": "place", "tool": "dreamplace", "state": "Success", "runtime": "0:01:12"}, - ], - ) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "synthesis" in out - assert "placement" in out - - def test_status_missing_run(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing" in out - assert "ecc run" in out - - def test_status_invalid_flow_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - f.write("not valid json{{{") - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 1 - - -# =========================================================================== -# AC-5: ecc log -# =========================================================================== - - -class TestLog: - def test_log_step_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Info: running\nError: bad thing\nWarning: meh\nTraceback: crash\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "Error: bad thing" in out - assert "Traceback: crash" in out - assert "Warning: meh" in out - assert "Info: running" in out - - def test_log_step_errors_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Info: running\nError: bad thing\n") - - rc = cli_main.run(["log", "synthesis", "--errors", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert any("Error" in obj["line"] for obj in objects) - - def test_log_no_step_shows_locations(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - log_dir = os.path.join(run_dir, "log") - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, "flow.log"), "w") as f: - f.write("log content\n") - - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "ecc log" in out - - def test_log_no_step_discovers_step_logs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\n") - - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "synthesis" in out - assert "Synthesis_yosys/log/synthesis.log" in out - assert "ecc log synthesis" in out - - def test_log_no_step_global_logs_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - log_dir = os.path.join(run_dir, "log") - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, "flow.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "ecc log" in out - - def test_log_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) - - rc = cli_main.run(["log", "nonexistent", "--project", project_dir]) - assert rc == 1 - - def test_log_missing_step_logs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 1 - - -# =========================================================================== -# AC-6: ecc metrics -# =========================================================================== - - -class TestMetrics: - def test_metrics_reads_step_metrics(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: - json.dump({"Cell number": 312, "Cell area": 1840.2}, f) - - rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "cell_number: 312" in out - - def test_metrics_all_steps(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - for step_dir_name in ["Synthesis_yosys", "Floorplan_ecc"]: - analysis = os.path.join(run_dir, step_dir_name, "analysis") - os.makedirs(analysis, exist_ok=True) - metrics_name = step_dir_name.split("_")[0] + "_metrics.json" - with open(os.path.join(analysis, metrics_name), "w") as f: - json.dump({"Cell number": 100}, f) - - rc = cli_main.run(["metrics", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "synthesis" in out - assert "floorplan" in out - - def test_metrics_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: - json.dump({"Cell number": 312}, f) - - rc = cli_main.run(["metrics", "synthesis", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert len(data["records"]) == 1 - assert data["records"][0]["metric"] == "cell_number" - - def test_metrics_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: - json.dump({"Cell number": 312, "Cell area": 1840.2}, f) - - rc = cli_main.run(["metrics", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert len(objects) == 2 - - def test_metrics_normalizes_known_keys(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - analysis_dir = os.path.join(run_dir, "CTS_ecc", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "CTS_metrics.json"), "w") as f: - json.dump({"Frequency [MHz]": 450.0, "Die area [μm^2]": "10000.000"}, f) - - rc = cli_main.run(["metrics", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "frequency_mhz: 450.0" in out - assert "die_area_um2" in out - - def test_metrics_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) - - rc = cli_main.run(["metrics", "nonexistent", "--project", project_dir]) - assert rc == 1 - - def test_metrics_missing_file(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "CTS_ecc", "analysis"), exist_ok=True) - - rc = cli_main.run(["metrics", "cts", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing" in out - assert "ecc log cts" in out - - def test_metrics_json_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) - - rc = cli_main.run(["metrics", "nonexistent", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "unknown_step" - assert data["records"][0]["step"] == "nonexistent" - - def test_metrics_json_missing_file(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "CTS_ecc", "analysis"), exist_ok=True) - - rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["status"] == "missing" - - def test_metrics_jsonl_unknown_step(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - os.makedirs(os.path.join(project_dir, "runs", "default"), exist_ok=True) - - rc = cli_main.run(["metrics", "nonexistent", "--jsonl", "--project", project_dir]) - assert rc == 1 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert objects[0]["status"] == "unknown_step" - - -# =========================================================================== -# AC-7: Disclosure commands on all output -# =========================================================================== - - -class TestDisclosureCommands: - def test_init_lines_have_disclosure(self, tmp_path, capsys): - project_path = str(tmp_path / "disctest") - rc = cli_main.run(["init", project_path]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_check_lines_have_disclosure(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - rc = cli_main.run(["check", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_status_lines_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_metrics_lines_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: - json.dump({"Cell number": 312}, f) - - rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert _has_disclosure(out) - - def test_log_error_lines_have_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: something failed\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "ecc log synthesis" in out - - def test_project_arg_propagated_to_disclosure(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert f"--project {project_dir}" in out - - def test_output_lowercase_tokens(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, - [ - {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "0:00:01"}, - ], - ) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "synthesis" in out - assert "success" in out - - -# =========================================================================== -# AC-8: Packaging -# =========================================================================== - - -class TestPackaging: - def test_ecc_console_script_in_pyproject(self): - import tomllib - - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - pyproject = os.path.join(project_root, "pyproject.toml") - with open(pyproject, "rb") as f: - data = tomllib.load(f) - assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main" - - -# =========================================================================== -# Edge cases -# =========================================================================== - - -class TestEdgeCases: - def test_no_command_returns_nonzero(self, capsys): - rc = cli_main.run([]) - assert rc == 1 - - -class TestCheckFilelistValidation: - def test_check_fails_filelist_with_missing_sources(self, tmp_path, monkeypatch): - from chipcompiler.cli.project.config import _validate_pdk_contents - - monkeypatch.setattr( - _validate_pdk_contents, "__wrapped__", lambda *a, **k: None, raising=False - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", lambda *a, **k: None - ) - - project_dir = tmp_path / "flproj" - project_dir.mkdir() - (project_dir / "rtl").mkdir() - (project_dir / "rtl" / "gcd.v").write_text("module gcd; endmodule") - - filelist = project_dir / "rtl" / "files.f" - filelist.write_text("gcd.v\nmissing.v\nother_missing.v\n") - - pdk_root = tmp_path / "ics55" - pdk_root.mkdir() - - toml = f'''[design] -name = "gcd" -top = "gcd" -rtl = ["rtl/files.f"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["check", "--project", str(project_dir)]) - assert rc == 1 - - def test_check_fails_invalid_filelist_directive(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", lambda *a, **k: None - ) - - project_dir = tmp_path / "flproj2" - project_dir.mkdir() - (project_dir / "rtl").mkdir() - - filelist = project_dir / "rtl" / "files.f" - filelist.write_text("gcd.v\n-f other.f\n") - - pdk_root = tmp_path / "ics55" - pdk_root.mkdir() - - toml = f'''[design] -name = "gcd" -top = "gcd" -rtl = ["rtl/files.f"] -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - rc = cli_main.run(["check", "--project", str(project_dir)]) - assert rc == 1 - - -class TestRendererCmdStripping: - def test_text_strips_cmd_suffix(self): - from io import StringIO - - from chipcompiler.cli.rendering.render import render_text - - buf = StringIO() - render_text(({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},), file=buf) - line = buf.getvalue().strip() - assert "inspect=" in line - assert "log=" in line - assert "inspect_cmd=" not in line - assert "log_cmd=" not in line - - def test_json_preserves_cmd_keys(self): - from io import StringIO - - from chipcompiler.cli.core.types import CommandResult - from chipcompiler.cli.rendering.render import render_json - - buf = StringIO() - result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) - render_json(result, file=buf) - data = json.loads(buf.getvalue()) - assert "inspect_cmd" in data["records"][0] - assert "log_cmd" in data["records"][0] - - def test_jsonl_preserves_cmd_keys(self): - from io import StringIO - - from chipcompiler.cli.core.types import CommandResult - from chipcompiler.cli.rendering.render import render_jsonl - - buf = StringIO() - result = CommandResult(records=({"inspect_cmd": "ecc status", "log_cmd": "ecc log"},)) - render_jsonl(result, file=buf) - record = json.loads(buf.getvalue().strip()) - assert "inspect_cmd" in record - assert "log_cmd" in record - - -class TestMissingConfigErrorRecord: - def test_check_missing_config_has_kind_error_json(self, tmp_path, capsys): - rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["kind"] == "error" - assert record["error"] == "missing_config" - - def test_check_missing_config_has_kind_error_text(self, tmp_path, capsys): - rc = cli_main.run(["check", "--project", str(tmp_path)]) - assert rc == 1 - out = capsys.readouterr().out - assert "[error]" in out - assert "missing_config" in out - - def test_check_missing_config_has_disclosure_command(self, tmp_path, capsys): - rc = cli_main.run(["check", "--project", str(tmp_path), "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert "inspect" in record or "inspect_cmd" in record - - -# =========================================================================== -# Log output refactoring integration tests -# =========================================================================== - - -class TestLogDefaultShowsAllContent: - """AC-1: Default ecc log renders complete log content.""" - - def test_default_shows_all_lines(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("INFO: starting\nsome output\nError: bad\nWarning: meh\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "INFO: starting" in out - assert "some output" in out - assert "Error: bad" in out - assert "Warning: meh" in out - - def test_default_includes_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("ok\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "[log]" in out - assert "step=synthesis" in out - assert "source:" in out - - def test_blank_lines_preserved(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line1\n\nline3\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "line1" in out - assert "line3" in out - - -class TestLogTracebackComplete: - """AC-2: Python traceback blocks remain complete and contiguous.""" - - def test_traceback_complete_in_default_output(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write( - "INFO: before\n" - "Traceback (most recent call last):\n" - ' File "app.py", line 42, in run\n' - " result = compute()\n" - " ^^^^^^^^^\n" - "ValueError: invalid value\n" - "INFO: after\n" - ) - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "Traceback (most recent call last):" in out - assert 'File "app.py", line 42' in out - assert "result = compute()" in out - assert "^^^^^^^^^" in out - assert "ValueError: invalid value" in out - - def test_traceback_complete_in_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write('Traceback (most recent call last):\n File "a.py", line 1\nValueError: fail\n') - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert objects[0]["kind"] == "traceback" - assert objects[1]["kind"] == "traceback" - assert objects[2]["kind"] == "error" - - def test_keyboard_interrupt_jsonl_classified_as_error(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write( - 'Traceback (most recent call last):\n File "a.py", line 1\nKeyboardInterrupt\n' - ) - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert objects[0]["kind"] == "traceback" - assert objects[1]["kind"] == "traceback" - assert objects[2]["kind"] == "error" - assert objects[2]["line"] == "KeyboardInterrupt" - - -class TestLogPlainMode: - """AC-5: --plain emits full-content stable line records.""" - - def test_plain_has_all_fields(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\nINFO: ok\n") - - rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - lines = [line for line in out.strip().split("\n") if line.strip()] - assert len(lines) == 2 - assert "step=synthesis" in lines[0] - assert "line_no=1" in lines[0] - assert "kind=error" in lines[0] - assert "line_no=2" in lines[1] - assert "kind=info" in lines[1] - - def test_plain_no_ansi(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\n") - - rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "\x1b[" not in out - - def test_plain_stable_quoting_for_special_chars(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write('key=value path\\to\\file "quoted text"\n') - - rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "\x1b[" not in out - lines = [line for line in out.strip().split("\n") if line.strip()] - assert len(lines) == 1 - assert 'line="key=value' in lines[0] - assert "inspect_cmd=" in lines[0] - - -class TestLogJsonlMode: - """AC-6: --jsonl emits full-content structured log objects.""" - - def test_jsonl_per_line_objects(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\nINFO: ok\nplain\n") - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert len(objects) == 3 - for obj in objects: - assert "step" in obj - assert "source" in obj - assert "line_no" in obj - assert "kind" in obj - assert "line" in obj - assert "inspect_cmd" in obj - - def test_jsonl_no_ansi(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\n") - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "\x1b[" not in out - - -class TestLogJsonMode: - """ecc log --json must produce JSON envelope output.""" - - def test_json_step_output(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("Error: bad\nINFO: ok\n") - - rc = cli_main.run(["log", "synthesis", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert len(data["records"]) == 2 - - def test_json_listing_output(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - - -class TestLogListingMode: - """AC-7: ecc log without step lists available logs.""" - - def test_listing_shows_logs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "synthesis" in out - assert "ecc log synthesis" in out - - def test_listing_no_logs_returns_no_log_status(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "no_logs" in out - - def test_listing_jsonl_records(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - assert any("step" in o for o in objects) - - def test_listing_plain_step_logs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("content\n") - - rc = cli_main.run(["log", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "step=synthesis" in out - assert "source=" in out - assert "inspect_cmd=" in out - assert "line_no=" not in out - - def test_listing_plain_run_level_logs(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - log_dir = os.path.join(run_dir, "log") - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, "flow.log"), "w") as f: - f.write("log content\n") - - rc = cli_main.run(["log", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "log=" in out - assert "inspect_cmd=" in out - assert "line_no=" not in out - assert "kind=" not in out - - -class TestLogErrorCases: - """AC-9: Error cases are structured and readable.""" - - def test_unknown_step_returns_nonzero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["log", "nonexistent", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "unknown_step" in out - - def test_unknown_step_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - - rc = cli_main.run(["log", "nonexistent", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["status"] == "unknown_step" - - def test_known_step_no_logs_returns_nonzero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "missing" in out - - def test_known_step_no_logs_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "Synthesis_yosys"), exist_ok=True) - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["log_status"] == "missing" - - def test_empty_log_returns_zero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "empty" in out - - -class TestLogNoErrorsInDisclosure: - """AC-8: Disclosure commands do not include --errors.""" - - def test_listing_disclosure_no_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("ok\n") - - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--errors" not in out - - def test_step_log_inspect_no_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("ok\n") - - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--errors" not in out - - def test_status_disclosure_no_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - - rc = cli_main.run(["status", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--errors" not in out - - def test_metrics_disclosure_no_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - analysis_dir = os.path.join(run_dir, "Synthesis_yosys", "analysis") - os.makedirs(analysis_dir, exist_ok=True) - with open(os.path.join(analysis_dir, "Synthesis_metrics.json"), "w") as f: - json.dump({"Cell number": 100}, f) - - rc = cli_main.run(["metrics", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--errors" not in out - - def test_artifacts_log_disclosure_no_errors(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json(run_dir) - log_dir = os.path.join(run_dir, "CTS_ecc", "log") - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, "cts.log"), "w") as f: - f.write("log content\n") - - rc = cli_main.run(["artifacts", "cts", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "--errors" not in out - - -class TestLogUnreadableFile: - """AC-9: Unreadable log files return non-zero with OS error.""" - - def test_unreadable_log_returns_nonzero(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("content\n") - os.chmod(log_path, 0o000) - - try: - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 1 - out = capsys.readouterr().out - assert "unreadable" in out - finally: - os.chmod(log_path, 0o644) - - def test_unreadable_log_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("content\n") - os.chmod(log_path, 0o000) - - try: - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 1 - record = json.loads(capsys.readouterr().out.strip()) - assert record["log_status"] == "unreadable" - assert "source" in record - assert "error" in record - finally: - os.chmod(log_path, 0o644) - - -class TestLogMultiSource: - """AC-1: Multiple log files per step shown with separate source headers.""" - - def test_multi_source_pretty(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "a.log"), "w") as f: - f.write("from A\n") - with open(os.path.join(step_dir, "b.log"), "w") as f: - f.write("from B\n") - - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "a.log" in out - assert "b.log" in out - assert "from A" in out - assert "from B" in out - - -class TestLogErrorsDeprecation: - """AC-8: --errors is deprecated with visible notice.""" - - def test_errors_hidden_from_help(self, tmp_path, capsys): - rc = cli_main.run(["log", "--help"]) - assert rc == 0 - assert "--errors" not in capsys.readouterr().out - - def test_errors_emits_deprecation_warning(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("ok\n") - - rc = cli_main.run(["log", "synthesis", "--errors", "--project", project_dir]) - assert rc == 0 - err = capsys.readouterr().err - assert "deprecated" in err - - def test_errors_jsonl_still_full_records(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("INFO: running\nError: bad\n") - - rc = cli_main.run(["log", "synthesis", "--errors", "--jsonl", "--project", project_dir]) - assert rc == 0 - objects = [json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n")] - assert len(objects) == 2 - assert objects[0]["kind"] == "info" - assert objects[1]["kind"] == "error" - assert "\x1b[" not in capsys.readouterr().out - - -class TestCorruptFlowJson: - """Non-dict flow.json must be reported as corrupt, not missing.""" - - def test_array_flow_json_is_corrupt(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump([], f) - - rc = cli_main.run(["status", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("status") == "corrupt" - - def test_string_flow_json_is_corrupt(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump("bad", f) - - rc = cli_main.run(["status", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("status") == "corrupt" - - -class TestFlowOnlyStepMetrics: - """Step in flow.json but no step directory should report missing, not unknown.""" - - def test_metrics_flow_only_step_is_missing(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": [{"name": "CTS", "state": "unstart"}]}, f) - - rc = cli_main.run(["metrics", "cts", "--json", "--project", project_dir]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("status") == "missing" - assert data["records"][0].get("status") != "unknown_step" - - -class TestLogListingFlowOrder: - """Listing step logs follow flow.json order, not alphabetical.""" - - def _setup_steps_with_flow(self, tmp_path, step_names, extra_dirs=None): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - _create_flow_json( - run_dir, steps=[{"name": n, "tool": "ecc", "state": "Success"} for n in step_names] - ) - all_dirs = list(step_names) + (extra_dirs or []) - tool_map = { - "Synthesis": "yosys", - "Floorplan": "ecc", - "fixFanout": "ecc", - "place": "ecc", - "CTS": "ecc", - "legalization": "ecc", - "route": "ecc", - "drc": "ecc", - "filler": "ecc", - } - for name in all_dirs: - tool = tool_map.get(name, "ecc") - step_dir = os.path.join(run_dir, f"{name}_{tool}", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, f"{name.lower()}.log"), "w") as f: - f.write(f"log from {name}\n") - return project_dir - - def test_steps_follow_flow_json_order(self, tmp_path, capsys): - project_dir = self._setup_steps_with_flow( - tmp_path, - ["Synthesis", "Floorplan", "CTS"], - ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - steps = [r.get("step") for r in records if "step" in r] - assert steps == ["synthesis", "floorplan", "cts"] - - def test_run_level_logs_before_step_logs(self, tmp_path, capsys): - project_dir = self._setup_steps_with_flow( - tmp_path, - ["Synthesis", "CTS"], - ) - run_dir = os.path.join(project_dir, "runs", "default") - log_dir = os.path.join(run_dir, "log") - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, "flow.log"), "w") as f: - f.write("run-level log\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - run_indices = [i for i, r in enumerate(records) if "log" in r and "step" not in r] - step_indices = [i for i, r in enumerate(records) if "step" in r] - assert run_indices, "expected at least one run-level record" - assert step_indices, "expected at least one step record" - assert max(run_indices) < min(step_indices) - - def test_extra_steps_after_flow_steps(self, tmp_path, capsys): - project_dir = self._setup_steps_with_flow( - tmp_path, - ["Synthesis", "CTS"], - extra_dirs=["Floorplan"], - ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - steps = [r.get("step") for r in records if "step" in r] - synth_idx = steps.index("synthesis") - cts_idx = steps.index("cts") - fp_idx = steps.index("floorplan") - assert synth_idx < cts_idx - assert cts_idx < fp_idx - - def test_extra_steps_sorted_alphabetically(self, tmp_path, capsys): - project_dir = self._setup_steps_with_flow( - tmp_path, - ["Synthesis"], - extra_dirs=["Floorplan", "CTS"], - ) - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - steps = [r.get("step") for r in records if "step" in r] - extras = [s for s in steps if s != "synthesis"] - assert extras == sorted(extras) - - def test_missing_flow_json_falls_back_to_alphabetical(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(run_dir, exist_ok=True) - for name in ["CTS_ecc", "Floorplan_ecc", "Synthesis_yosys"]: - step_dir = os.path.join(run_dir, name, "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "test.log"), "w") as f: - f.write("content\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - steps = [r.get("step") for r in records if "step" in r] - assert steps == sorted(steps) - - def test_corrupt_flow_json_falls_back_to_alphabetical(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - f.write("not valid json{{{") - for name in ["CTS_ecc", "Floorplan_ecc", "Synthesis_yosys"]: - step_dir = os.path.join(run_dir, name, "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "test.log"), "w") as f: - f.write("content\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - steps = [r.get("step") for r in records if "step" in r] - assert steps == sorted(steps) - - -class TestLogListingTailPreview: - """Tail preview shows up to 10 lines in default pretty text mode.""" - - def test_listing_shows_tail_lines(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - lines = [f"log line {i}" for i in range(15)] - with open(log_path, "w") as f: - f.write("\n".join(lines) + "\n") - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "log line 14" in out - assert "tail:" in out - - def test_listing_tail_max_10_lines(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - lines = [f"line {i}" for i in range(20)] - with open(log_path, "w") as f: - f.write("\n".join(lines) + "\n") - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - output_lines = out.split("\n") - tail_header_idx = next( - index for index, line in enumerate(output_lines) if line.strip() == "tail:" - ) - tail_content = [ - line - for line in output_lines[tail_header_idx + 1 :] - if line.startswith(" ") and "inspect:" not in line - ] - assert len(tail_content) == 10 - - def test_empty_log_no_tail_block(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("") - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "tail:" not in out - assert "inspect:" in out - - def test_inspect_visible_below_tail(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("content line\n") - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - tail_pos = out.find("tail:") - inspect_pos = out.find("inspect:") - assert tail_pos < inspect_pos - - -class TestLogListingMachineModeNoTail: - """Machine modes must not include tail data.""" - - def test_plain_no_tail(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line 1\nline 2\nline 3\n") - rc = cli_main.run(["log", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "tail=" not in out - - def test_json_no_tail(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line 1\nline 2\n") - rc = cli_main.run(["log", "--json", "--project", project_dir]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - for rec in data["records"]: - assert "tail" not in rec - - def test_jsonl_no_tail(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("line 1\nline 2\n") - rc = cli_main.run(["log", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - for rec in records: - assert "tail" not in rec - - -class TestLogStepUnchanged: - """ecc log full output must remain unchanged.""" - - def test_step_shows_all_lines_not_tail(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - lines = [f"line {i}" for i in range(20)] - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("\n".join(lines) + "\n") - rc = cli_main.run(["log", "synthesis", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "line 0" in out - assert "line 19" in out - - def test_step_plain_unchanged(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("a\nb\nc\n") - rc = cli_main.run(["log", "synthesis", "--plain", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "line_no=1" in out - assert "line_no=2" in out - assert "line_no=3" in out - assert "tail" not in out - - def test_step_jsonl_unchanged(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - with open(os.path.join(step_dir, "synthesis.log"), "w") as f: - f.write("a\nb\n") - rc = cli_main.run(["log", "synthesis", "--jsonl", "--project", project_dir]) - assert rc == 0 - records = [ - json.loads(ln) for ln in capsys.readouterr().out.strip().split("\n") if ln.strip() - ] - assert len(records) == 2 - for rec in records: - assert "tail" not in rec - - -class TestLogListingUnreadable: - """Unreadable logs in listing mode must omit tail, keep path+inspect, no traceback.""" - - def test_unreadable_step_log_in_listing(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - run_dir = os.path.join(project_dir, "runs", "default") - step_dir = os.path.join(run_dir, "Synthesis_yosys", "log") - os.makedirs(step_dir, exist_ok=True) - log_path = os.path.join(step_dir, "synthesis.log") - with open(log_path, "w") as f: - f.write("content\n") - os.chmod(log_path, 0o000) - - try: - rc = cli_main.run(["log", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "tail:" not in out - assert "Synthesis_yosys" in out - assert "inspect:" in out - assert "Traceback" not in out - finally: - os.chmod(log_path, 0o644) diff --git a/test/cli/test_cli_params.py b/test/cli/test_cli_params.py deleted file mode 100644 index a46a157a..00000000 --- a/test/cli/test_cli_params.py +++ /dev/null @@ -1,1326 +0,0 @@ -import json -import os - -from chipcompiler.cli import main as cli_main - - -def _create_valid_project(tmp_path, name="gcd", pdk_root=None, freq=100.0): - project_dir = tmp_path / name - project_dir.mkdir(exist_ok=True) - (project_dir / "rtl").mkdir(exist_ok=True) - (project_dir / "constraints").mkdir(exist_ok=True) - (project_dir / "runs").mkdir(exist_ok=True) - - rtl_file = project_dir / "rtl" / "gcd.v" - rtl_file.write_text("module gcd(input clk); endmodule\n") - - if pdk_root is None: - pdk_root = tmp_path / "ics55" - pdk_root.mkdir(exist_ok=True) - - toml = f'''[design] -name = "{name}" -top = "{name}" -rtl = ["rtl/gcd.v"] -clock_port = "clk" -frequency_mhz = {freq} - -[pdk] -name = "ics55" -root = "{pdk_root}" - -[flow] -preset = "rtl2gds" -run = "default" -''' - (project_dir / "ecc.toml").write_text(toml) - return str(project_dir) - - -class TestParamList: - def test_param_list_text_output(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "place.target_density" in out - - def test_param_list_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - params = [r["param"] for r in data["records"]] - assert "place.target_density" in params - - def test_param_list_jsonl(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) - assert rc == 0 - lines = capsys.readouterr().out.strip().split("\n") - objects = [json.loads(ln) for ln in lines] - assert len(objects) == 12 - - def test_param_list_plain(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - assert "place.target_density" in out - - -class TestParamShow: - def test_param_show_known_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "place.target_density" in out - - def test_param_show_json(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["param"] == "place.target_density" - assert record["default"] == 0.2 - assert "source" in record - assert "maps_to" in record - - def test_param_show_unknown_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "show", "unknown.key", "--project", project_dir]) - assert rc == 1 - - -class TestParamSet: - def test_param_set_writes_toml(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - ["param", "set", "place.target_density", "0.65", "--project", project_dir] - ) - assert rc == 0 - - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - assert "target_density" in content - assert "0.65" in content - - def test_param_set_then_show(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() # flush set output - - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["value"] == 0.65 - assert record["source"] == "ecc.toml" - - def test_param_set_rejects_unknown_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "set", "bogus.key", "5", "--project", project_dir]) - assert rc == 1 - - def test_param_set_rejects_invalid_value(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "set", "place.target_density", "1.5", "--project", project_dir]) - assert rc == 1 - - def test_param_set_preserves_other_sections(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) - - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - assert "[design]" in content - assert "[pdk]" in content - assert "[flow]" in content - - -class TestParamUnset: - def test_param_unset_removes_override(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() # flush set output - - rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) - assert rc == 0 - capsys.readouterr() # flush unset output - - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert record["source"] == "default" - - def test_param_unset_noop_when_absent(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "no override" in out - - -class TestParamDiff: - def test_param_diff_shows_overrides(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() # flush set output - - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] - assert len(records) == 1 - assert records[0]["param"] == "place.target_density" - - def test_param_diff_clean_when_no_overrides(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" - - -class TestRunSet: - def test_run_set_override(self, tmp_path, monkeypatch, capsys): - from types import SimpleNamespace - - project_dir = _create_valid_project(tmp_path) - workspace_obj = SimpleNamespace(name="workspace") - capture = {"kwargs": None} - - def fake_create(**kwargs): - capture["kwargs"] = kwargs - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - monkeypatch.setattr( - "chipcompiler.engine.EngineFlow", - type( - "DummyFlow", - (), - { - "__init__": lambda self, workspace: None, - "has_init": lambda self: False, - "add_step": lambda self, **kw: None, - "create_step_workspaces": lambda self: None, - "run_steps": lambda self: True, - }, - ), - ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.setattr( - "chipcompiler.cli.rendering.progress.should_enable_run_progress", - lambda *a, **kw: False, - ) - - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "place.target_density=0.65", - ] - ) - assert rc == 0 - - params = capture["kwargs"]["parameters"] - assert params.get("DreamPlace", {}).get("target_density") == 0.65 - - def test_run_set_rejects_unknown_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "bogus.key=5", - ] - ) - assert rc == 1 - - def test_run_set_rejects_invalid_value(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "place.target_density=1.5", - ] - ) - assert rc == 1 - - def test_run_set_does_not_modify_toml(self, tmp_path, monkeypatch, capsys): - from types import SimpleNamespace - - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - original_toml = f.read() - - workspace_obj = SimpleNamespace(name="workspace") - - def fake_create(**kwargs): - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - monkeypatch.setattr( - "chipcompiler.engine.EngineFlow", - type( - "DummyFlow", - (), - { - "__init__": lambda self, workspace: None, - "has_init": lambda self: False, - "add_step": lambda self, **kw: None, - "create_step_workspaces": lambda self: None, - "run_steps": lambda self: True, - }, - ), - ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.setattr( - "chipcompiler.cli.rendering.progress.should_enable_run_progress", - lambda *a, **kw: False, - ) - - cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "place.target_density=0.65", - ] - ) - - with open(toml_path) as f: - current_toml = f.read() - assert current_toml == original_toml - - -class TestOutputContracts: - def test_plain_no_ansi(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - - def test_json_no_ansi(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - - def test_jsonl_no_ansi(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--jsonl"]) - assert rc == 0 - out = capsys.readouterr().out - assert "\033[" not in out - - def test_json_uses_records_envelope(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert "records" in data - assert isinstance(data["records"], list) - - def test_plain_is_line_oriented(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) - assert rc == 0 - out = capsys.readouterr().out - lines = [line for line in out.strip().split("\n") if line.strip()] - assert len(lines) == 12 - - -class TestConfigResolved: - def test_config_resolved_includes_param_records(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": []}, f) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] - param_records = [r for r in records if r.get("kind") == "param"] - assert len(param_records) == 12 - first_param = param_records[0] - assert "source" in first_param - assert "maps_to" in first_param - - def test_config_resolved_shows_toml_source(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() # flush set output - - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": []}, f) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] - density = next(r for r in param_records if r["key"] == "place.target_density") - assert density["value"] == 0.65 - assert density["source"] == "ecc.toml" - - def test_config_resolved_seeds_design_frequency(self, tmp_path, monkeypatch, capsys): - project_dir = _create_valid_project(tmp_path, freq=200.0) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - run_dir = os.path.join(project_dir, "runs", "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump({"steps": []}, f) - - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] - freq = next(r for r in param_records if r["key"] == "design.frequency_mhz") - assert freq["value"] == 200.0 - - -class TestTomlValidationErrors: - def _create_project_with_invalid_param(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += '\n[params.synth]\nmax_fanout = "not_an_int"\n' - with open(toml_path, "w") as f: - f.write(content) - return project_dir - - def test_check_fails_invalid_param_type(self, tmp_path, capsys): - project_dir = self._create_project_with_invalid_param(tmp_path) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - reasons = [r.get("reason", "") for r in data["records"]] - assert any("params" in r for r in reasons) - - def test_check_fails_unknown_param_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.bogus]\nkey = 5\n" - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 1 - - def test_run_fails_invalid_param_type(self, tmp_path): - project_dir = self._create_project_with_invalid_param(tmp_path) - rc = cli_main.run(["run", "--project", project_dir]) - assert rc == 1 - - -class TestPrettyOutput: - def test_param_list_default_is_grouped_text(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "place" in out - assert "place.target_density" in out - - def test_param_list_plain_is_one_line_per_record(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"]) - assert rc == 0 - out = capsys.readouterr().out - lines = [line for line in out.strip().split("\n") if line.strip()] - assert len(lines) == 12 - assert "\033[" not in out - - def test_param_show_default_is_pretty(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "place.target_density" in out - assert "source" in out - assert "default" in out - - def test_param_set_default_is_pretty(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - ["param", "set", "place.target_density", "0.65", "--project", project_dir] - ) - assert rc == 0 - out = capsys.readouterr().out - assert "0.65" in out - - def test_param_diff_default_is_pretty(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() - rc = cli_main.run(["param", "diff", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "place.target_density" in out - - -class TestResolvedListValues: - def test_param_list_json_has_value_and_source(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] - density = next(r for r in records if r["param"] == "place.target_density") - assert density["value"] == 0.65 - assert density["source"] == "ecc.toml" - assert "default" in density - assert "maps_to" in density - assert "inspect" in density - - def test_param_list_default_source_when_no_overrides(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - for r in data["records"]: - if r["param"] == "design.frequency_mhz": - assert r["source"] == "ecc.toml" - else: - assert r["source"] == "default" - - -class TestDiffFiltering: - def test_diff_only_shows_values_that_differ(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - records = data["records"] - assert len(records) == 1 - assert records[0]["param"] == "place.target_density" - assert records[0]["value"] == 0.65 - assert records[0]["default"] != 0.65 - - def test_diff_clean_when_set_to_default(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - schema_default = 0.2 - cli_main.run( - ["param", "set", "place.target_density", str(schema_default), "--project", project_dir] - ) - capsys.readouterr() - - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" - - -class TestScopedTomlEdit: - def test_set_preserves_unrelated_sections(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - original = f.read() - - cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - design_section = original[original.index("[design]") : original.index("[pdk]")] - assert design_section in after - - def test_set_preserves_comments(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace("[design]", "[design]\n# my design") - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "# my design" in after - - def test_set_same_key_twice_has_one_assignment(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - content = f.read() - assert content.count("target_density") == 1 - assert "0.7" in content - assert "0.65" not in content - - def test_set_then_show_still_works(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - - cli_main.run(["param", "set", "place.target_density", "0.65", "--project", project_dir]) - capsys.readouterr() - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 - - -class TestNativeTomlTypeValidation: - def test_check_rejects_float_for_int(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.synth]\nmax_fanout = 16.5\n" - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 1 - - def test_check_rejects_bool_for_int(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.synth]\nmax_fanout = true\n" - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 1 - - def test_check_rejects_float_in_list_int(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.floorplan]\ncore_margin = [2.5, 3]\n" - with open(toml_path, "w") as f: - f.write(content) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 1 - - def test_check_accepts_valid_int(self, tmp_path, capsys, monkeypatch): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.synth]\nmax_fanout = 16\n" - with open(toml_path, "w") as f: - f.write(content) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda pdk_root, pdk_name: [], - ) - rc = cli_main.run(["check", "--project", project_dir, "--json"]) - assert rc == 0 - - -class TestCliProvenance: - def test_run_set_reports_cli_source_in_config(self, tmp_path, monkeypatch, capsys): - from types import SimpleNamespace - - project_dir = _create_valid_project(tmp_path) - workspace_obj = SimpleNamespace(name="workspace") - - def fake_create(**kwargs): - run_dir = kwargs["directory"] - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - monkeypatch.setattr( - "chipcompiler.engine.EngineFlow", - type( - "DummyFlow", - (), - { - "__init__": lambda self, workspace: None, - "has_init": lambda self: False, - "add_step": lambda self, **kw: None, - "create_step_workspaces": lambda self: None, - "run_steps": lambda self: True, - }, - ), - ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.setattr( - "chipcompiler.cli.rendering.progress.should_enable_run_progress", - lambda *a, **kw: False, - ) - - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "synth.max_fanout=16", - ] - ) - assert rc == 0 - capsys.readouterr() - - # Verify provenance file was written - provenance = os.path.join( - project_dir, "runs", "default", "home", "cli-param-overrides.json" - ) - assert os.path.isfile(provenance) - with open(provenance) as f: - data = json.load(f) - assert data["synth.max_fanout"] == 16 - - def test_config_resolved_shows_cli_source(self, tmp_path, monkeypatch, capsys): - from types import SimpleNamespace - - project_dir = _create_valid_project(tmp_path) - workspace_obj = SimpleNamespace(name="workspace") - - def fake_create(**kwargs): - run_dir = kwargs["directory"] - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - monkeypatch.setattr( - "chipcompiler.engine.EngineFlow", - type( - "DummyFlow", - (), - { - "__init__": lambda self, workspace: None, - "has_init": lambda self: False, - "add_step": lambda self, **kw: None, - "create_step_workspaces": lambda self: None, - "run_steps": lambda self: True, - }, - ), - ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.setattr( - "chipcompiler.cli.rendering.progress.should_enable_run_progress", - lambda *a, **kw: False, - ) - - # Run with --set - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "synth.max_fanout=16", - ] - ) - assert rc == 0 - capsys.readouterr() - - # Now inspect config --resolved - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] - fanout = next(r for r in param_records if r["key"] == "synth.max_fanout") - assert fanout["value"] == 16 - assert fanout["source"] == "cli" - - def test_config_resolved_toml_plus_cli_precedence(self, tmp_path, monkeypatch, capsys): - from types import SimpleNamespace - - project_dir = _create_valid_project(tmp_path) - workspace_obj = SimpleNamespace(name="workspace") - - # Set a TOML override first - cli_main.run(["param", "set", "synth.max_fanout", "16", "--project", project_dir]) - capsys.readouterr() - - def fake_create(**kwargs): - run_dir = kwargs["directory"] - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - return workspace_obj - - monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create) - monkeypatch.setattr( - "chipcompiler.engine.EngineFlow", - type( - "DummyFlow", - (), - { - "__init__": lambda self, workspace: None, - "has_init": lambda self: False, - "add_step": lambda self, **kw: None, - "create_step_workspaces": lambda self: None, - "run_steps": lambda self: True, - }, - ), - ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], - ) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda name, root: None, - ) - monkeypatch.setattr( - "chipcompiler.cli.rendering.progress.should_enable_run_progress", - lambda *a, **kw: False, - ) - - # Run with different CLI override - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "synth.max_fanout=32", - ] - ) - assert rc == 0 - capsys.readouterr() - - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - param_records = [r for r in data["records"] if r.get("kind") == "param"] - fanout = next(r for r in param_records if r["key"] == "synth.max_fanout") - assert fanout["value"] == 32 - assert fanout["source"] == "cli" - - -class TestParamHandlersRejectInvalidToml: - """Param list/show/diff must return errors when ecc.toml has invalid [params.*].""" - - def _write_invalid_toml(self, project_dir): - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.synth]\nmax_fanout = 16.5\n" - with open(toml_path, "w") as f: - f.write(content) - - def test_param_list_rejects_invalid_toml(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" - - def test_param_show_rejects_invalid_toml(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "show", "synth.max_fanout", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" - - def test_param_diff_rejects_invalid_toml(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_invalid_toml(project_dir) - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" - - -class TestIndentedTomlKeys: - """Scoped TOML edit must handle indented assignment lines.""" - - def test_set_replaces_indented_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.place]\n target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert after.count("target_density") == 1 - assert "0.7" in after - - def test_set_then_show_indented(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.place]\n target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 - - def test_unset_removes_indented_key(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.place]\n target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "target_density" not in after - - def test_set_indented_preserves_other_sections(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += '\n[params.place]\n target_density = 0.65\n\n[flow]\npreset = "rtl2gds"\n' - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert 'preset = "rtl2gds"' in after - assert after.count("target_density") == 1 - - -class TestMultilineTomlValues: - """Scoped TOML edit must handle multiline array values.""" - - def test_set_replaces_multiline_array(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "2," not in after - assert after.count("core_margin") == 1 - assert "[4, 4]" in after - - def test_unset_removes_multiline_array(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "unset", "floorplan.core_margin", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "core_margin" not in after - - def test_set_multiline_then_show(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run( - ["param", "show", "floorplan.core_margin", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == [4, 4] - - def test_set_preserves_adjacent_key_after_multiline(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n[params.floorplan]\ncore_margin = [\n 2,\n 2,\n]\n core_util = 0.5\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "floorplan.core_margin", "[4, 4]", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "core_util = 0.5" in after - assert after.count("core_margin") == 1 - for line in after.splitlines(): - assert "core_margin" not in line or "core_util" not in line, ( - f"multiline replacement concatenated keys on one line: {line!r}" - ) - - """config --resolved must error on malformed/invalid CLI provenance.""" - - def _setup_run_dir(self, project_dir): - run_dir = os.path.join(project_dir, "runs", "default") - os.makedirs(os.path.join(run_dir, "home"), exist_ok=True) - return run_dir - - def test_malformed_json_provenance_fails(self, tmp_path, capsys, monkeypatch): - project_dir = _create_valid_project(tmp_path) - run_dir = self._setup_run_dir(project_dir) - with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: - f.write("not valid json{") - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda pdk_root, pdk_name: [], - ) - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_config" - - def test_non_dict_provenance_fails(self, tmp_path, capsys, monkeypatch): - project_dir = _create_valid_project(tmp_path) - run_dir = self._setup_run_dir(project_dir) - with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: - json.dump([1, 2, 3], f) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda pdk_root, pdk_name: [], - ) - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 1 - - def test_unknown_key_in_provenance_fails(self, tmp_path, capsys, monkeypatch): - project_dir = _create_valid_project(tmp_path) - run_dir = self._setup_run_dir(project_dir) - with open(os.path.join(run_dir, "home", "cli-param-overrides.json"), "w") as f: - json.dump({"nonexistent.param": 42}, f) - monkeypatch.setattr( - "chipcompiler.cli.project.config._validate_pdk_contents", - lambda pdk_root, pdk_name: [], - ) - rc = cli_main.run(["config", "--resolved", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_config" - - -class TestParamShowDisclosureCommands: - """param show must include disclosure command fields.""" - - def test_show_json_has_disclosure_commands(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - record = data["records"][0] - assert "inspect" in record - assert "set" in record - assert "run" in record - assert "ecc param show place.target_density" in record["inspect"] - - def test_show_text_has_disclosure_commands(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "show", "place.target_density", "--project", project_dir]) - assert rc == 0 - out = capsys.readouterr().out - assert "ecc param show place.target_density" in out - assert "ecc param set place.target_density" in out - assert "ecc run --set place.target_density" in out - - -class TestSafeTomlSectionParsing: - """Scoped TOML edits must handle comments and indented headers safely.""" - - def test_set_ignores_commented_section_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n# [params.place]\n# target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert "[params.place]" in after - assert "target_density = 0.7" in after - - def test_set_ignores_indented_next_section_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += '\n[params.place]\ntarget_density = 0.65\n\n [flow]\npreset = "rtl2gds"\n' - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - with open(toml_path) as f: - after = f.read() - assert after.count("target_density") == 1 - assert "0.7" in after - assert 'preset = "rtl2gds"' in after - - def test_set_then_show_after_commented_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n# [params.place]\n# target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - cli_main.run(["param", "set", "place.target_density", "0.7", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run( - ["param", "show", "place.target_density", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 0.7 - - def test_unset_ignores_commented_section_header(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content += "\n# [params.place]\n# target_density = 0.65\n" - with open(toml_path, "w") as f: - f.write(content) - - rc = cli_main.run(["param", "unset", "place.target_density", "--project", project_dir]) - assert rc == 0 - capsys.readouterr() - with open(toml_path) as f: - after = f.read() - assert "target_density" in after - - -class TestListDefaultDiffFiltering: - """param diff must not report list values equal to defaults.""" - - def test_list_default_not_in_diff(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "floorplan.core_margin", "[2,2]", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0].get("diff_status") == "clean" - - def test_list_changed_value_in_diff(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - cli_main.run(["param", "set", "floorplan.core_margin", "[4,4]", "--project", project_dir]) - capsys.readouterr() - - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert len(data["records"]) >= 1 - margin = next( - (r for r in data["records"] if r.get("param") == "floorplan.core_margin"), None - ) - assert margin is not None - assert margin["value"] == [4, 4] - - -class TestZeroFrequencyRejected: - """ecc param set design.frequency_mhz 0 must be rejected.""" - - def test_set_zero_rejected(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run(["param", "set", "design.frequency_mhz", "0", "--project", project_dir]) - assert rc == 1 - - def test_cli_set_zero_rejected(self, tmp_path): - project_dir = _create_valid_project(tmp_path) - rc = cli_main.run( - [ - "run", - "--project", - project_dir, - "--set", - "design.frequency_mhz=0", - ] - ) - assert rc == 1 - - -class TestDesignFrequencySeeded: - """ecc param list/show must reflect [design] frequency_mhz.""" - - def test_list_shows_design_frequency(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path, freq=200.0) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - freq = next(r for r in data["records"] if r["param"] == "design.frequency_mhz") - assert freq["value"] == 200.0 - - def test_show_shows_design_frequency(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path, freq=200.0) - rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 200.0 - - def test_param_override_beats_design_frequency(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path, freq=200.0) - cli_main.run(["param", "set", "design.frequency_mhz", "300", "--project", project_dir]) - capsys.readouterr() - rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] - ) - assert rc == 0 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["value"] == 300.0 - assert data["records"][0]["source"] == "ecc.toml" - - -class TestMalformedTomlRejected: - """ecc param list/show/diff must reject syntactically malformed ecc.toml.""" - - def _write_malformed_toml(self, project_dir): - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path, "w") as f: - f.write('[design\nname = "gcd"\n') - - def test_param_list_rejects_malformed(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_malformed_toml(project_dir) - rc = cli_main.run(["param", "list", "--project", project_dir, "--json"]) - assert rc == 1 - data = json.loads(capsys.readouterr().out) - assert data["records"][0]["error"] == "invalid_param_config" - - def test_param_show_rejects_malformed(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_malformed_toml(project_dir) - rc = cli_main.run( - ["param", "show", "design.frequency_mhz", "--project", project_dir, "--json"] - ) - assert rc == 1 - - def test_param_diff_rejects_malformed(self, tmp_path, capsys): - project_dir = _create_valid_project(tmp_path) - self._write_malformed_toml(project_dir) - rc = cli_main.run(["param", "diff", "--project", project_dir, "--json"]) - assert rc == 1 diff --git a/test/cli/test_typer_cli.py b/test/cli/test_typer_cli.py index c48ee6ef..a098e206 100644 --- a/test/cli/test_typer_cli.py +++ b/test/cli/test_typer_cli.py @@ -374,3 +374,9 @@ def fake_renderer(result, ctx, command_input, color): assert exc.exit_code == 0 assert capsys.readouterr().out.strip() == "registry:text:ok" + + +class TestEdgeCases: + def test_no_command_returns_nonzero(self, capsys): + rc = cli_main.run([]) + assert rc == 1 diff --git a/test/cli/test_workspace_cli.py b/test/cli/workspace/test_workspace_cli.py similarity index 99% rename from test/cli/test_workspace_cli.py rename to test/cli/workspace/test_workspace_cli.py index 9b2146a2..9e71ab45 100644 --- a/test/cli/test_workspace_cli.py +++ b/test/cli/workspace/test_workspace_cli.py @@ -935,7 +935,9 @@ def prepare_workspace_for_rerun(workspace, engine_flow): engine_flow.prepared_for_rerun = True engine_flow.call_order.append(("prepare_rerun", workspace.directory)) - monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare_workspace_for_rerun) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", prepare_workspace_for_rerun + ) rc = cli_main.run(["workspace", "run-flow", "--directory", str(ws), "--rerun", "--json"]) @@ -977,7 +979,9 @@ def test_run_flow_rerun_stops_when_prepare_fails(monkeypatch, tmp_path, capsys): def prepare_workspace_for_rerun(workspace, engine_flow): raise RuntimeError("cleanup failed") - monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare_workspace_for_rerun) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", prepare_workspace_for_rerun + ) rc = cli_main.run(["workspace", "run-flow", "--directory", str(ws), "--rerun", "--json"]) diff --git a/test/conftest.py b/test/conftest.py index 55e2395f..f11e18bd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -17,14 +17,14 @@ def _load_complete_ics55_pdk_available(): complete_ics55_pdk_available = _load_complete_ics55_pdk_available() -FILELIST_INTEGRATION_PREFIX = "test/test_filelist.py::TestCreateWorkspaceIntegration" +FILELIST_INTEGRATION_PREFIX = "test/data/test_workspace_filelist.py::TestCreateWorkspaceIntegration" PDK_REQUIRED_TESTS = { f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_filelist": "", f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_nested_filelist": "", - "test/test_harden.py::test_ics55_gcd": "../icsprout55-pdk", - "test/test_rcx.py::test_ics55_gcd": "", - "test/test_tools.py::test_ics55_gcd": "", + "test/integration/test_harden_flow.py::test_ics55_gcd": "../icsprout55-pdk", + "test/integration/test_rcx_flow.py::test_ics55_gcd": "", + "test/integration/test_rtl2gds_flow.py::test_ics55_gcd": "", } diff --git a/test/data/conftest.py b/test/data/conftest.py new file mode 100644 index 00000000..2cb65aee --- /dev/null +++ b/test/data/conftest.py @@ -0,0 +1,83 @@ +from pathlib import Path + +import pytest + + +def create_minimal_ics55_pdk(root: Path) -> Path: + tech_path = root / "prtech" / "techLEF" / "N551P6M_ecos.lef" + tech_path.parent.mkdir(parents=True, exist_ok=True) + tech_path.write_text("VERSION 5.8 ;\n") + + stdcell_root = root / "IP" / "STD_cell" / "ics55_LLSC_H7C_V1p10C100" + for flavor in ("ics55_LLSC_H7CR", "ics55_LLSC_H7CL"): + lef_path = stdcell_root / flavor / "lef" / f"{flavor}_ecos.lef" + lef_path.parent.mkdir(parents=True, exist_ok=True) + lef_path.write_text("VERSION 5.8 ;\n") + + lib_path = stdcell_root / flavor / "liberty" / f"{flavor}_ss_rcworst_1p08_125_nldm.lib" + lib_path.parent.mkdir(parents=True, exist_ok=True) + lib_path.write_text("library(test) { }\n") + + return root + + +def create_minimal_sg13g2_pdk(root: Path) -> Path: + tech_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_tech.lef" + tech_path.parent.mkdir(parents=True, exist_ok=True) + tech_path.write_text("VERSION 5.8 ;\n") + + lef_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_stdcell.lef" + lef_path.write_text("VERSION 5.8 ;\n") + + lib_path = root / "libs.ref" / "sg13g2_stdcell" / "lib" / "sg13g2_stdcell_typ_1p20V_25C.lib" + lib_path.parent.mkdir(parents=True, exist_ok=True) + lib_path.write_text("library(test) { }\n") + + return root + + +def ics55_parameters() -> dict: + return { + "PDK": "ics55", + "Design": "gcd", + "Top module": "gcd", + "Clock": "clk", + "Frequency max [MHz]": 100, + } + + +def sg13g2_parameters() -> dict: + return { + "PDK": "sg13g2", + "Design": "gcd", + "Top module": "gcd", + "Clock": "clk", + "Frequency max [MHz]": 100, + } + + +@pytest.fixture +def minimal_ics55_pdk_factory(): + return create_minimal_ics55_pdk + + +@pytest.fixture +def minimal_sg13g2_pdk_factory(): + return create_minimal_sg13g2_pdk + + +@pytest.fixture +def default_ics55_parameters(): + return ics55_parameters() + + +@pytest.fixture +def default_sg13g2_parameters(): + return sg13g2_parameters() + + +@pytest.fixture +def gcd_rtl_file(tmp_path): + rtl_path = tmp_path / "gcd.v" + rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + return rtl_path diff --git a/test/test_design_parameters.py b/test/data/test_design_parameters.py similarity index 100% rename from test/test_design_parameters.py rename to test/data/test_design_parameters.py diff --git a/test/test_home_data.py b/test/data/test_home_data.py similarity index 100% rename from test/test_home_data.py rename to test/data/test_home_data.py diff --git a/test/test_data_pdk.py b/test/data/test_pdk.py similarity index 52% rename from test/test_data_pdk.py rename to test/data/test_pdk.py index 467edbcd..e8d46cba 100644 --- a/test/test_data_pdk.py +++ b/test/data/test_pdk.py @@ -1,34 +1,13 @@ -#!/usr/bin/env python - -from pathlib import Path - import pytest from chipcompiler.data.pdk import get_pdk -def _create_minimal_ics55_pdk(root: Path) -> Path: - """Create the minimal ICS55 directory tree required by get_pdk().""" - tech_path = root / "prtech" / "techLEF" / "N551P6M_ecos.lef" - tech_path.parent.mkdir(parents=True, exist_ok=True) - tech_path.write_text("VERSION 5.8 ;\n") - - stdcell_root = root / "IP" / "STD_cell" / "ics55_LLSC_H7C_V1p10C100" - for flavor in ("ics55_LLSC_H7CR", "ics55_LLSC_H7CL"): - lef_path = stdcell_root / flavor / "lef" / f"{flavor}_ecos.lef" - lef_path.parent.mkdir(parents=True, exist_ok=True) - lef_path.write_text("VERSION 5.8 ;\n") - - lib_path = stdcell_root / flavor / "liberty" / f"{flavor}_ss_rcworst_1p08_125_nldm.lib" - lib_path.parent.mkdir(parents=True, exist_ok=True) - lib_path.write_text("library(test) { }\n") - - return root - - -def test_get_pdk_prefers_explicit_root_over_env(tmp_path, monkeypatch): - explicit_root = _create_minimal_ics55_pdk(tmp_path / "explicit") - env_root = _create_minimal_ics55_pdk(tmp_path / "env") +def test_get_pdk_prefers_explicit_root_over_env( + tmp_path, monkeypatch, minimal_ics55_pdk_factory +): + explicit_root = minimal_ics55_pdk_factory(tmp_path / "explicit") + env_root = minimal_ics55_pdk_factory(tmp_path / "env") monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(env_root)) pdk = get_pdk(pdk_name="ics55", pdk_root=str(explicit_root)) @@ -39,8 +18,8 @@ def test_get_pdk_prefers_explicit_root_over_env(tmp_path, monkeypatch): assert all(path.startswith(expected_root) for path in pdk.lefs + pdk.libs) -def test_get_pdk_uses_namespaced_env(tmp_path, monkeypatch): - env_root = _create_minimal_ics55_pdk(tmp_path / "env") +def test_get_pdk_uses_namespaced_env(tmp_path, monkeypatch, minimal_ics55_pdk_factory): + env_root = minimal_ics55_pdk_factory(tmp_path / "env") monkeypatch.setenv("CHIPCOMPILER_ICS55_PDK_ROOT", str(env_root)) monkeypatch.delenv("ICS55_PDK_ROOT", raising=False) @@ -49,8 +28,10 @@ def test_get_pdk_uses_namespaced_env(tmp_path, monkeypatch): assert pdk.root == str(env_root.resolve()) -def test_get_pdk_uses_legacy_env_when_namespaced_missing(tmp_path, monkeypatch): - legacy_root = _create_minimal_ics55_pdk(tmp_path / "legacy") +def test_get_pdk_uses_legacy_env_when_namespaced_missing( + tmp_path, monkeypatch, minimal_ics55_pdk_factory +): + legacy_root = minimal_ics55_pdk_factory(tmp_path / "legacy") monkeypatch.delenv("CHIPCOMPILER_ICS55_PDK_ROOT", raising=False) monkeypatch.setenv("ICS55_PDK_ROOT", str(legacy_root)) @@ -67,27 +48,11 @@ def test_get_pdk_raises_on_missing_pdk_files(tmp_path): get_pdk("ics55", pdk_root=str(invalid_root)) -#SG13G2 helpers and tests - -def _create_minimal_sg13g2_pdk(root: Path) -> Path: - """Create the minimal SG13G2 directory tree required by get_pdk().""" - tech_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_tech.lef" - tech_path.parent.mkdir(parents=True, exist_ok=True) - tech_path.write_text("VERSION 5.8 ;\n") - - lef_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_stdcell.lef" - lef_path.write_text("VERSION 5.8 ;\n") - - lib_path = root / "libs.ref" / "sg13g2_stdcell" / "lib" / "sg13g2_stdcell_typ_1p20V_25C.lib" - lib_path.parent.mkdir(parents=True, exist_ok=True) - lib_path.write_text("library(test) { }\n") - - return root - - -def test_get_pdk_sg13g2_prefers_explicit_root_over_env(tmp_path, monkeypatch): - explicit_root = _create_minimal_sg13g2_pdk(tmp_path / "explicit") - env_root = _create_minimal_sg13g2_pdk(tmp_path / "env") +def test_get_pdk_sg13g2_prefers_explicit_root_over_env( + tmp_path, monkeypatch, minimal_sg13g2_pdk_factory +): + explicit_root = minimal_sg13g2_pdk_factory(tmp_path / "explicit") + env_root = minimal_sg13g2_pdk_factory(tmp_path / "env") monkeypatch.setenv("CHIPCOMPILER_SG13G2_PDK_ROOT", str(env_root)) pdk = get_pdk("sg13g2", pdk_root=str(explicit_root)) @@ -98,8 +63,10 @@ def test_get_pdk_sg13g2_prefers_explicit_root_over_env(tmp_path, monkeypatch): assert all(path.startswith(expected_root) for path in pdk.lefs + pdk.libs) -def test_get_pdk_sg13g2_uses_namespaced_env(tmp_path, monkeypatch): - env_root = _create_minimal_sg13g2_pdk(tmp_path / "env") +def test_get_pdk_sg13g2_uses_namespaced_env( + tmp_path, monkeypatch, minimal_sg13g2_pdk_factory +): + env_root = minimal_sg13g2_pdk_factory(tmp_path / "env") monkeypatch.setenv("CHIPCOMPILER_SG13G2_PDK_ROOT", str(env_root)) monkeypatch.delenv("SG13G2_PDK_ROOT", raising=False) @@ -108,8 +75,10 @@ def test_get_pdk_sg13g2_uses_namespaced_env(tmp_path, monkeypatch): assert pdk.root == str(env_root.resolve()) -def test_get_pdk_sg13g2_uses_legacy_env_when_namespaced_missing(tmp_path, monkeypatch): - legacy_root = _create_minimal_sg13g2_pdk(tmp_path / "legacy") +def test_get_pdk_sg13g2_uses_legacy_env_when_namespaced_missing( + tmp_path, monkeypatch, minimal_sg13g2_pdk_factory +): + legacy_root = minimal_sg13g2_pdk_factory(tmp_path / "legacy") monkeypatch.delenv("CHIPCOMPILER_SG13G2_PDK_ROOT", raising=False) monkeypatch.setenv("SG13G2_PDK_ROOT", str(legacy_root)) @@ -127,8 +96,8 @@ def test_get_pdk_sg13g2_raises_on_missing_pdk_files(tmp_path, monkeypatch): get_pdk("sg13g2") -def test_get_pdk_sg13g2_cell_config(tmp_path, monkeypatch): - pdk_root = _create_minimal_sg13g2_pdk(tmp_path / "sg13g2") +def test_get_pdk_sg13g2_cell_config(tmp_path, monkeypatch, minimal_sg13g2_pdk_factory): + pdk_root = minimal_sg13g2_pdk_factory(tmp_path / "sg13g2") monkeypatch.setenv("CHIPCOMPILER_SG13G2_PDK_ROOT", str(pdk_root)) pdk = get_pdk("sg13g2") @@ -144,8 +113,8 @@ def test_get_pdk_sg13g2_cell_config(tmp_path, monkeypatch): assert "sg13g2_lgcp_1" in pdk.dont_use -def test_get_pdk_sg13g2_case_insensitive(tmp_path, monkeypatch): - pdk_root = _create_minimal_sg13g2_pdk(tmp_path / "sg13g2") +def test_get_pdk_sg13g2_case_insensitive(tmp_path, monkeypatch, minimal_sg13g2_pdk_factory): + pdk_root = minimal_sg13g2_pdk_factory(tmp_path / "sg13g2") monkeypatch.setenv("CHIPCOMPILER_SG13G2_PDK_ROOT", str(pdk_root)) pdk = get_pdk("SG13G2") diff --git a/test/test_workspace.py b/test/data/test_workspace.py similarity index 79% rename from test/test_workspace.py rename to test/data/test_workspace.py index cc0243ca..80ed6de9 100644 --- a/test/test_workspace.py +++ b/test/data/test_workspace.py @@ -1,7 +1,4 @@ -#!/usr/bin/env python - import json -from pathlib import Path from chipcompiler.data import create_workspace, load_workspace from chipcompiler.data.workspace import ( @@ -13,36 +10,10 @@ from chipcompiler.utility import json_read, json_write -def _create_minimal_ics55_pdk(root: Path) -> Path: - tech_path = root / "prtech" / "techLEF" / "N551P6M_ecos.lef" - tech_path.parent.mkdir(parents=True, exist_ok=True) - tech_path.write_text("VERSION 5.8 ;\n") - - stdcell_root = root / "IP" / "STD_cell" / "ics55_LLSC_H7C_V1p10C100" - for flavor in ("ics55_LLSC_H7CR", "ics55_LLSC_H7CL"): - lef_path = stdcell_root / flavor / "lef" / f"{flavor}_ecos.lef" - lef_path.parent.mkdir(parents=True, exist_ok=True) - lef_path.write_text("VERSION 5.8 ;\n") - - lib_path = stdcell_root / flavor / "liberty" / f"{flavor}_ss_rcworst_1p08_125_nldm.lib" - lib_path.parent.mkdir(parents=True, exist_ok=True) - lib_path.write_text("library(test) { }\n") - - return root - - -def _default_parameters() -> dict: - return { - "PDK": "ics55", - "Design": "gcd", - "Top module": "gcd", - "Clock": "clk", - "Frequency max [MHz]": 100, - } - - -def test_create_workspace_persists_pdk_root_in_parameters(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_create_workspace_persists_pdk_root_in_parameters( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -52,7 +23,7 @@ def test_create_workspace_persists_pdk_root_in_parameters(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -65,8 +36,10 @@ def test_create_workspace_persists_pdk_root_in_parameters(tmp_path): assert parameters_data.get("PDK Root") == resolved_root -def test_load_workspace_restores_pdk_root_from_parameters(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_load_workspace_restores_pdk_root_from_parameters( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -76,7 +49,7 @@ def test_load_workspace_restores_pdk_root_from_parameters(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -89,8 +62,10 @@ def test_load_workspace_restores_pdk_root_from_parameters(tmp_path): assert all(path.startswith(resolved_root) for path in loaded.pdk.libs) -def test_workspace_config_refresh_uses_updated_parameters(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_workspace_config_refresh_uses_updated_parameters( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -100,7 +75,7 @@ def test_workspace_config_refresh_uses_updated_parameters(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -118,8 +93,10 @@ def test_workspace_config_refresh_uses_updated_parameters(tmp_path): assert placement["PL"]["GP"]["global_right_padding"] == 13 -def test_refresh_workspace_config_updates_all_parameter_derived_fields(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_refresh_workspace_config_updates_all_parameter_derived_fields( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -129,7 +106,7 @@ def test_refresh_workspace_config_updates_all_parameter_derived_fields(tmp_path) origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -164,8 +141,10 @@ def test_refresh_workspace_config_updates_all_parameter_derived_fields(tmp_path) assert dreamplace["routability_opt_flag"] == 0 -def test_sync_workspace_config_to_parameters_updates_routing_layers_and_refreshes_peers(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_sync_workspace_config_to_parameters_updates_routing_layers_and_refreshes_peers( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -175,7 +154,7 @@ def test_sync_workspace_config_to_parameters_updates_routing_layers_and_refreshe origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -195,8 +174,10 @@ def test_sync_workspace_config_to_parameters_updates_routing_layers_and_refreshe assert db["LayerSettings"]["routing_layer_1st"] == "MET4" -def test_sync_workspace_config_to_parameters_ignores_unmanaged_fields(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_sync_workspace_config_to_parameters_ignores_unmanaged_fields( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -206,7 +187,7 @@ def test_sync_workspace_config_to_parameters_ignores_unmanaged_fields(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -222,8 +203,10 @@ def test_sync_workspace_config_to_parameters_ignores_unmanaged_fields(tmp_path): assert after == before -def test_prepare_workspace_for_rerun_deletes_old_artifacts_and_resets_home_state(tmp_path): - pdk_root = _create_minimal_ics55_pdk(tmp_path / "ics55") +def test_prepare_workspace_for_rerun_deletes_old_artifacts_and_resets_home_state( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -233,7 +216,7 @@ def test_prepare_workspace_for_rerun_deletes_old_artifacts_and_resets_home_state origin_def="", origin_verilog=str(rtl_path), pdk="ics55", - parameters=_default_parameters(), + parameters=default_ics55_parameters, pdk_root=str(pdk_root), ) @@ -340,9 +323,13 @@ def create_step_workspaces(self): assert reset_parameters["Top module"] == parameters_before_json["Top module"] assert reset_parameters["Clock"] == parameters_before_json["Clock"] assert reset_parameters["Frequency max [MHz]"] == parameters_before_json["Frequency max [MHz]"] - assert reset_parameters["Core"]["Utilitization"] == parameters_before_json["Core"]["Utilitization"] + assert ( + reset_parameters["Core"]["Utilitization"] == parameters_before_json["Core"]["Utilitization"] + ) assert reset_parameters["Core"]["Margin"] == parameters_before_json["Core"]["Margin"] - assert reset_parameters["Core"]["Aspect ratio"] == parameters_before_json["Core"]["Aspect ratio"] + assert ( + reset_parameters["Core"]["Aspect ratio"] == parameters_before_json["Core"]["Aspect ratio"] + ) assert reset_parameters["Die"]["Size"] == [] assert reset_parameters["Die"]["Area"] == 0 assert reset_parameters["Core"]["Size"] == [] @@ -371,36 +358,10 @@ def create_step_workspaces(self): assert engine_flow.create_calls == 1 -#SG13G2 workspace tests - -def _create_minimal_sg13g2_pdk(root: Path) -> Path: - """Create the minimal SG13G2 directory tree required by get_pdk().""" - tech_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_tech.lef" - tech_path.parent.mkdir(parents=True, exist_ok=True) - tech_path.write_text("VERSION 5.8 ;\n") - - lef_path = root / "libs.ref" / "sg13g2_stdcell" / "lef" / "sg13g2_stdcell.lef" - lef_path.write_text("VERSION 5.8 ;\n") - - lib_path = root / "libs.ref" / "sg13g2_stdcell" / "lib" / "sg13g2_stdcell_typ_1p20V_25C.lib" - lib_path.parent.mkdir(parents=True, exist_ok=True) - lib_path.write_text("library(test) { }\n") - - return root - - -def _sg13g2_default_parameters() -> dict: - return { - "PDK": "sg13g2", - "Design": "gcd", - "Top module": "gcd", - "Clock": "clk", - "Frequency max [MHz]": 100, - } - - -def test_create_workspace_sg13g2_persists_pdk_root_in_parameters(tmp_path): - pdk_root = _create_minimal_sg13g2_pdk(tmp_path / "sg13g2") +def test_create_workspace_sg13g2_persists_pdk_root_in_parameters( + tmp_path, minimal_sg13g2_pdk_factory, default_sg13g2_parameters +): + pdk_root = minimal_sg13g2_pdk_factory(tmp_path / "sg13g2") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -410,7 +371,7 @@ def test_create_workspace_sg13g2_persists_pdk_root_in_parameters(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="sg13g2", - parameters=_sg13g2_default_parameters(), + parameters=default_sg13g2_parameters, pdk_root=str(pdk_root), ) @@ -423,8 +384,10 @@ def test_create_workspace_sg13g2_persists_pdk_root_in_parameters(tmp_path): assert parameters_data.get("PDK Root") == resolved_root -def test_load_workspace_sg13g2_restores_pdk_root_from_parameters(tmp_path): - pdk_root = _create_minimal_sg13g2_pdk(tmp_path / "sg13g2") +def test_load_workspace_sg13g2_restores_pdk_root_from_parameters( + tmp_path, minimal_sg13g2_pdk_factory, default_sg13g2_parameters +): + pdk_root = minimal_sg13g2_pdk_factory(tmp_path / "sg13g2") rtl_path = tmp_path / "gcd.v" rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") @@ -434,7 +397,7 @@ def test_load_workspace_sg13g2_restores_pdk_root_from_parameters(tmp_path): origin_def="", origin_verilog=str(rtl_path), pdk="sg13g2", - parameters=_sg13g2_default_parameters(), + parameters=default_sg13g2_parameters, pdk_root=str(pdk_root), ) diff --git a/test/data/test_workspace_filelist.py b/test/data/test_workspace_filelist.py new file mode 100644 index 00000000..688b53de --- /dev/null +++ b/test/data/test_workspace_filelist.py @@ -0,0 +1,85 @@ +import os + +import pytest + +from chipcompiler.data import create_workspace, get_pdk +from chipcompiler.data.parameter import Parameters + + +@pytest.fixture +def test_parameters(): + parameters = Parameters() + parameters.data = { + "Design": "test", + "Top module": "top", + "Clock": "clk", + "Frequency max [MHz]": 100, + } + return parameters + + +@pytest.fixture +def pdk(): + return get_pdk(pdk_name="ics55") + + +def _write_rtl_file(path, module_name): + path.write_text(f"module {module_name}(); endmodule") + + +def _create_filelist(path, *entries): + path.write_text("\n".join(entries) + "\n") + + +class TestCreateWorkspaceIntegration: + def test_workspace_with_filelist(self, tmp_path, test_parameters, pdk): + project_dir = tmp_path / "project" + project_dir.mkdir() + _write_rtl_file(project_dir / "gcd.v", "gcd") + + filelist = project_dir / "design.f" + _create_filelist(filelist, "gcd.v") + + test_parameters.data["Design"] = "gcd" + test_parameters.data["Top module"] = "gcd" + + workspace_dir = tmp_path / "workspace" + workspace = create_workspace( + directory=str(workspace_dir), + origin_def="", + origin_verilog="", + pdk=pdk, + parameters=test_parameters, + input_filelist=str(filelist), + ) + + assert os.path.exists(workspace_dir) + assert os.path.exists(workspace_dir / "origin") + assert os.path.exists(workspace_dir / "origin" / "design.f") + assert os.path.exists(workspace_dir / "origin" / "gcd.v") + assert (workspace_dir / "origin" / "gcd.v").read_text() == "module gcd(); endmodule" + assert workspace.design.input_filelist == str(workspace_dir / "origin" / "design.f") + + def test_workspace_with_nested_filelist(self, tmp_path, test_parameters, pdk): + project_dir = tmp_path / "project" + (project_dir / "rtl" / "core").mkdir(parents=True) + + _write_rtl_file(project_dir / "rtl" / "core" / "alu.v", "alu") + _write_rtl_file(project_dir / "rtl" / "core" / "ctrl.v", "ctrl") + + filelist = project_dir / "design.f" + _create_filelist(filelist, "rtl/core/alu.v", "rtl/core/ctrl.v") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=str(workspace_dir), + origin_def="", + origin_verilog="", + pdk=pdk, + parameters=test_parameters, + input_filelist=str(filelist), + ) + + origin_dir = workspace_dir / "origin" + assert (origin_dir / "rtl" / "core" / "alu.v").exists() + assert (origin_dir / "rtl" / "core" / "ctrl.v").exists() diff --git a/test/integration/conftest.py b/test/integration/conftest.py new file mode 100644 index 00000000..1d36dcbd --- /dev/null +++ b/test/integration/conftest.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from chipcompiler.data import create_workspace, get_design_parameters, get_pdk +from chipcompiler.engine import EngineDB, EngineFlow + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def gcd_fixture_verilog() -> Path: + return REPO_ROOT / "test" / "fixtures" / "gcd" / "gcd.v" + + +def run_workspace_flow( + flow_builder, + *, + design_name="gcd", + pdk_name="ics55", + workspace_suffix, + pdk_root=None, + with_engine_db=False, +): + workspace_dir = REPO_ROOT / "test" / "examples" / workspace_suffix + parameters = get_design_parameters(pdk_name, design_name) + parameters.data["Design"] = design_name + parameters.data["Top module"] = design_name + parameters.data["Clock"] = "clk" + + if pdk_root is None: + pdk = get_pdk(pdk_name=pdk_name) + else: + pdk = get_pdk(pdk_name, pdk_root=str(pdk_root)) + + workspace = create_workspace( + directory=str(workspace_dir), + origin_def="", + origin_verilog=str(gcd_fixture_verilog()), + pdk=pdk, + parameters=parameters, + ) + + engine_db = EngineDB(workspace=workspace) if with_engine_db else None + engine_flow = EngineFlow(workspace=workspace, engine_db=engine_db) + if not engine_flow.has_init(): + for step, tool, state in flow_builder(): + engine_flow.add_step(step=step, tool=tool, state=state) + + engine_flow.create_step_workspaces() + return engine_flow.run_steps() + + +@pytest.fixture +def run_workspace_flow_factory(): + return run_workspace_flow + + +@pytest.fixture +def gcd_fixture_verilog_path(): + return gcd_fixture_verilog() diff --git a/test/integration/test_harden_flow.py b/test/integration/test_harden_flow.py new file mode 100644 index 00000000..7798b73e --- /dev/null +++ b/test/integration/test_harden_flow.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import pytest + +from chipcompiler.rtl2gds import build_harden_flow + +pytestmark = [pytest.mark.integration, pytest.mark.pdk] + + +def test_ics55_gcd(run_workspace_flow_factory): + assert run_workspace_flow_factory( + build_harden_flow, + workspace_suffix="ics55_gcd_harden", + ) diff --git a/test/integration/test_rcx_flow.py b/test/integration/test_rcx_flow.py new file mode 100644 index 00000000..8dd7af51 --- /dev/null +++ b/test/integration/test_rcx_flow.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import pytest + +from chipcompiler.rtl2gds import build_rcx_flow + +pytestmark = [pytest.mark.integration, pytest.mark.pdk] + + +def test_ics55_gcd(run_workspace_flow_factory): + assert run_workspace_flow_factory( + build_rcx_flow, + workspace_suffix="ics55_gcd_rcx", + ) diff --git a/test/integration/test_rtl2gds_flow.py b/test/integration/test_rtl2gds_flow.py new file mode 100644 index 00000000..b21bde69 --- /dev/null +++ b/test/integration/test_rtl2gds_flow.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + +from pathlib import Path + +import pytest + +from chipcompiler.rtl2gds import build_rtl2gds_flow + +pytestmark = [pytest.mark.integration, pytest.mark.pdk] + + +def test_ics55_gcd(run_workspace_flow_factory): + assert run_workspace_flow_factory( + build_rtl2gds_flow, + workspace_suffix="ics55_gcd_tool", + with_engine_db=True, + ) + + +def test_sg13g2_gcd(run_workspace_flow_factory): + assert run_workspace_flow_factory( + build_rtl2gds_flow, + pdk_name="sg13g2", + workspace_suffix="sg13g2_gcd_tool", + pdk_root=Path(__file__).resolve().parents[2] / "ihp-sg13g2", + ) diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py new file mode 100644 index 00000000..8b27f610 --- /dev/null +++ b/test/packaging/test_cli_entrypoint.py @@ -0,0 +1,12 @@ +import os + + +class TestPackaging: + def test_ecc_console_script_in_pyproject(self): + import tomllib + + project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + pyproject = os.path.join(project_root, "pyproject.toml") + with open(pyproject, "rb") as f: + data = tomllib.load(f) + assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main" diff --git a/test/test_pyinstaller_packaging.py b/test/packaging/test_pyinstaller_packaging.py similarity index 100% rename from test/test_pyinstaller_packaging.py rename to test/packaging/test_pyinstaller_packaging.py diff --git a/test/test_ecc_dreamplace_parameter_overrides.py b/test/test_ecc_dreamplace_parameter_overrides.py deleted file mode 100644 index a90ca5cd..00000000 --- a/test/test_ecc_dreamplace_parameter_overrides.py +++ /dev/null @@ -1,77 +0,0 @@ -from chipcompiler.tools.ecc_dreamplace.parameter_overrides import ( - apply_parameter_overrides, -) - - -def test_flat_legacy_keys_map_to_dreamplace_fields(): - base = { - "target_density": 0.8, - "stop_overflow": 0.1, - "cell_padding_x": 600, - "routability_opt_flag": 0, - } - parameter_data = { - "Target density": 0.65, - "Target overflow": 0.05, - "Cell padding x": 800, - "Routability opt flag": 1, - } - - result = apply_parameter_overrides(base, parameter_data) - - assert result["target_density"] == 0.65 - assert result["stop_overflow"] == 0.05 - assert result["cell_padding_x"] == 800 - assert result["routability_opt_flag"] == 1 - - -def test_nested_dreamplace_overrides_are_applied(): - base = {"routability_opt_flag": 0, "target_density": 0.8} - parameter_data = { - "DreamPlace": { - "routability_opt_flag": 1, - "target_density": 0.7, - }, - } - - result = apply_parameter_overrides(base, parameter_data) - - assert result["routability_opt_flag"] == 1 - assert result["target_density"] == 0.7 - - -def test_nested_dreamplace_overrides_win_over_flat_keys(): - base = {"routability_opt_flag": 0} - parameter_data = { - "Routability opt flag": 1, - "DreamPlace": {"routability_opt_flag": 0}, - } - - result = apply_parameter_overrides(base, parameter_data) - - assert result["routability_opt_flag"] == 0 - - -def test_non_dict_dreamplace_value_keeps_flat_mappings(): - base = {"routability_opt_flag": 0} - parameter_data = { - "Routability opt flag": 1, - "DreamPlace": "invalid", - } - - result = apply_parameter_overrides(base, parameter_data) - - assert result["routability_opt_flag"] == 1 - - -def test_apply_parameter_overrides_copies_inputs(): - base = {"nested": {"value": 1}} - parameter_data = {"DreamPlace": {"list_value": [1, 2, 3]}} - - result = apply_parameter_overrides(base, parameter_data) - - result["nested"]["value"] = 2 - result["list_value"].append(4) - - assert base == {"nested": {"value": 1}} - assert parameter_data == {"DreamPlace": {"list_value": [1, 2, 3]}} diff --git a/test/test_filelist.py b/test/test_filelist.py deleted file mode 100644 index f350943a..00000000 --- a/test/test_filelist.py +++ /dev/null @@ -1,563 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- -""" -Test filelist parsing and copying functionality. - -Tests: -- parse_filelist: Extract file paths from filelist -- resolve_path: Resolve relative/absolute paths -- validate_filelist: Check file existence -- copy_filelist_with_sources: Copy filelist + RTL files to workspace -- create_workspace: Integration test with filelist -""" - -import os -import sys -import pytest - -current_dir = os.path.dirname(os.path.abspath(__file__)) -root = os.path.dirname(current_dir) -sys.path.insert(0, root) - -from chipcompiler.utility.filelist import ( - parse_filelist, - resolve_path, - validate_filelist, - get_filelist_info, - parse_incdir_directives -) -from chipcompiler.data.workspace import copy_filelist_with_sources -from chipcompiler.data import create_workspace, get_pdk -from chipcompiler.data.parameter import Parameters - - -@pytest.fixture -def workspace_dir(tmp_path): - """Create and return a workspace directory.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - return workspace - - -@pytest.fixture -def test_parameters(): - """Create default test parameters.""" - parameters = Parameters() - parameters.data = { - "Design": "test", - "Top module": "top", - "Clock": "clk", - "Frequency max [MHz]": 100 - } - return parameters - - -def write_rtl_file(path, module_name): - """Helper to write a simple RTL module file.""" - path.write_text(f"module {module_name}(); endmodule") - - -def write_header_file(path, content): - """Helper to write a header file.""" - path.write_text(content) - - -def create_filelist(path, *entries): - """Helper to create a filelist with given entries.""" - path.write_text("\n".join(entries) + "\n") - - -def create_filelist_with_content(path, content): - """Helper to create a filelist with raw content.""" - path.write_text(content) - - -def setup_project_with_incdir(project_dir, incdir_name="include", rtl_dir="rtl"): - """ - Helper to set up project structure with RTL and include directories. - Returns tuple of (rtl_dir_path, include_dir_path). - """ - rtl_path = project_dir / rtl_dir - include_path = project_dir / incdir_name - rtl_path.mkdir(parents=True) - include_path.mkdir(parents=True) - return rtl_path, include_path - - -class TestParseFilelist: - """Test filelist parsing functionality.""" - - def test_parse_simple_filelist(self, tmp_path): - """Parse filelist with simple file paths.""" - filelist = tmp_path / "design.f" - create_filelist(filelist, "rtl/gcd.v", "rtl/gcd_pkg.v") - - assert parse_filelist(str(filelist)) == ["rtl/gcd.v", "rtl/gcd_pkg.v"] - - def test_parse_with_comments(self, tmp_path): - """Parse filelist with comments.""" - filelist = tmp_path / "design.f" - filelist.write_text( - "# This is a comment\n" - "rtl/top.v\n" - "// Another comment\n" - "rtl/sub.v # inline comment\n" - ) - - assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] - - def test_parse_with_empty_lines(self, tmp_path): - """Parse filelist with empty lines.""" - filelist = tmp_path / "design.f" - filelist.write_text("rtl/file1.v\n\nrtl/file2.v\n\n\nrtl/file3.v\n") - - assert parse_filelist(str(filelist)) == ["rtl/file1.v", "rtl/file2.v", "rtl/file3.v"] - - def test_parse_with_quotes(self, tmp_path): - """Parse filelist with quoted paths.""" - filelist = tmp_path / "design.f" - filelist.write_text('"path with spaces/file.v"\n' "'another path/file.v'\n") - - assert parse_filelist(str(filelist)) == ["path with spaces/file.v", "another path/file.v"] - - def test_skip_incdir_directives(self, tmp_path): - """Skip +incdir directives (supported).""" - filelist = tmp_path / "design.f" - filelist.write_text("rtl/top.v\n+incdir+rtl/include\nrtl/sub.v\n") - - assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] - - def test_error_on_y_directive(self, tmp_path): - """Raise error for -y library search directive.""" - filelist = tmp_path / "design.f" - filelist.write_text("rtl/top.v\n-y rtl/lib\nrtl/sub.v\n") - - with pytest.raises(ValueError, match="Unsupported filelist option.*-y"): - parse_filelist(str(filelist)) - - def test_error_on_v_directive(self, tmp_path): - """Raise error for -v library file directive.""" - filelist = tmp_path / "design.f" - filelist.write_text("rtl/top.v\n-v rtl/lib.v\nrtl/sub.v\n") - - with pytest.raises(ValueError, match="Unsupported filelist option.*-v"): - parse_filelist(str(filelist)) - - def test_error_on_f_directive(self, tmp_path): - """Raise error for -f recursive filelist directive.""" - filelist = tmp_path / "design.f" - filelist.write_text("rtl/top.v\n-f sub.f\nrtl/sub.v\n") - - with pytest.raises(ValueError, match="Unsupported filelist option.*-f"): - parse_filelist(str(filelist)) - - def test_skip_backtick_includes(self, tmp_path): - """Skip backtick includes like `include.""" - filelist = tmp_path / "design.f" - filelist.write_text('rtl/top.v\n`include "header.vh"\nrtl/sub.v\n') - - assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] - - def test_nonexistent_filelist(self): - """Raise error for nonexistent filelist.""" - with pytest.raises(FileNotFoundError): - parse_filelist("/nonexistent/file.f") - - -class TestResolvePath: - """Test path resolution functionality.""" - - def test_resolve_relative_path(self, tmp_path): - """Resolve relative path against base directory.""" - base_dir = str(tmp_path) - expected = os.path.abspath(os.path.join(base_dir, "rtl/gcd.v")) - assert resolve_path("rtl/gcd.v", base_dir) == expected - - def test_resolve_absolute_path(self, tmp_path): - """Absolute path should be returned as-is.""" - abs_path = "/absolute/path/file.v" - assert resolve_path(abs_path, str(tmp_path)) == os.path.abspath(abs_path) - - def test_resolve_nested_path(self, tmp_path): - """Resolve nested relative path.""" - base_dir = str(tmp_path) - expected = os.path.abspath(os.path.join(base_dir, "rtl/core/alu.v")) - assert resolve_path("rtl/core/alu.v", base_dir) == expected - - -class TestValidateFilelist: - """Test filelist validation functionality.""" - - def test_validate_all_exist(self, tmp_path): - """All files exist.""" - rtl_dir = tmp_path / "rtl" - rtl_dir.mkdir() - write_rtl_file(rtl_dir / "gcd.v", "gcd") - write_rtl_file(rtl_dir / "top.v", "top") - - filelist = tmp_path / "design.f" - create_filelist(filelist, "rtl/gcd.v", "rtl/top.v") - - existing, missing = validate_filelist(str(filelist)) - assert existing == ["rtl/gcd.v", "rtl/top.v"] - assert missing == [] - - def test_validate_some_missing(self, tmp_path): - """Some files missing.""" - rtl_dir = tmp_path / "rtl" - rtl_dir.mkdir() - write_rtl_file(rtl_dir / "gcd.v", "gcd") - - filelist = tmp_path / "design.f" - create_filelist(filelist, "rtl/gcd.v", "rtl/missing.v") - - existing, missing = validate_filelist(str(filelist)) - assert existing == ["rtl/gcd.v"] - assert missing == ["rtl/missing.v"] - - def test_validate_all_missing(self, tmp_path): - """All files missing.""" - filelist = tmp_path / "design.f" - create_filelist(filelist, "rtl/missing1.v", "rtl/missing2.v") - - existing, missing = validate_filelist(str(filelist)) - assert existing == [] - assert missing == ["rtl/missing1.v", "rtl/missing2.v"] - - -class TestGetFilelistInfo: - """Test get_filelist_info functionality.""" - - def test_get_info(self, tmp_path): - """Get detailed filelist information.""" - rtl_dir = tmp_path / "rtl" - rtl_dir.mkdir() - write_rtl_file(rtl_dir / "gcd.v", "gcd") - - filelist = tmp_path / "design.f" - create_filelist(filelist, "rtl/gcd.v", "rtl/missing.v") - - info = get_filelist_info(str(filelist)) - - assert info['filelist'] == os.path.abspath(str(filelist)) - assert info['base_dir'] == str(tmp_path) - assert info['total_files'] == 2 - assert info['existing_files'] == ["rtl/gcd.v"] - assert info['missing_files'] == ["rtl/missing.v"] - assert "rtl/gcd.v" in info['file_sizes'] - assert info['file_sizes']["rtl/gcd.v"] > 0 - - -class TestCopyFilelistWithSources: - """Test copy_filelist_with_sources functionality.""" - - def test_copy_simple_filelist(self, tmp_path, workspace_dir): - """Copy filelist with single file.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - write_rtl_file(project_dir / "gcd.v", "gcd") - - filelist = project_dir / "design.f" - create_filelist(filelist, "gcd.v") - - new_filelist = copy_filelist_with_sources(str(filelist), str(workspace_dir)) - - assert os.path.exists(new_filelist) - assert new_filelist == str(workspace_dir / "origin" / "design.f") - - copied_file = workspace_dir / "origin" / "gcd.v" - assert copied_file.exists() - assert copied_file.read_text() == "module gcd(); endmodule" - - def test_copy_nested_structure(self, tmp_path, workspace_dir): - """Copy filelist preserving nested directory structure.""" - project_dir = tmp_path / "project" - (project_dir / "rtl" / "core").mkdir(parents=True) - (project_dir / "rtl" / "mem").mkdir(parents=True) - - write_rtl_file(project_dir / "rtl" / "core" / "alu.v", "alu") - write_rtl_file(project_dir / "rtl" / "core" / "ctrl.v", "ctrl") - write_rtl_file(project_dir / "rtl" / "mem" / "cache.v", "cache") - - filelist = project_dir / "design.f" - create_filelist(filelist, "rtl/core/alu.v", "rtl/core/ctrl.v", "rtl/mem/cache.v") - - copy_filelist_with_sources(str(filelist), str(workspace_dir)) - - origin_dir = workspace_dir / "origin" - assert (origin_dir / "rtl" / "core" / "alu.v").exists() - assert (origin_dir / "rtl" / "core" / "ctrl.v").exists() - assert (origin_dir / "rtl" / "mem" / "cache.v").exists() - assert (origin_dir / "rtl" / "core" / "alu.v").read_text() == "module alu(); endmodule" - - def test_copy_with_missing_files(self, tmp_path, workspace_dir): - """Copy filelist with some missing files.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - rtl_dir = project_dir / "rtl" - rtl_dir.mkdir() - write_rtl_file(rtl_dir / "top.v", "top") - - filelist = project_dir / "design.f" - create_filelist(filelist, "rtl/top.v", "rtl/missing.v") - - copy_filelist_with_sources(str(filelist), str(workspace_dir)) - - origin_dir = workspace_dir / "origin" - assert (origin_dir / "rtl" / "top.v").exists() - assert not (origin_dir / "rtl" / "missing.v").exists() - - def test_copy_with_absolute_paths(self, tmp_path, workspace_dir): - """Copy filelist with absolute paths.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - abs_file = project_dir / "absolute.v" - write_rtl_file(abs_file, "absolute") - - filelist = project_dir / "design.f" - filelist.write_text(f"{abs_file}\n") - - copy_filelist_with_sources(str(filelist), str(workspace_dir)) - - origin_dir = workspace_dir / "origin" - assert (origin_dir / "absolute.v").exists() - assert (origin_dir / "absolute.v").read_text() == "module absolute(); endmodule" - - -class TestCreateWorkspaceIntegration: - """Test create_workspace integration with filelist copying.""" - - @pytest.fixture - def pdk(self): - """Get the ICS55 PDK for integration tests.""" - return get_pdk(pdk_name="ics55") - - def test_workspace_with_filelist(self, tmp_path, test_parameters, pdk): - """Create workspace with filelist.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - write_rtl_file(project_dir / "gcd.v", "gcd") - - filelist = project_dir / "design.f" - create_filelist(filelist, "gcd.v") - - test_parameters.data["Design"] = "gcd" - test_parameters.data["Top module"] = "gcd" - - workspace_dir = tmp_path / "workspace" - workspace = create_workspace( - directory=str(workspace_dir), - origin_def="", - origin_verilog="", - pdk=pdk, - parameters=test_parameters, - input_filelist=str(filelist) - ) - - assert os.path.exists(workspace_dir) - assert os.path.exists(workspace_dir / "origin") - assert os.path.exists(workspace_dir / "origin" / "design.f") - assert os.path.exists(workspace_dir / "origin" / "gcd.v") - assert (workspace_dir / "origin" / "gcd.v").read_text() == "module gcd(); endmodule" - assert workspace.design.input_filelist == str(workspace_dir / "origin" / "design.f") - - def test_workspace_with_nested_filelist(self, tmp_path, test_parameters, pdk): - """Create workspace with nested filelist structure.""" - project_dir = tmp_path / "project" - (project_dir / "rtl" / "core").mkdir(parents=True) - - write_rtl_file(project_dir / "rtl" / "core" / "alu.v", "alu") - write_rtl_file(project_dir / "rtl" / "core" / "ctrl.v", "ctrl") - - filelist = project_dir / "design.f" - create_filelist(filelist, "rtl/core/alu.v", "rtl/core/ctrl.v") - - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=str(workspace_dir), - origin_def="", - origin_verilog="", - pdk=pdk, - parameters=test_parameters, - input_filelist=str(filelist) - ) - - origin_dir = workspace_dir / "origin" - assert (origin_dir / "rtl" / "core" / "alu.v").exists() - assert (origin_dir / "rtl" / "core" / "ctrl.v").exists() - - -class TestParseIncdirDirectives: - """Test suite for parse_incdir_directives function.""" - - def _parse_incdir(self, tmp_path, content): - """Helper to create filelist and parse incdir directives.""" - filelist = tmp_path / "design.f" - create_filelist_with_content(filelist, content) - return parse_incdir_directives(str(filelist)) - - def test_parse_single_incdir(self, tmp_path): - """Parse filelist with single +incdir directive.""" - dirs = self._parse_incdir(tmp_path, "+incdir+./include\nrtl/top.v\n") - assert dirs == ["./include"] - - def test_parse_multiple_incdir(self, tmp_path): - """Parse filelist with multiple +incdir directives.""" - content = ( - "+incdir+./include\n" - "+incdir+./rtl/common\n" - "+incdir+../shared/headers\n" - "rtl/top.v\n" - ) - dirs = self._parse_incdir(tmp_path, content) - assert dirs == ["./include", "./rtl/common", "../shared/headers"] - - def test_parse_incdir_current_dir(self, tmp_path): - """Parse filelist with +incdir+./ directive.""" - dirs = self._parse_incdir(tmp_path, "+incdir+./\nrtl/top.v\n") - assert dirs == ["./"] - - def test_parse_incdir_with_comments(self, tmp_path): - """Parse +incdir directives with inline comments.""" - content = ( - "+incdir+./include # Main headers\n" - "+incdir+./rtl/common // Common headers\n" - "rtl/top.v\n" - ) - dirs = self._parse_incdir(tmp_path, content) - assert dirs == ["./include", "./rtl/common"] - - def test_parse_incdir_with_quotes(self, tmp_path): - """Parse +incdir directives with quoted paths.""" - dirs = self._parse_incdir(tmp_path, '+incdir+"./include"\nrtl/top.v\n') - assert dirs == ["./include"] - - def test_parse_incdir_empty_filelist(self, tmp_path): - """Parse filelist with no +incdir directives.""" - dirs = self._parse_incdir(tmp_path, "rtl/top.v\nrtl/sub.v\n") - assert dirs == [] - - def test_parse_incdir_skip_comments(self, tmp_path): - """Ensure comments are skipped when parsing +incdir.""" - content = ( - "# +incdir+./should_skip\n" - "// +incdir+./also_skip\n" - "+incdir+./valid\n" - ) - dirs = self._parse_incdir(tmp_path, content) - assert dirs == ["./valid"] - - def test_parse_incdir_with_spaces(self, tmp_path): - """Parse +incdir directives with surrounding spaces.""" - content = ( - "+incdir+ ./include\n" # Space after prefix - "+incdir+ ./rtl/common \n" # Multiple spaces - "+incdir+ \"./quoted\" # comment\n" # Spaces with quotes - " +incdir+./leading\n" # Leading spaces on line - "\t+incdir+./tab\n" # Leading tab - ) - dirs = self._parse_incdir(tmp_path, content) - assert dirs == ["./include", "./rtl/common", "./quoted", "./leading", "./tab"] - - -class TestCopyFilelistWithIncdir: - """Test suite for copy_filelist_with_sources with +incdir support.""" - - def _copy_and_get_origin(self, tmp_path, project_dir, filelist_content): - """Helper to create filelist, copy sources, and return origin directory.""" - filelist = project_dir / "design.f" - create_filelist_with_content(filelist, filelist_content) - - workspace_dir = tmp_path / "workspace" - copy_filelist_with_sources(str(filelist), str(workspace_dir)) - return workspace_dir / "origin" - - def test_copy_with_incdir_basic(self, tmp_path): - """Copy filelist with basic +incdir directive.""" - project_dir = tmp_path / "project" - rtl_dir, include_dir = setup_project_with_incdir(project_dir) - - write_rtl_file(rtl_dir / "top.v", "top") - write_header_file(include_dir / "defines.vh", "`define WIDTH 32") - - origin_dir = self._copy_and_get_origin( - tmp_path, project_dir, "+incdir+./include\nrtl/top.v\n" - ) - - assert (origin_dir / "rtl" / "top.v").exists() - assert (origin_dir / "include" / "defines.vh").exists() - - def test_copy_with_incdir_deduplication(self, tmp_path): - """Verify deduplication when file appears in both filelist and +incdir.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - - write_rtl_file(project_dir / "top.v", "top") - write_header_file(project_dir / "types.vh", "`define TYPES") - - origin_dir = self._copy_and_get_origin( - tmp_path, project_dir, "top.v\n+incdir+./\n" - ) - - assert (origin_dir / "top.v").exists() - assert (origin_dir / "types.vh").exists() - - # Verify file count: top.v and types.vh only - verilog_files = list(origin_dir.glob("*.v")) + list(origin_dir.glob("*.vh")) - assert len(verilog_files) == 2 - - def test_copy_with_incdir_nested_structure(self, tmp_path): - """Copy +incdir with nested directory structure.""" - project_dir = tmp_path / "project" - rtl_dir, include_dir = setup_project_with_incdir(project_dir) - (include_dir / "subdir").mkdir() - - write_rtl_file(rtl_dir / "top.v", "top") - write_header_file(include_dir / "defines.vh", "`define A") - write_header_file(include_dir / "subdir" / "params.vh", "`define B") - - origin_dir = self._copy_and_get_origin( - tmp_path, project_dir, "+incdir+./include\nrtl/top.v\n" - ) - - assert (origin_dir / "rtl" / "top.v").exists() - assert (origin_dir / "include" / "defines.vh").exists() - assert (origin_dir / "include" / "subdir" / "params.vh").exists() - - def test_copy_with_incdir_missing_directory(self, tmp_path): - """Handle missing +incdir directory gracefully.""" - project_dir = tmp_path / "project" - (project_dir / "rtl").mkdir(parents=True) - - write_rtl_file(project_dir / "rtl" / "top.v", "top") - - origin_dir = self._copy_and_get_origin( - tmp_path, project_dir, "+incdir+./missing_include\nrtl/top.v\n" - ) - - assert (origin_dir / "rtl" / "top.v").exists() - - def test_copy_with_multiple_incdir(self, tmp_path): - """Copy filelist with multiple +incdir directives.""" - project_dir = tmp_path / "project" - (project_dir / "rtl").mkdir(parents=True) - (project_dir / "include1").mkdir(parents=True) - (project_dir / "include2").mkdir(parents=True) - - write_rtl_file(project_dir / "rtl" / "top.v", "top") - write_header_file(project_dir / "include1" / "defs1.vh", "`define A") - write_header_file(project_dir / "include2" / "defs2.vh", "`define B") - - origin_dir = self._copy_and_get_origin( - tmp_path, - project_dir, - "+incdir+./include1\n+incdir+./include2\nrtl/top.v\n", - ) - - assert (origin_dir / "rtl" / "top.v").exists() - assert (origin_dir / "include1" / "defs1.vh").exists() - assert (origin_dir / "include2" / "defs2.vh").exists() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/test/test_harden.py b/test/test_harden.py deleted file mode 100644 index 884d137e..00000000 --- a/test/test_harden.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- - -import sys -import os - -current_dir = os.path.split(os.path.abspath(__file__))[0] -root = current_dir.rsplit('/', 1)[0] -sys.path.append(root) - -from chipcompiler.data import ( - create_workspace, - load_workspace, - log_workspace, - log_parameters, - StepEnum, - StateEnum, - get_pdk, - get_design_parameters -) - -from chipcompiler.engine import ( - EngineDB, - EngineFlow -) - -def test_ics55_gcd(): - workspace_dir="{}/test/examples/ics55_gcd_harden".format(root) - - input_def = "" - input_verilog = "{}/test/fixtures/gcd/gcd.v".format(root) # RTL file - parameters=get_design_parameters("ics55", "gcd") - pdk = get_pdk(pdk_name= "ics55") - - workspace = create_workspace( - directory=workspace_dir, - origin_def=input_def, - origin_verilog=input_verilog, - pdk=pdk, - parameters=parameters - ) - # workspace = load_workspace(workspace_dir) - - - engine_flow = EngineFlow(workspace=workspace) - if not engine_flow.has_init(): - from chipcompiler.rtl2gds import build_harden_flow - steps = build_harden_flow() - for step, tool, state in steps: - engine_flow.add_step(step=step, tool=tool, state=state) - - engine_flow.create_step_workspaces() - - assert engine_flow.run_steps() - -if __name__ == "__main__": - test_ics55_gcd() - - exit(0) diff --git a/test/test_rcx.py b/test/test_rcx.py deleted file mode 100644 index 7e9de1a3..00000000 --- a/test/test_rcx.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- - -import sys -import os - -current_dir = os.path.split(os.path.abspath(__file__))[0] -root = current_dir.rsplit('/', 1)[0] -sys.path.append(root) - -from chipcompiler.data import ( - create_workspace, - load_workspace, - log_workspace, - log_parameters, - StepEnum, - StateEnum, - get_pdk, - get_design_parameters -) - -from chipcompiler.engine import ( - EngineDB, - EngineFlow -) - -def test_ics55_gcd(): - workspace_dir="{}/test/examples/ics55_gcd_rcx".format(root) - - input_def = "" - input_verilog = "{}/test/fixtures/gcd/gcd.v".format(root) # RTL file - parameters=get_design_parameters("ics55", "gcd") - pdk = get_pdk(pdk_name= "ics55") - - workspace = create_workspace( - directory=workspace_dir, - origin_def=input_def, - origin_verilog=input_verilog, - pdk=pdk, - parameters=parameters - ) - # workspace = load_workspace(workspace_dir) - - - engine_flow = EngineFlow(workspace=workspace) - if not engine_flow.has_init(): - from chipcompiler.rtl2gds import build_rcx_flow - steps = build_rcx_flow() - for step, tool, state in steps: - engine_flow.add_step(step=step, tool=tool, state=state) - - engine_flow.create_step_workspaces() - - assert engine_flow.run_steps() - -if __name__ == "__main__": - test_ics55_gcd() - - exit(0) diff --git a/test/test_tools.py b/test/test_tools.py deleted file mode 100644 index b740f969..00000000 --- a/test/test_tools.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- - -import sys -import os - -current_dir = os.path.split(os.path.abspath(__file__))[0] -root = current_dir.rsplit('/', 1)[0] -sys.path.append(root) - -from chipcompiler.data import ( - create_workspace, - load_workspace, - log_workspace, - log_parameters, - StepEnum, - StateEnum, - get_pdk, - get_design_parameters -) - -from chipcompiler.engine import ( - EngineDB, - EngineFlow -) - -def test_ics55_gcd(): - workspace_dir="{}/test/examples/ics55_gcd_tool".format(root) - - input_def = "" - input_verilog = "{}/test/fixtures/gcd/gcd.v".format(root) # RTL file - parameters=get_design_parameters("ics55", "gcd") - pdk = get_pdk(pdk_name= "ics55") - - workspace = create_workspace( - directory=workspace_dir, - origin_def=input_def, - origin_verilog=input_verilog, - pdk=pdk, - parameters=parameters - ) - # workspace = load_workspace(workspace_dir) - - from chipcompiler.engine import EngineDB - # build engine_db for workspace - engine_db = EngineDB(workspace=workspace) - # build engine flow for workspace - engine_flow = EngineFlow(workspace=workspace, engine_db=engine_db) - if not engine_flow.has_init(): - from chipcompiler.rtl2gds import build_rtl2gds_flow - steps = build_rtl2gds_flow() - for step, tool, state in steps: - engine_flow.add_step(step=step, tool=tool, state=state) - - engine_flow.create_step_workspaces() - - engine_flow.run_steps() - -def test_sg13g2_gcd(): - workspace_dir="{}/test/examples/sg13g2_gcd_tool".format(root) - - input_def = "" - input_verilog = "{}/test/fixtures/gcd/gcd.v".format(root) # RTL file - parameters=get_design_parameters("sg13g2", "gcd") - parameters.data["Design"] = "gcd" - parameters.data["Top module"] = "gcd" - parameters.data["Clock"] = "clk" - - pdk_root = "{}/ihp-sg13g2".format(root) - pdk = get_pdk("sg13g2", pdk_root=pdk_root) - - workspace = create_workspace( - directory=workspace_dir, - origin_def=input_def, - origin_verilog=input_verilog, - pdk=pdk, - parameters=parameters - ) - - - engine_flow = EngineFlow(workspace=workspace) - if not engine_flow.has_init(): - from chipcompiler.rtl2gds import build_rtl2gds_flow - steps = build_rtl2gds_flow() - for step, tool, state in steps: - engine_flow.add_step(step=step, tool=tool, state=state) - - engine_flow.create_step_workspaces() - - engine_flow.run_steps() - - -if __name__ == "__main__": - test_ics55_gcd() - test_sg13g2_gcd() - - exit(0) diff --git a/test/tools/ecc/__init__.py b/test/tools/ecc/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/tools/ecc/__init__.py @@ -0,0 +1 @@ + diff --git a/test/test_ecc_tools_module.py b/test/tools/ecc/test_module.py similarity index 99% rename from test/test_ecc_tools_module.py rename to test/tools/ecc/test_module.py index f31248f3..b6f1f706 100644 --- a/test/test_ecc_tools_module.py +++ b/test/tools/ecc/test_module.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- encoding: utf-8 -*- from chipcompiler.data import OriginDesign, StepEnum, Workspace from chipcompiler.tools.ecc.builder import build_step diff --git a/test/tools/ecc_dreamplace/__init__.py b/test/tools/ecc_dreamplace/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/tools/ecc_dreamplace/__init__.py @@ -0,0 +1 @@ + diff --git a/test/tools/ecc_dreamplace/conftest.py b/test/tools/ecc_dreamplace/conftest.py new file mode 100644 index 00000000..1a5f0e51 --- /dev/null +++ b/test/tools/ecc_dreamplace/conftest.py @@ -0,0 +1,26 @@ +from pathlib import Path + +import pytest + +from chipcompiler.data.parameter import get_parameters, update_parameters +from chipcompiler.tools.ecc_dreamplace import builder as dreamplace_builder +from chipcompiler.utility import json_read + + +@pytest.fixture +def dreamplace_default_config(): + config_path = ( + Path(dreamplace_builder.__file__).resolve().parent / "configs" / "dreamplace.json" + ) + return json_read(str(config_path)) + + +@pytest.fixture +def make_ics55_parameters(): + def factory(overrides: dict | None = None): + parameters = get_parameters("ics55") + if overrides: + update_parameters(overrides, parameters.data) + return parameters + + return factory diff --git a/test/test_ecc_dreamplace_config_permissions.py b/test/tools/ecc_dreamplace/test_config_permissions.py similarity index 72% rename from test/test_ecc_dreamplace_config_permissions.py rename to test/tools/ecc_dreamplace/test_config_permissions.py index f2f5107a..08951393 100644 --- a/test/test_ecc_dreamplace_config_permissions.py +++ b/test/tools/ecc_dreamplace/test_config_permissions.py @@ -1,7 +1,7 @@ import shutil import stat -from chipcompiler.data import PDK, OriginDesign, Parameters, StepEnum, Workspace +from chipcompiler.data import PDK, OriginDesign, StepEnum, Workspace from chipcompiler.data.workspace import init_workspace_config from chipcompiler.tools.ecc_dreamplace import builder as dreamplace_builder from chipcompiler.utility import json_read, json_write @@ -10,12 +10,13 @@ def test_workspace_config_generation_leaves_config_root_writable_after_read_only_copy( tmp_path, monkeypatch, + make_ics55_parameters, ): workspace = Workspace( directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd"), pdk=PDK(tech="tech.lef", lefs=["std.lef"], buffers=[], fillers=[]), - parameters=Parameters(data={}), + parameters=make_ics55_parameters(), ) config_dir = tmp_path / "workspace" / "config" @@ -45,12 +46,13 @@ def copy_readonly_config_file(src, dst): def test_dreamplace_config_generation_writes_generated_fields_to_copied_config( tmp_path, monkeypatch, + make_ics55_parameters, ): workspace = Workspace( directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd"), pdk=PDK(tech="tech.lef", lefs=["std.lef"]), - parameters=Parameters(data={}), + parameters=make_ics55_parameters(), ) step = dreamplace_builder.build_step( workspace=workspace, @@ -90,55 +92,66 @@ def copy_readonly_config_file(src, dst): assert data["base_design_name"] == "gcd" -def test_workspace_config_generation_applies_flat_dreamplace_parameter_overrides(tmp_path): +def test_workspace_config_generation_applies_flat_dreamplace_parameter_overrides( + tmp_path, + make_ics55_parameters, +): + overrides = { + "Target density": 0.65, + "Target overflow": 0.05, + "Cell padding x": 800, + "Routability opt flag": 1, + } workspace = Workspace( directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd"), pdk=PDK(tech="tech.lef", lefs=["std.lef"]), - parameters=Parameters( - data={ - "Target density": 0.65, - "Target overflow": 0.05, - "Cell padding x": 800, - "Routability opt flag": 1, - } - ), + parameters=make_ics55_parameters(overrides), ) init_workspace_config(workspace) dreamplace_config = json_read(workspace.config["dreamplace"]) - assert dreamplace_config["target_density"] == 0.65 - assert dreamplace_config["stop_overflow"] == 0.05 - assert dreamplace_config["cell_padding_x"] == 800 - assert dreamplace_config["routability_opt_flag"] == 1 + assert dreamplace_config["target_density"] == overrides["Target density"] + assert dreamplace_config["stop_overflow"] == overrides["Target overflow"] + assert dreamplace_config["cell_padding_x"] == overrides["Cell padding x"] + assert dreamplace_config["routability_opt_flag"] == overrides["Routability opt flag"] -def test_workspace_config_generation_nested_dreamplace_overrides_win_over_flat_keys(tmp_path): +def test_workspace_config_generation_nested_dreamplace_overrides_win_over_flat_keys( + tmp_path, + make_ics55_parameters, +): + overrides = { + "Routability opt flag": 1, + "DreamPlace": {"routability_opt_flag": 0}, + } workspace = Workspace( directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd"), pdk=PDK(tech="tech.lef", lefs=["std.lef"]), - parameters=Parameters( - data={ - "Routability opt flag": 1, - "DreamPlace": {"routability_opt_flag": 0}, - } - ), + parameters=make_ics55_parameters(overrides), ) init_workspace_config(workspace) dreamplace_config = json_read(workspace.config["dreamplace"]) - assert dreamplace_config["routability_opt_flag"] == 0 + assert dreamplace_config["routability_opt_flag"] == overrides["DreamPlace"][ + "routability_opt_flag" + ] -def test_dreamplace_step_config_refresh_reapplies_current_parameter_file(tmp_path, monkeypatch): +def test_dreamplace_step_config_refresh_reapplies_current_parameter_file( + tmp_path, + monkeypatch, + make_ics55_parameters, +): + initial_overrides = {"Target density": 0.65} workspace = Workspace( directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd"), pdk=PDK(tech="tech.lef", lefs=["std.lef"]), - parameters=Parameters(data={"Target density": 0.65}), + parameters=make_ics55_parameters(initial_overrides), ) (tmp_path / "workspace" / "home").mkdir(parents=True) workspace.parameters.path = str(tmp_path / "workspace" / "home" / "parameters.json") @@ -150,16 +163,18 @@ def test_dreamplace_step_config_refresh_reapplies_current_parameter_file(tmp_pat ) init_workspace_config(workspace) + updated_overrides = { + "Target density": 0.7, + "DreamPlace": { + "def_input": "stale.def", + "verilog_input": "stale.v", + "result_dir": "stale-output", + }, + } + updated_parameters = make_ics55_parameters(updated_overrides) json_write( workspace.parameters.path, - { - "Target density": 0.7, - "DreamPlace": { - "def_input": "stale.def", - "verilog_input": "stale.v", - "result_dir": "stale-output", - }, - }, + updated_parameters.data, ) monkeypatch.setattr( dreamplace_builder.ecc_builder, @@ -170,7 +185,7 @@ def test_dreamplace_step_config_refresh_reapplies_current_parameter_file(tmp_pat dreamplace_builder.build_step_config(workspace, step) dreamplace_config = json_read(workspace.config["dreamplace"]) - assert dreamplace_config["target_density"] == 0.7 + assert dreamplace_config["target_density"] == updated_overrides["Target density"] assert dreamplace_config["def_input"] == "input.def" assert dreamplace_config["verilog_input"] == "input.v" assert dreamplace_config["result_dir"] == step.data[step.name] diff --git a/test/test_ecc_dreamplace_module.py b/test/tools/ecc_dreamplace/test_module.py similarity index 100% rename from test/test_ecc_dreamplace_module.py rename to test/tools/ecc_dreamplace/test_module.py diff --git a/test/tools/ecc_dreamplace/test_parameter_overrides.py b/test/tools/ecc_dreamplace/test_parameter_overrides.py new file mode 100644 index 00000000..5fbc465a --- /dev/null +++ b/test/tools/ecc_dreamplace/test_parameter_overrides.py @@ -0,0 +1,82 @@ +from chipcompiler.tools.ecc_dreamplace.parameter_overrides import ( + apply_parameter_overrides, +) + + +def _alternate_flag(value): + return 0 if value else 1 + + +def _alternate_float(value): + return 0.65 if value != 0.65 else 0.7 + + +def test_flat_legacy_keys_map_to_dreamplace_fields(dreamplace_default_config): + parameter_data = { + "Target density": _alternate_float(dreamplace_default_config["target_density"]), + "Target overflow": _alternate_float(dreamplace_default_config["stop_overflow"]), + "Cell padding x": dreamplace_default_config["cell_padding_x"] + 500, + "Routability opt flag": _alternate_flag( + dreamplace_default_config["routability_opt_flag"] + ), + } + + result = apply_parameter_overrides(dreamplace_default_config, parameter_data) + + assert result["target_density"] == parameter_data["Target density"] + assert result["stop_overflow"] == parameter_data["Target overflow"] + assert result["cell_padding_x"] == parameter_data["Cell padding x"] + assert result["routability_opt_flag"] == parameter_data["Routability opt flag"] + + +def test_nested_dreamplace_overrides_are_applied(dreamplace_default_config): + overrides = { + "routability_opt_flag": _alternate_flag( + dreamplace_default_config["routability_opt_flag"] + ), + "target_density": _alternate_float(dreamplace_default_config["target_density"]), + } + parameter_data = {"DreamPlace": overrides} + + result = apply_parameter_overrides(dreamplace_default_config, parameter_data) + + assert result["routability_opt_flag"] == overrides["routability_opt_flag"] + assert result["target_density"] == overrides["target_density"] + + +def test_nested_dreamplace_overrides_win_over_flat_keys(dreamplace_default_config): + flat_value = _alternate_flag(dreamplace_default_config["routability_opt_flag"]) + nested_value = _alternate_flag(flat_value) + parameter_data = { + "Routability opt flag": flat_value, + "DreamPlace": {"routability_opt_flag": nested_value}, + } + + result = apply_parameter_overrides(dreamplace_default_config, parameter_data) + + assert result["routability_opt_flag"] == nested_value + + +def test_non_dict_dreamplace_value_keeps_flat_mappings(dreamplace_default_config): + flat_value = _alternate_flag(dreamplace_default_config["routability_opt_flag"]) + parameter_data = { + "Routability opt flag": flat_value, + "DreamPlace": "invalid", + } + + result = apply_parameter_overrides(dreamplace_default_config, parameter_data) + + assert result["routability_opt_flag"] == flat_value + + +def test_apply_parameter_overrides_copies_inputs(): + base = {"nested": {"value": 1}} + parameter_data = {"DreamPlace": {"list_value": [1, 2, 3]}} + + result = apply_parameter_overrides(base, parameter_data) + + result["nested"]["value"] = 2 + result["list_value"].append(4) + + assert base == {"nested": {"value": 1}} + assert parameter_data == {"DreamPlace": {"list_value": [1, 2, 3]}} diff --git a/test/tools/ecc_sizer/__init__.py b/test/tools/ecc_sizer/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/tools/ecc_sizer/__init__.py @@ -0,0 +1 @@ + diff --git a/test/test_ecc_sizer_module.py b/test/tools/ecc_sizer/test_module.py similarity index 100% rename from test/test_ecc_sizer_module.py rename to test/tools/ecc_sizer/test_module.py diff --git a/test/tools/yosys/__init__.py b/test/tools/yosys/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/tools/yosys/__init__.py @@ -0,0 +1 @@ + diff --git a/test/test_yosys_global_var_builder.py b/test/tools/yosys/test_global_var_builder.py similarity index 100% rename from test/test_yosys_global_var_builder.py rename to test/tools/yosys/test_global_var_builder.py diff --git a/test/test_tools_yosys_runner.py b/test/tools/yosys/test_runner.py similarity index 100% rename from test/test_tools_yosys_runner.py rename to test/tools/yosys/test_runner.py diff --git a/test/test_tools_yosys_utility.py b/test/tools/yosys/test_utility.py similarity index 100% rename from test/test_tools_yosys_utility.py rename to test/tools/yosys/test_utility.py diff --git a/test/utility/filelist/conftest.py b/test/utility/filelist/conftest.py new file mode 100644 index 00000000..80681f77 --- /dev/null +++ b/test/utility/filelist/conftest.py @@ -0,0 +1,78 @@ +import pytest + +from chipcompiler.data.workspace import copy_filelist_with_sources +from chipcompiler.utility.filelist import parse_incdir_directives + + +@pytest.fixture +def workspace_dir(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + return workspace + + +@pytest.fixture +def write_rtl_file(): + def write(path, module_name): + path.write_text(f"module {module_name}(); endmodule") + + return write + + +@pytest.fixture +def write_header_file(): + def write(path, content): + path.write_text(content) + + return write + + +@pytest.fixture +def create_filelist(): + def create(path, *entries): + path.write_text("\n".join(entries) + "\n") + + return create + + +@pytest.fixture +def create_filelist_with_content(): + def create(path, content): + path.write_text(content) + + return create + + +@pytest.fixture +def setup_project_with_incdir(): + def setup(project_dir, incdir_name="include", rtl_dir="rtl"): + rtl_path = project_dir / rtl_dir + include_path = project_dir / incdir_name + rtl_path.mkdir(parents=True) + include_path.mkdir(parents=True) + return rtl_path, include_path + + return setup + + +@pytest.fixture +def parse_incdir_from_content(tmp_path, create_filelist_with_content): + def parse(content): + filelist = tmp_path / "design.f" + create_filelist_with_content(filelist, content) + return parse_incdir_directives(str(filelist)) + + return parse + + +@pytest.fixture +def copy_filelist_content(tmp_path, create_filelist_with_content): + def copy(project_dir, filelist_content): + filelist = project_dir / "design.f" + create_filelist_with_content(filelist, filelist_content) + + workspace = tmp_path / "workspace" + copy_filelist_with_sources(str(filelist), str(workspace)) + return workspace / "origin" + + return copy diff --git a/test/utility/filelist/test_copy.py b/test/utility/filelist/test_copy.py new file mode 100644 index 00000000..88c56936 --- /dev/null +++ b/test/utility/filelist/test_copy.py @@ -0,0 +1,166 @@ +import os + +from chipcompiler.data.workspace import copy_filelist_with_sources + + +class TestCopyFilelistWithSources: + def test_copy_simple_filelist( + self, tmp_path, workspace_dir, write_rtl_file, create_filelist + ): + project_dir = tmp_path / "project" + project_dir.mkdir() + write_rtl_file(project_dir / "gcd.v", "gcd") + + filelist = project_dir / "design.f" + create_filelist(filelist, "gcd.v") + + new_filelist = copy_filelist_with_sources(str(filelist), str(workspace_dir)) + + assert os.path.exists(new_filelist) + assert new_filelist == str(workspace_dir / "origin" / "design.f") + + copied_file = workspace_dir / "origin" / "gcd.v" + assert copied_file.exists() + assert copied_file.read_text() == "module gcd(); endmodule" + + def test_copy_nested_structure( + self, tmp_path, workspace_dir, write_rtl_file, create_filelist + ): + project_dir = tmp_path / "project" + (project_dir / "rtl" / "core").mkdir(parents=True) + (project_dir / "rtl" / "mem").mkdir(parents=True) + + write_rtl_file(project_dir / "rtl" / "core" / "alu.v", "alu") + write_rtl_file(project_dir / "rtl" / "core" / "ctrl.v", "ctrl") + write_rtl_file(project_dir / "rtl" / "mem" / "cache.v", "cache") + + filelist = project_dir / "design.f" + create_filelist(filelist, "rtl/core/alu.v", "rtl/core/ctrl.v", "rtl/mem/cache.v") + + copy_filelist_with_sources(str(filelist), str(workspace_dir)) + + origin_dir = workspace_dir / "origin" + assert (origin_dir / "rtl" / "core" / "alu.v").exists() + assert (origin_dir / "rtl" / "core" / "ctrl.v").exists() + assert (origin_dir / "rtl" / "mem" / "cache.v").exists() + assert (origin_dir / "rtl" / "core" / "alu.v").read_text() == "module alu(); endmodule" + + def test_copy_with_missing_files( + self, tmp_path, workspace_dir, write_rtl_file, create_filelist + ): + project_dir = tmp_path / "project" + project_dir.mkdir() + rtl_dir = project_dir / "rtl" + rtl_dir.mkdir() + write_rtl_file(rtl_dir / "top.v", "top") + + filelist = project_dir / "design.f" + create_filelist(filelist, "rtl/top.v", "rtl/missing.v") + + copy_filelist_with_sources(str(filelist), str(workspace_dir)) + + origin_dir = workspace_dir / "origin" + assert (origin_dir / "rtl" / "top.v").exists() + assert not (origin_dir / "rtl" / "missing.v").exists() + + def test_copy_with_absolute_paths(self, tmp_path, workspace_dir, write_rtl_file): + project_dir = tmp_path / "project" + project_dir.mkdir() + abs_file = project_dir / "absolute.v" + write_rtl_file(abs_file, "absolute") + + filelist = project_dir / "design.f" + filelist.write_text(f"{abs_file}\n") + + copy_filelist_with_sources(str(filelist), str(workspace_dir)) + + origin_dir = workspace_dir / "origin" + assert (origin_dir / "absolute.v").exists() + assert (origin_dir / "absolute.v").read_text() == "module absolute(); endmodule" + + +class TestCopyFilelistWithIncdir: + def test_copy_with_incdir_basic( + self, tmp_path, write_rtl_file, write_header_file, setup_project_with_incdir, + copy_filelist_content + ): + project_dir = tmp_path / "project" + rtl_dir, include_dir = setup_project_with_incdir(project_dir) + + write_rtl_file(rtl_dir / "top.v", "top") + write_header_file(include_dir / "defines.vh", "`define WIDTH 32") + + origin_dir = copy_filelist_content(project_dir, "+incdir+./include\nrtl/top.v\n") + + assert (origin_dir / "rtl" / "top.v").exists() + assert (origin_dir / "include" / "defines.vh").exists() + + def test_copy_with_incdir_deduplication( + self, tmp_path, write_rtl_file, write_header_file, copy_filelist_content + ): + project_dir = tmp_path / "project" + project_dir.mkdir() + + write_rtl_file(project_dir / "top.v", "top") + write_header_file(project_dir / "types.vh", "`define TYPES") + + origin_dir = copy_filelist_content(project_dir, "top.v\n+incdir+./\n") + + assert (origin_dir / "top.v").exists() + assert (origin_dir / "types.vh").exists() + + verilog_files = list(origin_dir.glob("*.v")) + list(origin_dir.glob("*.vh")) + assert len(verilog_files) == 2 + + def test_copy_with_incdir_nested_structure( + self, tmp_path, write_rtl_file, write_header_file, setup_project_with_incdir, + copy_filelist_content + ): + project_dir = tmp_path / "project" + rtl_dir, include_dir = setup_project_with_incdir(project_dir) + (include_dir / "subdir").mkdir() + + write_rtl_file(rtl_dir / "top.v", "top") + write_header_file(include_dir / "defines.vh", "`define A") + write_header_file(include_dir / "subdir" / "params.vh", "`define B") + + origin_dir = copy_filelist_content(project_dir, "+incdir+./include\nrtl/top.v\n") + + assert (origin_dir / "rtl" / "top.v").exists() + assert (origin_dir / "include" / "defines.vh").exists() + assert (origin_dir / "include" / "subdir" / "params.vh").exists() + + def test_copy_with_incdir_missing_directory( + self, tmp_path, write_rtl_file, copy_filelist_content + ): + project_dir = tmp_path / "project" + (project_dir / "rtl").mkdir(parents=True) + + write_rtl_file(project_dir / "rtl" / "top.v", "top") + + origin_dir = copy_filelist_content( + project_dir, "+incdir+./missing_include\nrtl/top.v\n" + ) + + assert (origin_dir / "rtl" / "top.v").exists() + + def test_copy_with_multiple_incdir( + self, tmp_path, write_rtl_file, write_header_file, copy_filelist_content + ): + project_dir = tmp_path / "project" + (project_dir / "rtl").mkdir(parents=True) + (project_dir / "include1").mkdir(parents=True) + (project_dir / "include2").mkdir(parents=True) + + write_rtl_file(project_dir / "rtl" / "top.v", "top") + write_header_file(project_dir / "include1" / "defs1.vh", "`define A") + write_header_file(project_dir / "include2" / "defs2.vh", "`define B") + + origin_dir = copy_filelist_content( + project_dir, + "+incdir+./include1\n+incdir+./include2\nrtl/top.v\n", + ) + + assert (origin_dir / "rtl" / "top.v").exists() + assert (origin_dir / "include1" / "defs1.vh").exists() + assert (origin_dir / "include2" / "defs2.vh").exists() diff --git a/test/utility/filelist/test_parse.py b/test/utility/filelist/test_parse.py new file mode 100644 index 00000000..dc5b5c14 --- /dev/null +++ b/test/utility/filelist/test_parse.py @@ -0,0 +1,206 @@ +import os + +import pytest + +from chipcompiler.utility.filelist import ( + get_filelist_info, + parse_filelist, + resolve_path, + validate_filelist, +) + + +class TestParseFilelist: + def test_parse_simple_filelist(self, tmp_path, create_filelist): + filelist = tmp_path / "design.f" + create_filelist(filelist, "rtl/gcd.v", "rtl/gcd_pkg.v") + + assert parse_filelist(str(filelist)) == ["rtl/gcd.v", "rtl/gcd_pkg.v"] + + def test_parse_with_comments(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text( + "# This is a comment\n" + "rtl/top.v\n" + "// Another comment\n" + "rtl/sub.v # inline comment\n" + ) + + assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] + + def test_parse_with_empty_lines(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text("rtl/file1.v\n\nrtl/file2.v\n\n\nrtl/file3.v\n") + + assert parse_filelist(str(filelist)) == ["rtl/file1.v", "rtl/file2.v", "rtl/file3.v"] + + def test_parse_with_quotes(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text('"path with spaces/file.v"\n' "'another path/file.v'\n") + + assert parse_filelist(str(filelist)) == ["path with spaces/file.v", "another path/file.v"] + + def test_skip_incdir_directives(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text("rtl/top.v\n+incdir+rtl/include\nrtl/sub.v\n") + + assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] + + def test_error_on_y_directive(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text("rtl/top.v\n-y rtl/lib\nrtl/sub.v\n") + + with pytest.raises(ValueError, match="Unsupported filelist option.*-y"): + parse_filelist(str(filelist)) + + def test_error_on_v_directive(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text("rtl/top.v\n-v rtl/lib.v\nrtl/sub.v\n") + + with pytest.raises(ValueError, match="Unsupported filelist option.*-v"): + parse_filelist(str(filelist)) + + def test_error_on_f_directive(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text("rtl/top.v\n-f sub.f\nrtl/sub.v\n") + + with pytest.raises(ValueError, match="Unsupported filelist option.*-f"): + parse_filelist(str(filelist)) + + def test_skip_backtick_includes(self, tmp_path): + filelist = tmp_path / "design.f" + filelist.write_text('rtl/top.v\n`include "header.vh"\nrtl/sub.v\n') + + assert parse_filelist(str(filelist)) == ["rtl/top.v", "rtl/sub.v"] + + def test_nonexistent_filelist(self): + with pytest.raises(FileNotFoundError): + parse_filelist("/nonexistent/file.f") + + +class TestResolvePath: + def test_resolve_relative_path(self, tmp_path): + base_dir = str(tmp_path) + expected = os.path.abspath(os.path.join(base_dir, "rtl/gcd.v")) + assert resolve_path("rtl/gcd.v", base_dir) == expected + + def test_resolve_absolute_path(self, tmp_path): + abs_path = "/absolute/path/file.v" + assert resolve_path(abs_path, str(tmp_path)) == os.path.abspath(abs_path) + + def test_resolve_nested_path(self, tmp_path): + base_dir = str(tmp_path) + expected = os.path.abspath(os.path.join(base_dir, "rtl/core/alu.v")) + assert resolve_path("rtl/core/alu.v", base_dir) == expected + + +class TestValidateFilelist: + def test_validate_all_exist(self, tmp_path, write_rtl_file, create_filelist): + rtl_dir = tmp_path / "rtl" + rtl_dir.mkdir() + write_rtl_file(rtl_dir / "gcd.v", "gcd") + write_rtl_file(rtl_dir / "top.v", "top") + + filelist = tmp_path / "design.f" + create_filelist(filelist, "rtl/gcd.v", "rtl/top.v") + + existing, missing = validate_filelist(str(filelist)) + assert existing == ["rtl/gcd.v", "rtl/top.v"] + assert missing == [] + + def test_validate_some_missing(self, tmp_path, write_rtl_file, create_filelist): + rtl_dir = tmp_path / "rtl" + rtl_dir.mkdir() + write_rtl_file(rtl_dir / "gcd.v", "gcd") + + filelist = tmp_path / "design.f" + create_filelist(filelist, "rtl/gcd.v", "rtl/missing.v") + + existing, missing = validate_filelist(str(filelist)) + assert existing == ["rtl/gcd.v"] + assert missing == ["rtl/missing.v"] + + def test_validate_all_missing(self, tmp_path, create_filelist): + filelist = tmp_path / "design.f" + create_filelist(filelist, "rtl/missing1.v", "rtl/missing2.v") + + existing, missing = validate_filelist(str(filelist)) + assert existing == [] + assert missing == ["rtl/missing1.v", "rtl/missing2.v"] + + +class TestGetFilelistInfo: + def test_get_info(self, tmp_path, write_rtl_file, create_filelist): + rtl_dir = tmp_path / "rtl" + rtl_dir.mkdir() + write_rtl_file(rtl_dir / "gcd.v", "gcd") + + filelist = tmp_path / "design.f" + create_filelist(filelist, "rtl/gcd.v", "rtl/missing.v") + + info = get_filelist_info(str(filelist)) + + assert info["filelist"] == os.path.abspath(str(filelist)) + assert info["base_dir"] == str(tmp_path) + assert info["total_files"] == 2 + assert info["existing_files"] == ["rtl/gcd.v"] + assert info["missing_files"] == ["rtl/missing.v"] + assert "rtl/gcd.v" in info["file_sizes"] + assert info["file_sizes"]["rtl/gcd.v"] > 0 + + +class TestParseIncdirDirectives: + def test_parse_single_incdir(self, parse_incdir_from_content): + dirs = parse_incdir_from_content("+incdir+./include\nrtl/top.v\n") + assert dirs == ["./include"] + + def test_parse_multiple_incdir(self, parse_incdir_from_content): + content = ( + "+incdir+./include\n" + "+incdir+./rtl/common\n" + "+incdir+../shared/headers\n" + "rtl/top.v\n" + ) + dirs = parse_incdir_from_content(content) + assert dirs == ["./include", "./rtl/common", "../shared/headers"] + + def test_parse_incdir_current_dir(self, parse_incdir_from_content): + dirs = parse_incdir_from_content("+incdir+./\nrtl/top.v\n") + assert dirs == ["./"] + + def test_parse_incdir_with_comments(self, parse_incdir_from_content): + content = ( + "+incdir+./include # Main headers\n" + "+incdir+./rtl/common // Common headers\n" + "rtl/top.v\n" + ) + dirs = parse_incdir_from_content(content) + assert dirs == ["./include", "./rtl/common"] + + def test_parse_incdir_with_quotes(self, parse_incdir_from_content): + dirs = parse_incdir_from_content('+incdir+"./include"\nrtl/top.v\n') + assert dirs == ["./include"] + + def test_parse_incdir_empty_filelist(self, parse_incdir_from_content): + dirs = parse_incdir_from_content("rtl/top.v\nrtl/sub.v\n") + assert dirs == [] + + def test_parse_incdir_skip_comments(self, parse_incdir_from_content): + content = ( + "# +incdir+./should_skip\n" + "// +incdir+./also_skip\n" + "+incdir+./valid\n" + ) + dirs = parse_incdir_from_content(content) + assert dirs == ["./valid"] + + def test_parse_incdir_with_spaces(self, parse_incdir_from_content): + content = ( + "+incdir+ ./include\n" + "+incdir+ ./rtl/common \n" + '+incdir+ "./quoted" # comment\n' + " +incdir+./leading\n" + "\t+incdir+./tab\n" + ) + dirs = parse_incdir_from_content(content) + assert dirs == ["./include", "./rtl/common", "./quoted", "./leading", "./tab"] diff --git a/test/test_json_utility.py b/test/utility/test_json.py similarity index 100% rename from test/test_json_utility.py rename to test/utility/test_json.py