"""CLI tests: env-var configuration and bad-argument handling. The serve coroutine blocks on a shutdown signal (by design — it is a service), so serve-args tests capture the parsed args instead of running the real server loop. """ from __future__ import annotations import pytest from wago_sim.cli import _env, _env_port, main class TestEnvHelpers: def test_port_from_env(self, monkeypatch): monkeypatch.setenv("WAGO_SIM_PORT", "1234") assert _env_port("WAGO_SIM_PORT", 502) == 1234 monkeypatch.delenv("WAGO_SIM_PORT") assert _env_port("WAGO_SIM_PORT", 502) == 502 def test_host_from_env(self, monkeypatch): monkeypatch.setenv("WAGO_SIM_HOST", "10.0.0.5") assert _env("WAGO_SIM_HOST", "0.0.0.0") == "10.0.0.5" monkeypatch.delenv("WAGO_SIM_HOST") assert _env("WAGO_SIM_HOST", "0.0.0.0") == "0.0.0.0" class TestServeEnvConfig: """WAGO_SIM_* env vars configure serve without CLI args (service mode).""" @staticmethod def _capture_serve(monkeypatch): """Replace the serve coroutine with an args recorder. main() builds its parser on each call and resolves `_serve` from the module namespace, so monkeypatching before the call works. """ captured: dict = {} async def fake_serve(args) -> int: captured["node"] = args.node captured["host"] = args.host captured["port"] = args.port return 0 monkeypatch.setattr("wago_sim.cli._serve", fake_serve) return captured def test_node_and_port_from_env(self, monkeypatch): captured = self._capture_serve(monkeypatch) monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml") monkeypatch.setenv("WAGO_SIM_PORT", "15025") monkeypatch.setenv("WAGO_SIM_HOST", "127.0.0.1") assert main(["serve"]) == 0 assert captured["node"] == "config/pump_node.yaml" assert captured["port"] == 15025 assert captured["host"] == "127.0.0.1" def test_cli_overrides_env(self, monkeypatch): captured = self._capture_serve(monkeypatch) monkeypatch.setenv("WAGO_SIM_NODE", "config/other.yaml") monkeypatch.setenv("WAGO_SIM_PORT", "1111") assert main(["serve", "--node", "config/pump_node.yaml", "--port", "15026"]) == 0 assert captured["node"] == "config/pump_node.yaml" assert captured["port"] == 15026 def test_missing_node_is_an_error(self, monkeypatch): monkeypatch.delenv("WAGO_SIM_NODE", raising=False) with pytest.raises(SystemExit) as excinfo: main(["serve"]) # argparse parser.error exit code assert excinfo.value.code == 2 def test_node_env_only_no_cli_args(self, monkeypatch): """The container CMD is plain `wago-sim serve` — everything env.""" captured = self._capture_serve(monkeypatch) monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml") assert main(["serve"]) == 0 assert captured["node"] == "config/pump_node.yaml" # host/port fall back to defaults assert captured["host"] == "0.0.0.0" assert captured["port"] == 502 class TestBadArgumentHandling: """Bad arguments produce clean errors, exit code 2, no tracebacks.""" def test_no_command_is_usage_error(self): with pytest.raises(SystemExit) as excinfo: main([]) assert excinfo.value.code == 2 def test_unknown_command_is_usage_error(self): with pytest.raises(SystemExit) as excinfo: main(["frobnicate"]) assert excinfo.value.code == 2 def test_unknown_option_is_usage_error(self): with pytest.raises(SystemExit) as excinfo: main(["serve", "--frobnicate"]) assert excinfo.value.code == 2 def test_non_numeric_port_rejected_at_parse(self): with pytest.raises(SystemExit) as excinfo: main(["serve", "--node", "config/pump_node.yaml", "--port", "abc"]) assert excinfo.value.code == 2 @pytest.mark.parametrize("port", ["0", "-1", "65536", "99999"]) def test_port_out_of_range_rejected(self, port): with pytest.raises(SystemExit) as excinfo: main(["serve", "--node", "config/pump_node.yaml", "--port", port]) assert excinfo.value.code == 2 def test_env_port_invalid_rejected(self, monkeypatch, capsys): monkeypatch.setenv("WAGO_SIM_PORT", "banana") monkeypatch.delenv("WAGO_SIM_NODE", raising=False) with pytest.raises(SystemExit) as excinfo: main(["serve"]) assert excinfo.value.code == 2 assert "WAGO_SIM_PORT" in capsys.readouterr().err def test_env_port_out_of_range_rejected(self, monkeypatch): monkeypatch.setenv("WAGO_SIM_PORT", "70000") monkeypatch.delenv("WAGO_SIM_NODE", raising=False) with pytest.raises(SystemExit) as excinfo: main(["serve"]) assert excinfo.value.code == 2 def test_missing_node_file_is_clean_error(self, capsys): with pytest.raises(SystemExit) as excinfo: main(["serve", "--node", "config/does_not_exist.yaml"]) assert excinfo.value.code == 2 err = capsys.readouterr().err assert "wago-sim: error:" in err assert "node config file not found" in err assert "Traceback" not in err def test_missing_scenario_file_is_clean_error(self, capsys): with pytest.raises(SystemExit) as excinfo: main(["validate", "--scenario", "scenarios/nope.yaml"]) assert excinfo.value.code == 2 err = capsys.readouterr().err assert "scenario file not found" in err assert "Traceback" not in err def test_invalid_yaml_is_clean_error(self, capsys, tmp_path): bad = tmp_path / "bad.yaml" bad.write_text("key: [unclosed", encoding="utf-8") with pytest.raises(SystemExit) as excinfo: main(["validate", "--scenario", str(bad)]) assert excinfo.value.code == 2 err = capsys.readouterr().err assert "not valid YAML" in err assert "Traceback" not in err def test_directory_as_yaml_is_clean_error(self, capsys): with pytest.raises(SystemExit) as excinfo: main(["validate", "--scenario", "scenarios"]) assert excinfo.value.code == 2 err = capsys.readouterr().err assert "not a file" in err assert "Traceback" not in err def test_run_missing_required_options_is_usage_error(self): with pytest.raises(SystemExit) as excinfo: main(["run"]) assert excinfo.value.code == 2 class TestReportPathHandling: def test_report_dirs_auto_created(self, tmp_path, capsys): """Missing report parent directories are created before the run.""" from wago_sim.cli import _prepare_report_path target = tmp_path / "a" / "b" / "r.jsonl" result = _prepare_report_path(str(target), "--report-jsonl") assert result.parent.is_dir() def test_unwritable_report_path_is_clean_error(self, tmp_path, capsys): from wago_sim.cli import _prepare_report_path # a *file* used as parent directory -> cannot create blocker = tmp_path / "blocker" blocker.write_text("x", encoding="utf-8") target = blocker / "sub" / "r.jsonl" with pytest.raises(SystemExit) as excinfo: _prepare_report_path(str(target), "--report-jsonl") assert excinfo.value.code == 2 assert "cannot create directory" in capsys.readouterr().err def test_missing_node_is_an_error(self, monkeypatch): monkeypatch.delenv("WAGO_SIM_NODE", raising=False) with pytest.raises(SystemExit) as excinfo: main(["serve"]) assert excinfo.value.code == 2 # argparse error exit code