diff --git a/CHANGELOG.md b/CHANGELOG.md index bd84f88..0a07e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- Tests import the real `paho-mqtt` package instead of stubbing it in + `sys.modules`. Audit-named test files are renamed to topic names. + - The `[tui]` extra now requires Textual 8.x (`textual>=8.0,<9.0`). The previous `<2.0` cap was hiding an 8.x `Select` API change (`Select.NULL` replaced `Select.BLANK`/`False` for no selection). Pilot tests read diff --git a/tests/contracts/test_contract_models.py b/tests/contracts/test_contract_models.py index 28ad77a..c6628e0 100644 --- a/tests/contracts/test_contract_models.py +++ b/tests/contracts/test_contract_models.py @@ -24,15 +24,9 @@ import json import sys from pathlib import Path -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import contracts # noqa: E402 from bambu_cli.contracts import Contract, all_contracts # noqa: E402 @@ -46,16 +40,13 @@ reason="schema generation needs 3.10+ to evaluate `X | None`; runtime does not", ) - # --- the registry ------------------------------------------------------------ - def test_contracts_are_discovered(): found = all_contracts() assert found, "no contracts discovered — all_contracts() derivation is broken" assert len({c.schema_name for c in found}) == len(found), "two contracts claim the same schema_name" - def test_every_schema_file_has_a_contract_and_vice_versa(): """Drift in both directions is a failure. @@ -68,21 +59,17 @@ def test_every_schema_file_has_a_contract_and_vice_versa(): f"schema-only={sorted(on_disk - modelled)}, contract-only={sorted(modelled - on_disk)}" ) - def test_every_contract_declares_a_title(): for contract in all_contracts(): assert contract.schema_title, f"{contract.__name__} has no schema_title" - # --- to_payload semantics ---------------------------------------------------- - def test_unset_optionals_are_omitted_not_nulled(): payload = contracts.Pause(status="paused", command="pause", paused=True).to_payload() assert payload == {"status": "paused", "command": "pause", "paused": True} assert "next_command" not in payload - def test_keep_none_fields_are_emitted_as_null(): # setup reports model/nozzle as null rather than dropping them: a consumer # distinguishes "not configured" from "key absent because of an old version". @@ -97,13 +84,11 @@ def test_keep_none_fields_are_emitted_as_null(): assert payload["model"] is None assert payload["nozzle"] is None - def test_key_order_follows_field_order(): # Agents pattern-match on the leading status/command pair. payload = contracts.Light(status="light_changed", command="light", action="on", changed=True).to_payload() assert list(payload)[:2] == ["status", "command"] - def test_extra_keys_pass_through(): # The schemas allow additional properties; commands add detail beyond the # guaranteed shape and must not have it silently dropped. @@ -112,23 +97,19 @@ def test_extra_keys_pass_through(): ).to_payload(sequence_id="42") assert payload["sequence_id"] == "42" - def test_extra_none_is_dropped_like_a_declared_optional(): payload = contracts.Light( status="light_changed", command="light", action="on", changed=True ).to_payload(irrelevant=None) assert "irrelevant" not in payload - def test_contracts_are_frozen(): light = contracts.Light(status="light_changed", command="light", action="on", changed=True) with pytest.raises(dataclasses.FrozenInstanceError): light.changed = False # type: ignore[misc] - # --- runtime does not need the generator's Python ----------------------------- - def test_contracts_import_without_evaluating_annotations(): """The package must not call get_type_hints() on these models. @@ -144,7 +125,6 @@ def test_contracts_import_without_evaluating_annotations(): f"{contract.__name__} has resolved annotations — something called get_type_hints()" ) - def test_pydantic_is_not_a_runtime_dependency(): """Importing the package must never pull pydantic in. @@ -160,10 +140,8 @@ def test_pydantic_is_not_a_runtime_dependency(): assert out.returncode == 0, out.stderr assert out.stdout.strip() == "False", "importing bambu_cli pulled in pydantic" - # --- generated schemas agree with the models AND with to_payload -------------- - @_needs_generator def test_committed_schemas_match_the_contracts(): """The anti-drift gate, run as a test as well as a CI step.""" @@ -172,7 +150,6 @@ def test_committed_schemas_match_the_contracts(): assert gen_schemas.main(["--check"]) == 0, "docs/schemas is stale — run python scripts/gen_schemas.py" - # One representative instance per contract. Kept explicit rather than # auto-constructed: the point is to check a *realistic* payload shape. SAMPLES = [ @@ -246,11 +223,9 @@ def test_committed_schemas_match_the_contracts(): contracts.Tui(status="error", command="tui", exit_code=5, error="interactive only", failed_step="parse"), ] - def test_samples_cover_every_contract(): assert {type(s).schema_name for s in SAMPLES} == {c.schema_name for c in all_contracts()} - @pytest.mark.parametrize("sample", SAMPLES, ids=lambda s: type(s).schema_name) def test_payload_validates_against_its_generated_schema(sample): """A model is only useful if what it *emits* matches what it *publishes*.""" @@ -260,7 +235,6 @@ def test_payload_validates_against_its_generated_schema(sample): payload = json.loads(json.dumps(sample.to_payload(), default=_as_plain)) _validate(payload, schema) - def _as_plain(obj): """Nested contracts/dataclasses render as plain dicts, same as emit_json sees.""" if isinstance(obj, Contract): diff --git a/tests/contracts/test_schema_validation.py b/tests/contracts/test_schema_validation.py index 23d1d91..f12511d 100644 --- a/tests/contracts/test_schema_validation.py +++ b/tests/contracts/test_schema_validation.py @@ -8,15 +8,9 @@ import json import sys from pathlib import Path -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import bambu # noqa: E402 from bambu_cli.cli import main # noqa: E402 from bambu_cli import utils # noqa: E402 @@ -27,13 +21,11 @@ ROOT = Path(__file__).resolve().parents[2] SCHEMA_DIR = ROOT / "docs" / "schemas" - def _load_schema(name: str) -> dict: path = SCHEMA_DIR / name assert path.is_file(), f"missing schema {path}" return json.loads(path.read_text(encoding="utf-8")) - def _validate(instance, schema, path="$"): """Minimal subset of JSON Schema (type/const/enum/required/properties).""" if "const" in schema: @@ -70,7 +62,6 @@ def _validate(instance, schema, path="$"): extra = set(instance) - set(props) assert not extra, f"{path}: unexpected keys {extra}" - @pytest.fixture(autouse=True) def _reset(): utils._JSON_EMITTED = False @@ -79,7 +70,6 @@ def _reset(): utils._JSON_EMITTED = False utils._LAST_ERROR_PAYLOAD = None - # Which published schema(s) back each `--json`-emitting subcommand. README.md # advertises "every command speaks --json with published schemas", so this map is # what makes that claim enforceable rather than aspirational. @@ -118,7 +108,6 @@ def _reset(): # `--version`, which is a global flag rather than a subcommand. _SHARED_SCHEMAS = {"error_envelope.json", "ok_envelope.json", "version.json"} - def _parser_subcommands(): """Same derivation idiom as scripts/cli_help_smoke.py, deliberately.""" from bambu_cli.cli import build_parser @@ -129,7 +118,6 @@ def _parser_subcommands(): return set(getattr(action, "choices", None) or {}) raise AssertionError("could not derive subcommands from build_parser()") - def test_every_subcommand_has_a_published_schema(): """Derived from the parser, so a new subcommand cannot ship schema-less. @@ -145,7 +133,6 @@ def test_every_subcommand_has_a_published_schema(): for name in names: assert (SCHEMA_DIR / name).is_file(), f"{command}: missing schema {name}" - def test_every_schema_file_is_wellformed_and_self_identifying(): """Each schema parses, declares the required metadata, and its $id matches its filename -- a copy-paste $id is otherwise invisible.""" @@ -159,7 +146,6 @@ def test_every_schema_file_is_wellformed_and_self_identifying(): f"{name}: $id {schema['$id']!r} does not match filename" ) - def test_no_orphan_schema_files(): """Every published schema is reachable from a subcommand or is a shared envelope -- catches a schema left behind after a command is renamed.""" @@ -167,7 +153,6 @@ def test_no_orphan_schema_files(): found = {p.name for p in SCHEMA_DIR.glob("*.json")} assert not (found - mapped), f"unreferenced schema files: {sorted(found - mapped)}" - def test_api_doc_lists_every_schema(): """docs/api.md carries a hand-written schema table that has drifted before. @@ -178,7 +163,6 @@ def test_api_doc_lists_every_schema(): missing = [p.name for p in sorted(SCHEMA_DIR.glob("*.json")) if f"schemas/{p.name}" not in api] assert not missing, f"schemas absent from docs/api.md: {missing}" - def test_version_payload_matches_schema(monkeypatch, tmp_path, capsys): monkeypatch.setattr(sys, "argv", ["plate", "--json", "--version"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) @@ -188,7 +172,6 @@ def test_version_payload_matches_schema(monkeypatch, tmp_path, capsys): _validate(payload, _load_schema("version.json")) assert payload["version"] == VERSION - def test_status_ok_matches_ok_envelope(monkeypatch, tmp_path, capsys): monkeypatch.setattr(sys, "argv", ["plate", "--sim", "status", "--json"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) @@ -198,7 +181,6 @@ def test_status_ok_matches_ok_envelope(monkeypatch, tmp_path, capsys): _validate(payload, _load_schema("ok_envelope.json")) assert payload["command"] == "status" - def test_status_ok_matches_status_schema(monkeypatch, tmp_path, capsys): monkeypatch.setattr(sys, "argv", ["plate", "--sim", "status", "--json"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) @@ -207,7 +189,6 @@ def test_status_ok_matches_status_schema(monkeypatch, tmp_path, capsys): payload = json.loads(capsys.readouterr().out) _validate(payload, _load_schema("status.json")) - def test_setup_error_matches_error_envelope(monkeypatch, tmp_path, capsys): monkeypatch.setattr(sys, "argv", ["plate", "setup", "--json"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) @@ -220,14 +201,12 @@ def test_setup_error_matches_error_envelope(monkeypatch, tmp_path, capsys): assert payload["command"] == "setup" assert payload["failed_step"] == "validate" - def test_status_event_schema_against_builder(): from bambu_cli.protocols.mqtt import _status_event event = _status_event({"gcode_state": "RUNNING", "mc_percent": 10}, "update") _validate(event, _load_schema("status_event.json")) - def _write_valid_config(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -243,7 +222,6 @@ def _write_valid_config(path: Path) -> None: encoding="utf-8", ) - def test_preflight_matches_schema(monkeypatch, tmp_path, capsys): """Missing config still emits a preflight envelope with checks[] (error path).""" monkeypatch.setattr(sys, "argv", ["plate", "preflight", "--json"]) @@ -256,7 +234,6 @@ def test_preflight_matches_schema(monkeypatch, tmp_path, capsys): assert payload["command"] == "preflight" assert isinstance(payload.get("checks"), list) - def test_doctor_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "config.json" _write_valid_config(config_path) @@ -267,7 +244,6 @@ def test_doctor_matches_schema(monkeypatch, tmp_path, capsys): payload = json.loads(capsys.readouterr().out) _validate(payload, _load_schema("doctor.json")) - def test_job_dry_run_matches_schema(monkeypatch, tmp_path, capsys): model = tmp_path / "cube.gcode" model.write_text("; gcode\n") @@ -284,7 +260,6 @@ def test_job_dry_run_matches_schema(monkeypatch, tmp_path, capsys): payload = json.loads(capsys.readouterr().out) _validate(payload, _load_schema("job_ok.json")) - def test_gcode_confirmation_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -298,12 +273,10 @@ def test_gcode_confirmation_matches_schema(monkeypatch, tmp_path, capsys): assert payload["status"] == "confirmation_required" assert payload["sent"] is False - def test_gcode_sent_fixture_matches_schema(): payload = {"status": "sent", "command": "gcode", "gcode": "G28", "sent": True} _validate(payload, _load_schema("gcode.json")) - def test_print_confirmation_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -323,7 +296,6 @@ def test_print_confirmation_matches_schema(monkeypatch, tmp_path, capsys): assert payload["status"] == "confirmation_required" assert payload["printed"] is False - def test_print_started_fixture_matches_schema(): payload = { "status": "print_started", @@ -334,7 +306,6 @@ def test_print_started_fixture_matches_schema(): } _validate(payload, _load_schema("print.json")) - def test_delete_confirmation_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -348,7 +319,6 @@ def test_delete_confirmation_matches_schema(monkeypatch, tmp_path, capsys): assert payload["status"] == "confirmation_required" assert payload["deleted"] is False - def test_stop_confirmation_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -362,12 +332,10 @@ def test_stop_confirmation_matches_schema(monkeypatch, tmp_path, capsys): assert payload["status"] == "confirmation_required" assert payload["stopped"] is False - def test_stop_success_fixture_matches_schema(): payload = {"status": "stopped", "command": "stop", "stopped": True} _validate(payload, _load_schema("stop.json")) - def test_files_listing_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -379,12 +347,10 @@ def test_files_listing_matches_schema(monkeypatch, tmp_path, capsys): _validate(payload, _load_schema("files.json")) assert payload["count"] == len(payload["files"]) - def test_files_empty_listing_matches_schema(): """count/files must still validate when the printer holds nothing.""" _validate({"status": "ok", "command": "files", "count": 0, "files": []}, _load_schema("files.json")) - def test_upload_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -399,7 +365,6 @@ def test_upload_matches_schema(monkeypatch, tmp_path, capsys): assert payload["uploaded"] is True assert payload["remote_name"] == "probe.gcode.3mf" - def test_upload_dry_run_fixture_matches_schema(): payload = { "status": "dry_run_ok", @@ -411,7 +376,6 @@ def test_upload_dry_run_fixture_matches_schema(): } _validate(payload, _load_schema("upload.json")) - def test_setup_summary_matches_schema(): """Built by the real _setup_summary, not a hand-written fixture, so the schema tracks the function rather than someone's memory of it.""" @@ -445,7 +409,6 @@ def test_setup_summary_matches_schema(): assert "access_code" not in payload assert "CODE" not in json.dumps(payload) - def test_delete_success_fixture_matches_schema(): payload = { "status": "deleted", @@ -455,22 +418,18 @@ def test_delete_success_fixture_matches_schema(): } _validate(payload, _load_schema("delete.json")) - def test_light_success_fixture_matches_schema(): payload = {"status": "light_changed", "command": "light", "action": "on", "changed": True} _validate(payload, _load_schema("light.json")) - def test_pause_success_fixture_matches_schema(): payload = {"status": "paused", "command": "pause", "paused": True} _validate(payload, _load_schema("pause.json")) - def test_resume_success_fixture_matches_schema(): payload = {"status": "resumed", "command": "resume", "resumed": True} _validate(payload, _load_schema("resume.json")) - def test_pause_confirmation_fixture_matches_schema(): payload = { "status": "confirmation_required", @@ -480,7 +439,6 @@ def test_pause_confirmation_fixture_matches_schema(): } _validate(payload, _load_schema("pause.json")) - def test_resume_confirmation_fixture_matches_schema(): payload = { "status": "confirmation_required", @@ -490,7 +448,6 @@ def test_resume_confirmation_fixture_matches_schema(): } _validate(payload, _load_schema("resume.json")) - def test_snapshot_success_fixture_matches_schema(): """Hand-written fixture: snapshot requires injecting a real grab_frame + camera TLS stack; the hermetic seam exists (tests/test_snapshot_output.py) but is not @@ -508,7 +465,6 @@ def test_snapshot_success_fixture_matches_schema(): } _validate(payload, _load_schema("snapshot.json")) - def test_device_command_errors_match_error_envelope(monkeypatch, tmp_path, capsys): """Invalid gcode still uses the shared error envelope.""" config_path = tmp_path / "config" / "cfg.json" @@ -522,7 +478,6 @@ def test_device_command_errors_match_error_envelope(monkeypatch, tmp_path, capsy _validate(payload, _load_schema("error_envelope.json")) assert payload["command"] == "gcode" - def test_job_error_matches_job_error_and_error_envelope(monkeypatch, tmp_path, capsys): """Missing source emits the job summary error shape (error_envelope + job fields).""" config_path = tmp_path / "config" / "config.json" @@ -544,7 +499,6 @@ def test_job_error_matches_job_error_and_error_envelope(monkeypatch, tmp_path, c assert payload["failed_step"] == "validate" assert payload["status"] == "error" - def test_config_show_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -562,7 +516,6 @@ def test_config_show_matches_schema(monkeypatch, tmp_path, capsys): # Secrets must never appear in cleartext. assert payload["config"].get("access_code") in (None, "") - def test_config_validate_matches_schema(monkeypatch, tmp_path, capsys): config_path = tmp_path / "config" / "cfg.json" _write_valid_config(config_path) @@ -581,7 +534,6 @@ def test_config_validate_matches_schema(monkeypatch, tmp_path, capsys): assert payload["command"] == "config" assert isinstance(payload.get("checks"), list) - def test_download_error_matches_error_envelope(monkeypatch, tmp_path, capsys): monkeypatch.setattr(sys, "argv", ["plate", "download", "not-a-url", "--json"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) @@ -593,7 +545,6 @@ def test_download_error_matches_error_envelope(monkeypatch, tmp_path, capsys): assert payload["command"] == "download" assert payload["failed_step"] == "validate" - def test_download_success_fixture_matches_schema(): """Success shape from download/downloader._record_download_success. @@ -615,7 +566,6 @@ def test_download_success_fixture_matches_schema(): } _validate(payload, _load_schema("download.json")) - def test_download_archive_success_fixture_matches_schema(): payload = { "status": "downloaded", @@ -630,7 +580,6 @@ def test_download_archive_success_fixture_matches_schema(): } _validate(payload, _load_schema("download.json")) - def test_slice_success_real_output_matches_schema(tmp_path, monkeypatch, capsys): """Slice success envelope captured from real slicer/output.py emit_json via orca stub. @@ -695,7 +644,6 @@ def test_slice_success_real_output_matches_schema(tmp_path, monkeypatch, capsys) ) _validate(payload, _load_schema("slice.json")) - def test_slice_list_settings_matches_schema(monkeypatch, tmp_path, capsys): """`slice --list-settings --json` discovery envelope (agent override vocabulary).""" profiles = tmp_path / "profiles" @@ -733,7 +681,6 @@ def test_slice_list_settings_matches_schema(monkeypatch, tmp_path, capsys): # bookkeeping keys must not leak into the settable surface assert "name" not in payload["process"]["settings"] - def test_slice_error_matches_error_envelope(monkeypatch, tmp_path, capsys): missing = tmp_path / "nope.stl" monkeypatch.setattr(sys, "argv", ["plate", "slice", str(missing), "--json"]) diff --git a/tests/json_contract_base.py b/tests/json_contract_base.py index 35f718d..574db1b 100644 --- a/tests/json_contract_base.py +++ b/tests/json_contract_base.py @@ -17,28 +17,18 @@ import json import sys import zipfile -from unittest.mock import MagicMock import pytest -# paho-mqtt is an optional/heavy dep; stub it the same way other tests do so -# importing the package never fails on environments without it installed. -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - -from bambu_cli import bambu # noqa: E402 -from bambu_cli import utils # noqa: E402 -from bambu_cli.cli import build_parser, main # noqa: E402 - +from bambu_cli import bambu +from bambu_cli import utils +from bambu_cli.cli import build_parser, main # --------------------------------------------------------------------------- # assert_shape: a small, self-contained schema-shape checker (no jsonschema # dependency available/allowed). # --------------------------------------------------------------------------- - def assert_shape(payload, spec, path="$"): """Validate `payload` against a small hand-rolled spec. @@ -73,7 +63,6 @@ def assert_shape(payload, spec, path="$"): for idx, item in enumerate(payload): assert_shape(item, spec["items"], path=f"{path}[{idx}]") - ANY = {} STR = {"type": str} BOOL = {"type": bool} @@ -84,7 +73,6 @@ def assert_shape(payload, spec, path="$"): BASE_OK = {"type": dict, "required": {"status": {"enum": ["ok"]}, "command": STR}} - def base_error_spec(command=None, require_failed_step=True): required = { "status": {"enum": ["error"]}, @@ -96,12 +84,10 @@ def base_error_spec(command=None, require_failed_step=True): required["failed_step"] = STR return {"type": dict, "required": required} - # --------------------------------------------------------------------------- # Harness # --------------------------------------------------------------------------- - @pytest.fixture(autouse=True) def _reset_json_state(): utils._JSON_EMITTED = False @@ -112,7 +98,6 @@ def _reset_json_state(): utils._LAST_ERROR_PAYLOAD = None utils._LAST_DOWNLOAD_PAYLOAD = None - def run_main(monkeypatch, tmp_path, argv, config_path=None): """Drive bambu_cli.cli.main() with a scratch config path so no real on-disk config is ever touched, and return the SystemExit (or None).""" @@ -129,12 +114,10 @@ def run_main(monkeypatch, tmp_path, argv, config_path=None): exc = e return exc - def read_json(capsys): out = capsys.readouterr().out return json.loads(out) - def make_ready_file(tmp_path, name="ready.3mf", content="simulated 3mf content"): path = tmp_path / name path.write_text(content, encoding="utf-8") diff --git a/tests/test_bambu_cli_regressions.py b/tests/test_bambu_cli_regressions.py index e1ddf89..f07d4ef 100644 --- a/tests/test_bambu_cli_regressions.py +++ b/tests/test_bambu_cli_regressions.py @@ -16,7 +16,6 @@ """ import os -import sys import types import inspect import tempfile @@ -25,20 +24,11 @@ import pytest from tests.bambu_test_base import settings_ctx - -# paho-mqtt is an optional/heavy dep; stub it the same way the main suite does so -# importing the package never fails on environments without it installed. -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - -from bambu_cli import bambu # noqa: E402 -from bambu_cli import slicer # noqa: E402 -from bambu_cli.protocols import ftps # noqa: E402 +from bambu_cli import bambu +from bambu_cli import slicer +from bambu_cli.protocols import ftps from bambu_cli.errors import BambuError - def _slice_args(tmpdir, infile): """A plain namespace with every attribute cmd_slice reads via getattr/args.x.""" return types.SimpleNamespace( @@ -59,7 +49,6 @@ def _slice_args(tmpdir, infile): sim=False, ) - def _fake_popen_factory(returncode, stdout="", stderr="", touch_path=None): """Return a class that stands in for subprocess.Popen and yields the given result. @@ -96,7 +85,6 @@ def kill(self): return _FakePopen - def _write_profiles(tmpdir): paths = {} for nm in ("machine.json", "process.json", "filament.json"): @@ -106,12 +94,10 @@ def _write_profiles(tmpdir): paths[nm] = p return paths - # --------------------------------------------------------------------------- # (a) benign GL/thumbnail non-zero exit is treated as success; real errors fail # --------------------------------------------------------------------------- - def _write_valid_3mf(path): """Write a minimal non-corrupt Bambu-style .3mf (zip with expected members).""" import zipfile @@ -124,7 +110,6 @@ def _write_valid_3mf(path): zf.writestr("3D/3dmodel.model", '') zf.writestr("Metadata/plate_1.gcode", "; plate\nG28\n") - def test_a_benign_gl_noise_nonzero_is_success(): """rc=1 with GLFW/skip-thumbnail noise + a valid non-empty .3mf -> returns path.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -165,7 +150,6 @@ def test_a_benign_gl_noise_nonzero_is_success(): assert result == outpath, "benign GL-noise non-zero exit should be treated as success" - def test_a_corrupt_3mf_rejected_despite_benign_gl_noise(): """Non-empty but corrupt/truncated .3mf must fail even with GLFW noise present.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -206,7 +190,6 @@ def test_a_corrupt_3mf_rejected_despite_benign_gl_noise(): code = getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) assert code not in (0, None), f"corrupt .3mf must exit non-zero, got {code!r}" - def test_a_real_error_still_fails(): """rc=1 with 'nothing to be sliced' and no .3mf -> must sys.exit non-zero.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -246,12 +229,10 @@ def exists_side_effect(path): code = getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) assert code not in (0, None), f"real slice error must exit non-zero, got {code!r}" - # --------------------------------------------------------------------------- # (b) FTPS teardown uses close(), never quit() # --------------------------------------------------------------------------- - class _RecordingFtp: """Fake ftp object that records which teardown methods were called.""" @@ -267,7 +248,6 @@ def quit(self): def voidcmd(self, *a, **k): self.calls.append("voidcmd") - def test_b_get_ftp_client_teardown_uses_close_not_quit(): from tests.bambu_test_base import _test_printer @@ -279,7 +259,6 @@ def test_b_get_ftp_client_teardown_uses_close_not_quit(): assert "close" in fake.calls, "get_ftp_client must close the FTP connection" assert "quit" not in fake.calls, "get_ftp_client must NOT call the hanging quit()" - def test_b_get_ftp_client_teardown_uses_close_not_quit_on_error(): from tests.bambu_test_base import _test_printer @@ -291,18 +270,15 @@ def test_b_get_ftp_client_teardown_uses_close_not_quit_on_error(): assert "close" in fake.calls, "__exit__ on error must close the FTP connection" assert "quit" not in fake.calls, "__exit__ must NOT call the hanging quit()" - # --------------------------------------------------------------------------- # (c) download path can resolve _record_download_success (was a NameError) # --------------------------------------------------------------------------- - def test_c_record_download_success_importable(): from bambu_cli.utils import _record_download_success assert callable(_record_download_success) - def test_c_cmd_download_references_record_download_success_without_nameerror(): """The _cmd_download body must reference a *resolvable* name. @@ -317,12 +293,10 @@ def test_c_cmd_download_references_record_download_success_without_nameerror(): exec("from bambu_cli.utils import _record_download_success", ns) assert callable(ns["_record_download_success"]) - # --------------------------------------------------------------------------- # (d) snapshot prefers the direct camera grab and does NOT use Docker # --------------------------------------------------------------------------- - def test_d_snapshot_uses_direct_grab_not_docker(): from bambu_cli.commands.snapshot import cmd_snapshot as _cmd_snapshot @@ -352,12 +326,10 @@ def test_d_snapshot_uses_direct_grab_not_docker(): assert "docker" not in joined.lower(), f"snapshot must not call docker, saw: {cmd!r}" assert mock_run.call_count == 0, "direct grab path must not shell out at all" - # --------------------------------------------------------------------------- # Download hardening regressions (SSRF + size limits) # --------------------------------------------------------------------------- - def test_get_safe_connection_blocks_private_ip(): """DNS resolving to a private address must be refused (SSRF guard).""" import socket @@ -371,7 +343,6 @@ def test_get_safe_connection_blocks_private_ip(): download._get_safe_connection("evil.example.com", 80, 5, None) download._dns_cache.clear() - def test_safe_opener_has_no_default_http_handlers(): """Every hop (including redirects) must connect via the Safe* handlers, and environment proxies must be disabled so validation cannot be bypassed.""" @@ -391,7 +362,6 @@ def test_safe_opener_has_no_default_http_handlers(): for handler in proxy_handlers: assert not handler.proxies - def test_download_enforces_size_limit_mid_stream(tmp_path): """A response with no Content-Length must still be cut off at the limit.""" from bambu_cli import download @@ -416,7 +386,6 @@ def test_download_enforces_size_limit_mid_stream(tmp_path): leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0] assert not leftovers, f"partial download not cleaned up: {leftovers}" - def test_ftps_data_socket_unwrap_is_noop(): """Bambu firmware never answers TLS close-notify on the data channel; ftplib's storbinary would hang in conn.unwrap() until the socket timeout diff --git a/tests/test_audit_fixes_pr4_integration.py b/tests/test_cli_envelopes.py similarity index 97% rename from tests/test_audit_fixes_pr4_integration.py rename to tests/test_cli_envelopes.py index 25436bc..4a01af1 100644 --- a/tests/test_audit_fixes_pr4_integration.py +++ b/tests/test_cli_envelopes.py @@ -1,9 +1,4 @@ -"""Integration-level regression tests for the deep-audit findings that ride real -command paths (doctor capability table, camera error paths, upload dry-run -diagnosis, wizard use_ams wiring, CLI --json envelope on bad global flags and on -interrupt). Sabotage-verified alongside the unit tests in -test_audit_fixes_pr4.py. -""" +"""CLI --json envelopes on doctor, snapshot, upload dry-run, wizard, and interrupt.""" from tests.bambu_test_base import * # noqa: F401,F403 diff --git a/tests/test_audit_config_secrets.py b/tests/test_config_secrets.py similarity index 97% rename from tests/test_audit_config_secrets.py rename to tests/test_config_secrets.py index c7835d6..e5d1f80 100644 --- a/tests/test_audit_config_secrets.py +++ b/tests/test_config_secrets.py @@ -1,21 +1,11 @@ -"""Regression tests for the config/secrets hardening deep audit. - -Each test corresponds to a confirmed audit finding; every one was -sabotage-verified (revert the fix -> the test fails). -""" +"""Config/secrets: insecure_tls coercion, chmod, migration, overwrite.""" import json import os -import sys import tempfile import unittest from argparse import Namespace -from unittest.mock import MagicMock, patch - -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) +from unittest.mock import patch import bambu_cli.config as config import bambu_cli.setup_cmd as setup_cmd @@ -23,10 +13,8 @@ from bambu_cli.errors import BambuError from bambu_cli.setup_cmd import wizard as wizard_mod - # --- Finding 4: insecure_tls strict, fail-CLOSED coercion -------------------- - class TestInsecureTlsCoercion(unittest.TestCase): def test_json_string_false_does_not_disable_tls(self): # A hand-edited "insecure_tls": "false" is a truthy str; it must NOT @@ -51,10 +39,8 @@ def test_non_bool_type_stays_closed_and_warns(self): self.assertIs(s.insecure_tls, False) self.assertTrue(mock_warn.called) - # --- Finding 5: chmod failure degrades to a warning, still reads config ------ - @unittest.skipIf(os.name == "nt", "POSIX permission enforcement only") class TestChmodFailureDegradesToWarning(unittest.TestCase): def setUp(self): @@ -77,10 +63,8 @@ def test_chmod_failure_warns_and_still_loads(self): joined = "\n".join(cm.output) self.assertIn("Could not tighten permissions", joined) - # --- Finding 1: migration leaves no plaintext-secret .bak -------------------- - class TestMigrationNoSecretBackup(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -102,10 +86,8 @@ def test_no_bak_with_inline_secret_after_migration(self): with open(bak, encoding="utf-8") as f: self.assertNotIn("SECRET123", f.read()) - # --- Finding 7: migration is idempotent / retryable across its two writes ----- - class TestMigrationRetryable(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -179,10 +161,8 @@ def test_error_when_target_exists_with_different_contents(self): with open(self.config_path, encoding="utf-8") as f: self.assertEqual(json.load(f)["access_code"], "SECRET123") - # --- Findings 3 & 9: BOM-tolerant config reads -------------------------------- - class TestBomTolerantConfigReads(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -200,10 +180,8 @@ def test_migrate_tolerates_bom(self): result = setup_cmd.migrate_access_code(config_path=self.config_path, access_code_file_path=acf) self.assertEqual(result["status"], "migrated") - # --- Findings 2 & 4: preflight warnings for config conflicts ------------------ - class TestPreflightWarnings(unittest.TestCase): def _run_preflight(self, cfg): with ( @@ -249,10 +227,8 @@ def test_inline_alongside_file_warns(self): self.assertEqual(conflict[0]["status"], "warning") self.assertNotIn("STALE", conflict[0]["message"]) - # --- Finding 6: interactive wizard rejects an empty access code --------------- - class TestInteractiveEmptyAccessCode(unittest.TestCase): def test_empty_input_is_rejected(self): args = Namespace(json=False) @@ -266,10 +242,8 @@ def test_reprompts_then_accepts_valid(self): code = wizard_mod._prompt_interactive_access_code(args, max_attempts=3) self.assertEqual(code, "REAL_CODE") - # --- Finding 8: setup refuses to clobber an existing differing secret file ---- - class TestSetupBothFlagsNoClobber(unittest.TestCase): def _args(self, tmp_path, code_file, force=False): return Namespace( @@ -323,10 +297,8 @@ def test_force_overwrites(self): wizard_mod._cmd_setup_noninteractive(args) self.assertEqual(code_file.read_text(encoding="utf-8").strip(), "NEW_CODE") - # --- Review item 2: overwrite guard fails closed on an unreadable file -------- - class TestOverwriteConflictFailsClosed(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -355,10 +327,8 @@ def test_conflict_when_unreadable(self): self.assertIsNotNone(conflict) self.assertIn("could not be read", conflict) - # --- Review item 3: interactive wizard confirms before clobbering ------------- - class TestInteractiveOverwriteConfirmation(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -385,6 +355,5 @@ def test_identical_file_needs_no_prompt(self): with patch.object(wizard_mod, "_prompt_text", side_effect=AssertionError("should not prompt")): wizard_mod._confirm_interactive_access_code_file_overwrite(args, self.code_file, "SAME") - if __name__ == "__main__": unittest.main() diff --git a/tests/test_download_hardening_p0.py b/tests/test_download_hardening_p0.py index fd5d6f3..08aa6ab 100644 --- a/tests/test_download_hardening_p0.py +++ b/tests/test_download_hardening_p0.py @@ -12,23 +12,17 @@ import os import socket -import sys import types import urllib.error from unittest.mock import MagicMock, patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) - from bambu_cli import bambu # noqa: E402 from bambu_cli import download # noqa: E402 from bambu_cli.constants import EXIT_FILE_ERROR, EXIT_NETWORK_ERROR # noqa: E402 from bambu_cli.errors import BambuError - def _args(tmp_path, url, **overrides): base = dict( url=url, @@ -41,13 +35,11 @@ def _args(tmp_path, url, **overrides): base.update(overrides) return types.SimpleNamespace(**base) - def _mock_opener(mock_resp): opener = MagicMock() opener.open.return_value.__enter__.return_value = mock_resp return opener - def _base_resp(url, body=b"x" * 100, content_type="model/stl", content_disposition=None): resp = MagicMock() resp.geturl.return_value = url @@ -72,12 +64,10 @@ def read(n=-1): resp.read.side_effect = read return resp - # --------------------------------------------------------------------------- # Redirect hop cap # --------------------------------------------------------------------------- - def test_redirect_hop_cap_enforced(): """More than MAX_DOWNLOAD_REDIRECT_HOPS hops must raise a clear URLError.""" req = types.SimpleNamespace( @@ -88,7 +78,6 @@ def test_redirect_hop_cap_enforced(): handler.redirect_request(req, None, 302, "Found", {}, "https://example.com/next") assert "Too many redirects" in str(excinfo.value) - def test_safe_opener_uses_capped_redirect_handler(): import urllib.request @@ -98,12 +87,10 @@ def test_safe_opener_uses_capped_redirect_handler(): # A plain HTTPRedirectHandler (no hop cap) must not also be registered. assert urllib.request.HTTPRedirectHandler not in handler_types - # --------------------------------------------------------------------------- # Redirect revalidation: SSRF + extension # --------------------------------------------------------------------------- - def test_redirect_to_private_ip_blocked(tmp_path): """A redirected connection resolving to a private IP must be refused, same as the initial hop (per-hop SSRF check via _get_safe_connection).""" @@ -114,7 +101,6 @@ def test_redirect_to_private_ip_blocked(tmp_path): download._get_safe_connection("internal.example.com", 443, 5, None) download._dns_cache.clear() - def test_redirected_url_with_unsupported_extension_rejected(tmp_path): """If the response's final (post-redirect) URL has a disallowed extension, the download must be rejected even though the original URL @@ -132,12 +118,10 @@ def test_redirected_url_with_unsupported_extension_rejected(tmp_path): leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0] assert not leftovers, f"partial download not cleaned up: {leftovers}" - # --------------------------------------------------------------------------- # Mid-stream size enforcement / short reads / empty files # --------------------------------------------------------------------------- - def test_mid_stream_oversize_deletes_partial_file(tmp_path): """Even without a Content-Length header, exceeding max_download_mb mid stream must abort and remove the partial file.""" @@ -156,7 +140,6 @@ def test_mid_stream_oversize_deletes_partial_file(tmp_path): leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0] assert not leftovers, f"partial download not cleaned up: {leftovers}" - def test_short_read_detected_and_partial_removed(tmp_path): """Content-Length promised more bytes than the body actually delivered.""" url = "https://example.com/model.stl" @@ -189,7 +172,6 @@ def read(n=-1): leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0] assert not leftovers, f"partial download not cleaned up: {leftovers}" - def test_empty_download_rejected(tmp_path): url = "https://example.com/model.stl" resp = _base_resp(url, body=b"", content_type="model/stl") @@ -209,12 +191,10 @@ def test_empty_download_rejected(tmp_path): leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0] assert not leftovers, f"partial download not cleaned up: {leftovers}" - # --------------------------------------------------------------------------- # Content-Disposition filename hardening # --------------------------------------------------------------------------- - def test_rfc2231_filename_star_decoded_and_sanitized(): """filename* (RFC 2231/5987) must be decoded and then sanitized just like a plain filename (path separators / traversal stripped).""" @@ -225,7 +205,6 @@ def test_rfc2231_filename_star_decoded_and_sanitized(): assert ".." not in result assert result.endswith(".stl") - def test_content_disposition_disallowed_extension_not_smuggled(tmp_path): """A Content-Disposition header must not be able to smuggle a disallowed extension onto the saved file: the allowlist is re-applied to the final diff --git a/tests/test_download_validation_boundary.py b/tests/test_download_validation_boundary.py index 7b78b57..8e810d7 100644 --- a/tests/test_download_validation_boundary.py +++ b/tests/test_download_validation_boundary.py @@ -25,17 +25,10 @@ from __future__ import annotations import json -import sys from argparse import Namespace -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import utils # noqa: E402 from bambu_cli.constants import EXIT_FILE_ERROR # noqa: E402 from bambu_cli.download import validation as V # noqa: E402 @@ -43,7 +36,6 @@ URL = "https://example.com/model.rar" - @pytest.fixture(autouse=True) def _reset_json_state(): utils._JSON_EMITTED = False @@ -52,24 +44,19 @@ def _reset_json_state(): utils._JSON_EMITTED = False utils._LAST_ERROR_PAYLOAD = None - def _args(**kw): return Namespace(json=True, **kw) - def _payload(capsys): return json.loads(capsys.readouterr().out) - # --- unsupported source extension ------------------------------------------- - @pytest.mark.parametrize("value", ["archive.rar", "notes.pdf", "/path/to/render.png", "a.tar", "b.7z"]) def test_clearly_unsupported_extension_is_named(value): """The extension is reported so a caller can say *which* type was refused.""" assert V._known_unsupported_download_extension(value) is not None - @pytest.mark.parametrize( "value", [ @@ -89,12 +76,10 @@ def test_ambiguous_or_supported_extensions_are_not_rejected(value): """Guessing wrong here would refuse a legitimate download.""" assert V._known_unsupported_download_extension(value) is None - def test_extension_is_read_after_percent_decoding(): # A percent-encoded name must not smuggle a refused type past the check. assert V._known_unsupported_download_extension("https://example.com/notes%2Epdf") is not None - def test_reject_unsupported_extension_aborts_with_file_error(capsys): with pytest.raises(BambuError) as excinfo: V._reject_unsupported_download_extension(_args(), URL, None, URL, "archive.rar") @@ -106,19 +91,16 @@ def test_reject_unsupported_extension_aborts_with_file_error(capsys): assert payload["failed_step"] == "validate" assert payload["extension"] == ".rar" - def test_reject_unsupported_extension_honours_the_caller_step(capsys): # The same refusal happens mid-download after a redirect; the step must say so. with pytest.raises(BambuError): V._reject_unsupported_download_extension(_args(), URL, None, URL, "archive.rar", failed_step="download") assert _payload(capsys)["failed_step"] == "download" - def test_reject_unsupported_extension_is_a_no_op_for_supported_types(capsys): V._reject_unsupported_download_extension(_args(), URL, None, URL, "model.stl") assert capsys.readouterr().out == "" - def test_rejection_redacts_credentials_in_the_url(capsys): # Username-only + IP host: exercises the userinfo-stripping path without # writing a literal `user:pass@host` or email into the repo, which @@ -130,10 +112,8 @@ def test_rejection_redacts_credentials_in_the_url(capsys): emitted = capsys.readouterr().out assert "user@" not in emitted, "userinfo leaked into the error envelope" - # --- unsupported content type ------------------------------------------------ - @pytest.mark.parametrize( "content_type", ["image/png", "image/jpeg", "IMAGE/PNG", "image/png; charset=binary", "application/pdf", "text/plain"], @@ -141,7 +121,6 @@ def test_rejection_redacts_credentials_in_the_url(capsys): def test_clearly_unsupported_content_types_are_named(content_type): assert V._known_unsupported_content_type(content_type) is not None - @pytest.mark.parametrize( "content_type", [ @@ -158,11 +137,9 @@ def test_ambiguous_content_types_are_allowed_through(content_type): """Most servers send octet-stream for model files; refusing it breaks downloads.""" assert V._known_unsupported_content_type(content_type) is None - def test_content_type_parameters_are_ignored_when_matching(): assert V._known_unsupported_content_type("image/png; charset=utf-8") == "image/png" - def test_reject_unsupported_content_type_reports_the_download_step(capsys): with pytest.raises(BambuError) as excinfo: V._reject_unsupported_content_type(_args(), URL, None, URL, "image/png") @@ -173,15 +150,12 @@ def test_reject_unsupported_content_type_reports_the_download_step(capsys): assert payload["failed_step"] == "download" assert payload["content_type"] == "image/png" - def test_reject_unsupported_content_type_is_a_no_op_when_ambiguous(capsys): V._reject_unsupported_content_type(_args(), URL, None, URL, "application/octet-stream") assert capsys.readouterr().out == "" - # --- the error envelope is recorded even without --json ---------------------- - def test_failure_detail_is_recorded_for_non_json_callers(capsys): """`job` reads the last error payload to build its own envelope. diff --git a/tests/test_html_link_scraping.py b/tests/test_html_link_scraping.py index cae213d..73765e2 100644 --- a/tests/test_html_link_scraping.py +++ b/tests/test_html_link_scraping.py @@ -7,12 +7,7 @@ network. Ground rules (docs/test-backlog.md): never touch the network. """ -import sys -from unittest.mock import MagicMock, patch - -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) +from unittest.mock import patch from bambu_cli.download import html_links # noqa: E402 from bambu_cli.download.html_links import ( # noqa: E402 @@ -22,11 +17,9 @@ BASE = "https://example.com/models/42" - def _resolve(html, base=BASE): return _resolve_html_model_link(html.encode("utf-8"), base) - # --------------------------------------------------------------------------- # _is_html_content_type # --------------------------------------------------------------------------- @@ -39,7 +32,6 @@ def test_is_html_content_type_variants(): assert not _is_html_content_type(None) assert not _is_html_content_type("") - # --------------------------------------------------------------------------- # Basic extraction # --------------------------------------------------------------------------- @@ -48,19 +40,16 @@ def test_extracts_single_absolute_stl_link(): assert url == "https://cdn.example.com/part.stl" assert name == "part.stl" - def test_relative_link_resolved_against_base(): url, name = _resolve('x') assert url == "https://example.com/files/widget.3mf" assert name == "widget.3mf" - def test_root_relative_link_resolved_against_base(): url, name = _resolve('x') assert url == "https://example.com/d/thing.stl" assert name == "thing.stl" - # --------------------------------------------------------------------------- # Extension-priority selection # --------------------------------------------------------------------------- @@ -69,20 +58,17 @@ def test_prefers_stl_over_zip_by_priority(): url, name = _resolve(html) assert name == "mesh.stl" - def test_prefers_3mf_over_gcode_and_zip(): html = 'gmz' _, name = _resolve(html) assert name == "model.3mf" - def test_first_seen_breaks_priority_tie(): html = '12' url, name = _resolve(html) assert url == "https://example.com/one/first.stl" assert name == "first.stl" - # --------------------------------------------------------------------------- # Filename-hint fallback (path lacks a usable extension) # --------------------------------------------------------------------------- @@ -91,24 +77,20 @@ def test_filename_hint_used_when_path_has_no_extension(): assert url == "https://example.com/download?id=5" assert name == "model.3mf" - def test_hint_ignored_when_path_extension_already_valid(): url, name = _resolve('x') assert name == "real.stl" - def test_data_attributes_are_scanned(): url, name = _resolve('
') assert url == "https://cdn.example.com/z.obj" assert name == "z.obj" - def test_self_closing_tag_link_extracted(): url, name = _resolve('') assert url == "https://example.com/imgs/scan.stl" assert name == "scan.stl" - # --------------------------------------------------------------------------- # Rejection paths # --------------------------------------------------------------------------- @@ -121,24 +103,19 @@ def test_javascript_mailto_data_hash_schemes_rejected(): ) assert _resolve(html) == (None, None) - def test_non_http_scheme_rejected(): assert _resolve('x') == (None, None) - def test_unsupported_extension_rejected(): assert _resolve('x') == (None, None) - def test_empty_page_returns_none(): assert _resolve_html_model_link(b"", BASE) == (None, None) assert _resolve_html_model_link(None, BASE) == (None, None) - def test_no_candidates_returns_none(): assert _resolve("

nothing here

") == (None, None) - # --------------------------------------------------------------------------- # Dedup + scan-limit truncation # --------------------------------------------------------------------------- @@ -148,7 +125,6 @@ def test_identical_links_deduped_still_resolve(): assert url == "https://example.com/dup/part.stl" assert name == "part.stl" - def test_scan_limit_truncates_tail_links(): # A valid link that sits entirely past the scan window must be ignored. with patch.object(html_links, "HTML_LINK_SCAN_LIMIT", 64): @@ -156,13 +132,11 @@ def test_scan_limit_truncates_tail_links(): html = padding + 'x' assert _resolve(html) == (None, None) - def test_link_within_scan_limit_is_kept(): with patch.object(html_links, "HTML_LINK_SCAN_LIMIT", 4096): _, name = _resolve('x') assert name == "kept.stl" - def test_malformed_bytes_do_not_raise(): # Invalid UTF-8 is decoded with errors="replace"; must not raise. url, name = _resolve_html_model_link(b"\xff\xfex", BASE) diff --git a/tests/test_interactive_core.py b/tests/test_interactive_core.py index 60b459e..a9f5a9f 100644 --- a/tests/test_interactive_core.py +++ b/tests/test_interactive_core.py @@ -10,24 +10,16 @@ import argparse import os -import sys import zipfile -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import context as _context # noqa: E402 from bambu_cli import utils # noqa: E402 from bambu_cli.context import RuntimeContext, Settings # noqa: E402 from bambu_cli.errors import BambuError # noqa: E402 from bambu_cli.interactive import core # noqa: E402 - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() @@ -36,7 +28,6 @@ def _reset_context(): utils._LAST_ERROR_PAYLOAD = None utils._LAST_DOWNLOAD_PAYLOAD = None - def _install_ready_settings(tmp_path, **overrides): from dataclasses import replace @@ -56,13 +47,11 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings)) return settings - def _make_stl(tmp_path, name="cube.stl"): p = tmp_path / name p.write_text("solid cube\nendsolid cube\n", encoding="utf-8") return str(p) - def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): p = tmp_path / name with zipfile.ZipFile(p, "w") as zf: @@ -75,14 +64,12 @@ def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): ) return str(p) - def _args(**overrides): ns = argparse.Namespace(cmd="tui", json=False, sim=False, verbose=False) for k, v in overrides.items(): setattr(ns, k, v) return ns - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -95,19 +82,16 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - # --------------------------------------------------------------------------- # validate_source # --------------------------------------------------------------------------- - def test_validate_source_accepts_local_model(tmp_path): stl = _make_stl(tmp_path) source, error = core.validate_source(f" {stl} ") assert source == stl assert error is None - def test_validate_source_rejects_empty_missing_dir_and_extension(tmp_path): assert core.validate_source("")[1] == "Please enter a URL or file path." assert "File not found" in core.validate_source(str(tmp_path / "nope.stl"))[1] @@ -116,31 +100,26 @@ def test_validate_source_rejects_empty_missing_dir_and_extension(tmp_path): bad.write_text("x", encoding="utf-8") assert "Unsupported file type" in core.validate_source(str(bad))[1] - def test_validate_source_rejects_leading_dash(tmp_path): source, error = core.validate_source("-foo.stl") assert source is None assert error is not None and error.startswith("Source cannot start with '-'") - def test_validate_source_accepts_http_url(): source, error = core.validate_source("https://example.com/cube.stl") assert error is None assert source == "https://example.com/cube.stl" - # --------------------------------------------------------------------------- # AMS detection # --------------------------------------------------------------------------- - def test_match_material_preset_maps_known_and_unknown(): assert core.match_material_preset("pla") == "PLA" assert core.match_material_preset(" abs ") == "ABS" assert core.match_material_preset("PLA-CF") is None assert core.match_material_preset(None) is None - def test_detect_ams_material_records_slot_from_callback(): def detector(args, on_active_slot=None): on_active_slot(3) @@ -149,12 +128,10 @@ def detector(args, on_active_slot=None): material, slot = core.detect_ams_material(detector, _args()) assert (material, slot) == ("PETG", 3) - def test_detect_ams_material_supports_single_arg_test_seams(): material, slot = core.detect_ams_material(lambda args: "PLA", _args()) assert (material, slot) == ("PLA", None) - def test_detect_ams_material_drops_unknown_material_and_its_slot(): def detector(args, on_active_slot=None): on_active_slot(2) @@ -162,7 +139,6 @@ def detector(args, on_active_slot=None): assert core.detect_ams_material(detector, _args()) == (None, None) - def test_read_loaded_ams_material_swallows_errors(tmp_path, monkeypatch): _install_ready_settings(tmp_path) @@ -173,17 +149,14 @@ def status(self): monkeypatch.setattr("bambu_cli.context.RuntimeContext.printer", lambda self: BoomPrinter()) assert core.read_loaded_ams_material(_args()) is None - # --------------------------------------------------------------------------- # Preflight # --------------------------------------------------------------------------- - def test_preflight_problem_none_when_ready(tmp_path): _install_ready_settings(tmp_path) assert core.preflight_problem(_args()) is None - def test_preflight_problem_reports_unconfigured_printer(tmp_path): _install_ready_settings(tmp_path, printer_ip="0.0.0.0") problem = core.preflight_problem(_args()) @@ -191,7 +164,6 @@ def test_preflight_problem_reports_unconfigured_printer(tmp_path): # --sim gets past the unconfigured-IP gate (the slicer checks still apply). assert core.preflight_problem(_args(sim=True)) is None - def test_preflight_problem_reports_missing_profiles(tmp_path): settings = _install_ready_settings(tmp_path) import shutil @@ -201,12 +173,10 @@ def test_preflight_problem_reports_missing_profiles(tmp_path): assert problem is not None assert "profiles" in problem.lower() - # --------------------------------------------------------------------------- # run_prepare_pipeline # --------------------------------------------------------------------------- - def test_run_prepare_pipeline_downloads_then_slices(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -228,7 +198,6 @@ def test_run_prepare_pipeline_downloads_then_slices(tmp_path): assert state.printable_path == sliced assert state.sliced is True - def test_run_prepare_pipeline_local_presliced_skips_slicer(tmp_path): _install_ready_settings(tmp_path) presliced = _sliced_3mf(tmp_path, name="ready.gcode.3mf") @@ -243,7 +212,6 @@ def test_run_prepare_pipeline_local_presliced_skips_slicer(tmp_path): assert state.sliced is False assert state.printable_path == presliced - def test_run_prepare_pipeline_extracts_local_zip_then_slices(tmp_path): _install_ready_settings(tmp_path) bundle = tmp_path / "bundle.zip" @@ -262,7 +230,6 @@ def test_run_prepare_pipeline_extracts_local_zip_then_slices(tmp_path): assert state.sliced is True assert len(slicer.calls) == 1 - def test_run_prepare_pipeline_aborts_when_download_returns_nothing(tmp_path): _install_ready_settings(tmp_path) steps = core.GoSteps(download=Recorder(return_value=None), slice=Recorder()) @@ -271,7 +238,6 @@ def test_run_prepare_pipeline_aborts_when_download_returns_nothing(tmp_path): core.run_prepare_pipeline(steps, state, str(tmp_path)) assert ei.value.exit_code == 3 - def test_run_prepare_pipeline_annotates_slicer_failure_with_next_command(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -284,12 +250,10 @@ def test_run_prepare_pipeline_annotates_slicer_failure_with_next_command(tmp_pat core.run_prepare_pipeline(steps, state, str(tmp_path)) assert ei.value.next_command == ["slice", stl, "-v"] - # --------------------------------------------------------------------------- # preview_rows # --------------------------------------------------------------------------- - def test_preview_rows_for_sliced_model(tmp_path): _install_ready_settings(tmp_path) sliced = _sliced_3mf(tmp_path) @@ -302,7 +266,6 @@ def test_preview_rows_for_sliced_model(tmp_path): assert "Supports: yes" in rows["Material"] assert "1h" in rows["Estimate"] or "min" in rows["Estimate"] - def test_preview_rows_presliced_flags_material_not_applied(tmp_path): _install_ready_settings(tmp_path) presliced = _sliced_3mf(tmp_path, name="ready.gcode.3mf") @@ -311,7 +274,6 @@ def test_preview_rows_presliced_flags_material_not_applied(tmp_path): assert rows["Material"] == core.PRESLICED_MATERIAL_LINE assert "PETG" not in rows["Material"] - def test_preview_rows_gcode_has_no_estimate(tmp_path): _install_ready_settings(tmp_path) gcode = tmp_path / "part.gcode" @@ -320,7 +282,6 @@ def test_preview_rows_gcode_has_no_estimate(tmp_path): rows = dict(core.preview_rows(state, str(gcode))) assert rows["Estimate"] == "estimate unavailable (pre-sliced file)" - def test_preview_rows_unreadable_estimate(tmp_path): _install_ready_settings(tmp_path) empty = tmp_path / "empty.gcode.3mf" @@ -330,12 +291,10 @@ def test_preview_rows_unreadable_estimate(tmp_path): rows = dict(core.preview_rows(state, str(empty))) assert "Couldn't read a time estimate" in rows["Estimate"] - # --------------------------------------------------------------------------- # build_job_namespace — the only place confirm=True can be set # --------------------------------------------------------------------------- - def test_build_job_namespace_carries_confirm_and_flags(tmp_path): _install_ready_settings(tmp_path) sliced = _sliced_3mf(tmp_path) @@ -346,7 +305,6 @@ def test_build_job_namespace_carries_confirm_and_flags(tmp_path): assert ns.sim is True assert ns.verbose is True - def test_build_job_namespace_sets_ams_only_when_detected_material_kept(tmp_path): _install_ready_settings(tmp_path) sliced = _sliced_3mf(tmp_path) @@ -366,19 +324,16 @@ def test_build_job_namespace_sets_ams_only_when_detected_material_kept(tmp_path) ns = core.build_job_namespace(slotless, _args(), confirm=False) assert not getattr(ns, "use_ams", False) - # --------------------------------------------------------------------------- # workdir hygiene # --------------------------------------------------------------------------- - def test_under_workdir(): assert core.under_workdir(os.path.join("/tmp", "wd", "a.stl"), os.path.join("/tmp", "wd")) assert core.under_workdir(os.path.join("/tmp", "wd"), os.path.join("/tmp", "wd")) assert not core.under_workdir(os.path.join("/tmp", "other", "a.stl"), os.path.join("/tmp", "wd")) assert not core.under_workdir("/tmp/a.stl", None) - def test_make_workdir_and_cleanup_workdir(tmp_path): workdir = core.make_workdir(prefix="bambu-core-test-") assert os.path.isdir(workdir) @@ -388,7 +343,6 @@ def test_make_workdir_and_cleanup_workdir(tmp_path): # Idempotent: a second cleanup is a no-op, not an error. core.cleanup_workdir(state) - def test_cleanup_workdir_keeps_a_preserved_file(tmp_path, monkeypatch): workdir = core.make_workdir(prefix="bambu-core-test-") kept = os.path.join(workdir, "keep.3mf") @@ -400,7 +354,6 @@ def test_cleanup_workdir_keeps_a_preserved_file(tmp_path, monkeypatch): core.cleanup_workdir(core.WizardState(workdir=workdir)) assert not os.path.exists(workdir) - def test_cleanup_workdir_honors_keep_env(monkeypatch): workdir = core.make_workdir(prefix="bambu-core-test-") monkeypatch.setenv("BAMBU_KEEP_WORKDIR", "1") @@ -409,7 +362,6 @@ def test_cleanup_workdir_honors_keep_env(monkeypatch): monkeypatch.delenv("BAMBU_KEEP_WORKDIR") core.cleanup_workdir(core.WizardState(workdir=workdir)) - def test_preserve_printable_leaves_user_file_in_place(tmp_path): presliced = _sliced_3mf(tmp_path, name="mine.gcode.3mf") workdir = str(tmp_path / "work") @@ -418,7 +370,6 @@ def test_preserve_printable_leaves_user_file_in_place(tmp_path): assert core.preserve_printable(state) == presliced assert os.path.exists(presliced) - def test_preserve_printable_moves_workdir_file_into_cwd(tmp_path): workdir = str(tmp_path / "work") os.makedirs(workdir) @@ -436,17 +387,14 @@ def test_preserve_printable_moves_workdir_file_into_cwd(tmp_path): assert os.path.dirname(os.path.abspath(kept)) == str(cwd) assert not os.path.exists(printable) - def test_preserve_printable_returns_none_without_a_file(tmp_path): assert core.preserve_printable(core.WizardState()) is None assert core.preserve_printable(core.WizardState(printable_path=str(tmp_path / "gone.3mf"))) is None - # --------------------------------------------------------------------------- # GoSteps defaults resolve to the real commands # --------------------------------------------------------------------------- - def test_gosteps_defaults_resolve_to_real_collaborators(): from bambu_cli import commands @@ -457,23 +405,19 @@ def test_gosteps_defaults_resolve_to_real_collaborators(): assert steps.get_setup() is commands.cmd_setup assert steps.get_ams_material() is core.read_loaded_ams_material - def test_gosteps_injection_wins(): sentinel = object() steps = core.GoSteps(download=sentinel, slice=sentinel, job=sentinel, setup=sentinel, ams_material=sentinel) assert steps.get_download() is sentinel assert steps.get_ams_material() is sentinel - # --------------------------------------------------------------------------- # SliceOverrides — the wizard must be untouched by their existence # --------------------------------------------------------------------------- - def test_wizard_state_starts_with_no_overrides(): assert core.WizardState().overrides.is_empty() - def test_run_prepare_pipeline_passes_the_untouched_namespace_when_no_overrides(tmp_path): """`plate go` byte-identity: the slice namespace is what it always was.""" _install_ready_settings(tmp_path) @@ -495,7 +439,6 @@ def capture(ns=None, **kwargs): expected = _slice_args_for_job(stl, preset_to_job_args("PLA", "standard", False, state.source), str(tmp_path)) assert seen["vars"] == vars(expected) - def test_run_prepare_pipeline_applies_overrides_when_present(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -508,7 +451,6 @@ def test_run_prepare_pipeline_applies_overrides_when_present(tmp_path): assert slicer.calls[0].walls == 5 assert slicer.calls[0].set_filament == ["filament_flow_ratio=0.9"] - def test_preview_rows_gain_an_overrides_line_only_when_set(tmp_path): _install_ready_settings(tmp_path) sliced = _sliced_3mf(tmp_path) diff --git a/tests/test_interactive_presets.py b/tests/test_interactive_presets.py index 1138777..9d415d9 100644 --- a/tests/test_interactive_presets.py +++ b/tests/test_interactive_presets.py @@ -2,16 +2,9 @@ from __future__ import annotations -import sys -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.cli import build_parser # noqa: E402 from bambu_cli.constants import ( # noqa: E402 MAX_BED_TEMP_C, @@ -21,19 +14,16 @@ ) from bambu_cli.interactive.presets import MATERIAL_PRESETS, QUALITY_PRESETS, preset_to_job_args # noqa: E402 - # --------------------------------------------------------------------------- # MATERIAL_PRESETS schema # --------------------------------------------------------------------------- - def test_every_material_has_nozzle_and_bed_temp(): for name, preset in MATERIAL_PRESETS.items(): assert "nozzle_temp" in preset, f"{name} missing nozzle_temp" assert "bed_temp" in preset, f"{name} missing bed_temp" assert "filament" in preset, f"{name} missing filament" - @pytest.mark.parametrize("name,preset", MATERIAL_PRESETS.items()) def test_nozzle_temp_in_range(name, preset): assert MIN_NOZZLE_TEMP_C < preset["nozzle_temp"] <= MAX_NOZZLE_TEMP_C, ( @@ -41,14 +31,12 @@ def test_nozzle_temp_in_range(name, preset): f"[{MIN_NOZZLE_TEMP_C}, {MAX_NOZZLE_TEMP_C}]" ) - @pytest.mark.parametrize("name,preset", MATERIAL_PRESETS.items()) def test_bed_temp_in_range(name, preset): assert MIN_BED_TEMP_C <= preset["bed_temp"] <= MAX_BED_TEMP_C, ( f"{name}: bed_temp {preset['bed_temp']} outside [{MIN_BED_TEMP_C}, {MAX_BED_TEMP_C}]" ) - # --------------------------------------------------------------------------- # Filament substring resolution (regression: see MATERIAL_PRESETS comment) # --------------------------------------------------------------------------- @@ -82,7 +70,6 @@ def test_bed_temp_in_range(name, preset): "TPU": "Bambu TPU 95A @base.json", } - @pytest.mark.parametrize("material", sorted(MATERIAL_PRESETS)) def test_material_filament_substrings_are_unambiguous(material): """Each preset substring must match exactly ONE real @base profile. @@ -99,29 +86,24 @@ def test_material_filament_substrings_are_unambiguous(material): assert len(hits) == 1, f"{material}: substring {requested!r} matched {len(hits)} profiles: {hits}" assert hits[0] == _EXPECTED_RESOLUTION[material] - # --------------------------------------------------------------------------- # QUALITY_PRESETS schema # --------------------------------------------------------------------------- VALID_QUALITY_VALUES = {"draft", "standard", "high"} - def test_quality_presets_valid_values(): for name, preset in QUALITY_PRESETS.items(): assert preset["quality"] in VALID_QUALITY_VALUES, f"{name}: unexpected quality value '{preset['quality']}'" - # --------------------------------------------------------------------------- # preset_to_job_args # --------------------------------------------------------------------------- - def test_preset_to_job_args_source_set(): ns = preset_to_job_args("PLA", "standard", False, "model.stl") assert ns.source == "model.stl" - def test_preset_to_job_args_has_all_job_keys(): """Pin test: result must have at least all attributes from build_parser job defaults.""" reference = build_parser().parse_args(["job", "dummy.stl"]) @@ -133,7 +115,6 @@ def test_preset_to_job_args_has_all_job_keys(): missing = ref_keys - result_keys assert not missing, f"preset_to_job_args result missing keys: {missing}" - def test_preset_pla_standard(): ns = preset_to_job_args("PLA", "standard", False, "file.stl") assert ns.nozzle_temp == 220 @@ -141,20 +122,17 @@ def test_preset_pla_standard(): assert ns.quality == "standard" assert ns.filament == "Bambu PLA Basic @base" - def test_preset_petg_fine(): ns = preset_to_job_args("PETG", "fine", False, "file.stl") assert ns.quality == "high" assert ns.nozzle_temp == 255 assert ns.bed_temp == 70 - def test_supports_enabled(): ns = preset_to_job_args("PLA", "standard", True, "f.stl") assert ns.supports is True assert ns.support_type == "tree" - def test_supports_disabled(): ns = preset_to_job_args("PLA", "standard", False, "f.stl") assert ns.supports is False diff --git a/tests/test_interactive_session.py b/tests/test_interactive_session.py index 291a358..30bcda9 100644 --- a/tests/test_interactive_session.py +++ b/tests/test_interactive_session.py @@ -12,15 +12,9 @@ import sys import zipfile from dataclasses import replace -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import context as _context # noqa: E402 from bambu_cli import utils # noqa: E402 from bambu_cli.context import RuntimeContext, Settings # noqa: E402 @@ -32,7 +26,6 @@ # Test doubles # --------------------------------------------------------------------------- - class ScriptedPrompts: """A prompt layer that replays a scripted list of answers. @@ -64,7 +57,6 @@ def confirm(self, message, *, default=False): def print(self, message=""): self.printed.append(message) - class Recorder: """A callable that records the namespace it was called with and returns a value.""" @@ -79,14 +71,12 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _tty(monkeypatch): """cmd_go requires a TTY; pretend stdin is one for the state-machine tests.""" monkeypatch.setattr(sys.stdin, "isatty", lambda: True) yield - @pytest.fixture(autouse=True) def _reset_context(): """Isolate the process-wide RuntimeContext between tests.""" @@ -97,7 +87,6 @@ def _reset_context(): utils._LAST_ERROR_PAYLOAD = None utils._LAST_DOWNLOAD_PAYLOAD = None - def _install_ready_settings(tmp_path, **overrides): """Install a RuntimeContext whose preflight passes: real orca exe + profiles dir.""" orca = tmp_path / "orca" @@ -116,13 +105,11 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings)) return settings - def _make_stl(tmp_path, name="cube.stl"): p = tmp_path / name p.write_text("solid cube\nendsolid cube\n", encoding="utf-8") return str(p) - def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): """A minimal sliced .3mf carrying a parseable slice_info.config estimate.""" p = tmp_path / name @@ -136,7 +123,6 @@ def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): ) return str(p) - def _make_zip(tmp_path, member="model.stl", name="bundle.zip"): """A local .zip carrying one sliceable model member.""" p = tmp_path / name @@ -144,19 +130,16 @@ def _make_zip(tmp_path, member="model.stl", name="bundle.zip"): zf.writestr(member, "solid cube\nendsolid cube\n") return str(p) - def _args(**overrides): ns = argparse.Namespace(cmd="go", source=None, json=False, sim=False) for k, v in overrides.items(): setattr(ns, k, v) return ns - # --------------------------------------------------------------------------- # Happy path # --------------------------------------------------------------------------- - def test_happy_path_calls_download_slice_job_in_order(tmp_path, monkeypatch): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -191,7 +174,6 @@ def test_happy_path_calls_download_slice_job_in_order(tmp_path, monkeypatch): assert job.calls[0].confirm is True assert any("Printing" in m for m in prompts.printed) - def test_local_file_source_skips_download(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -208,7 +190,6 @@ def test_local_file_source_skips_download(tmp_path): assert len(slicer.calls) == 1 assert len(job.calls) == 1 - def test_positional_source_skips_first_prompt(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -219,12 +200,10 @@ def test_positional_source_skips_first_prompt(tmp_path): # No 'text:' prompt was issued because the positional was used. assert not any(a.startswith("text:") for a in prompts.asked) - # --------------------------------------------------------------------------- # Confirm gate (SABOTAGE-VERIFIED) # --------------------------------------------------------------------------- - def test_decline_at_confirm_offers_upload_only(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -249,7 +228,6 @@ def test_decline_at_confirm_offers_upload_only(tmp_path): assert len(job.calls) == 1 assert job.calls[0].confirm is False - def test_decline_both_calls_nothing_and_keeps_file(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -270,12 +248,10 @@ def test_decline_both_calls_nothing_and_keeps_file(tmp_path): assert job.calls == [] # nothing sent assert any("Nothing sent" in m for m in prompts.printed) - # --------------------------------------------------------------------------- # Cancellation (Ctrl-C / EOF) at each prompt -> exit 5 # --------------------------------------------------------------------------- - @pytest.mark.parametrize( "script", [ @@ -302,12 +278,10 @@ def test_cancel_at_each_step_exits_5(tmp_path, script, capsys): assert ei.value.exit_code == 5 assert "cancelled" in capsys.readouterr().err.lower() - # --------------------------------------------------------------------------- # Preflight: unconfigured printer -> setup offer # --------------------------------------------------------------------------- - def test_unconfigured_printer_offers_setup(tmp_path): # Start unconfigured (printer_ip == sentinel); setup "fixes" it. configured = _install_ready_settings(tmp_path) @@ -347,7 +321,6 @@ def fake_load_config(**kwargs): assert len(setup.calls) == 1 - def test_decline_setup_exits_config_error(tmp_path): configured = _install_ready_settings(tmp_path) _context.set_current(RuntimeContext(settings=replace(configured, printer_ip="0.0.0.0"))) @@ -357,12 +330,10 @@ def test_decline_setup_exits_config_error(tmp_path): cmd_go(_args(), GoDeps(prompts=prompts, steps=steps)) assert ei.value.exit_code == 1 - # --------------------------------------------------------------------------- # Preflight: missing slicer -> exit 1 BEFORE any prompt # --------------------------------------------------------------------------- - def test_missing_slicer_exits_1_before_any_prompt(tmp_path): configured = _install_ready_settings(tmp_path) _context.set_current(RuntimeContext(settings=replace(configured, orca_slicer="/no/such/orca"))) @@ -373,12 +344,10 @@ def test_missing_slicer_exits_1_before_any_prompt(tmp_path): assert ei.value.exit_code == 1 assert prompts.asked == [] # no prompt was issued - # --------------------------------------------------------------------------- # Bad URL re-prompt x3 -> exit 3 # --------------------------------------------------------------------------- - def test_bad_url_reprompt_three_times_exits_3(tmp_path): _install_ready_settings(tmp_path) prompts = ScriptedPrompts( @@ -395,12 +364,10 @@ def test_bad_url_reprompt_three_times_exits_3(tmp_path): # exactly 3 source prompts were issued assert sum(1 for a in prompts.asked if a.startswith("text:")) == 3 - # --------------------------------------------------------------------------- # --sim end-to-end reaching "printed" through the injected prompt layer # --------------------------------------------------------------------------- - def test_sim_flag_propagates_to_job_namespace(tmp_path): """Under --sim the fake job step must receive sim=True and confirm=True.""" _install_ready_settings(tmp_path) @@ -424,7 +391,6 @@ def fake_job(ns=None, **kwargs): cmd_go(_args(sim=True), GoDeps(prompts=prompts, steps=steps)) assert printed["value"] is True - def test_pipeline_runs_download_then_slice_then_job_in_order(tmp_path): """A shared sequence log proves download -> slice -> job happen in that order.""" _install_ready_settings(tmp_path) @@ -449,7 +415,6 @@ def _run(ns=None, **kwargs): cmd_go(_args(), GoDeps(prompts=prompts, steps=steps)) assert sequence == ["download", "slice", "job"] - def test_happy_path_carries_temps_into_slice_namespace(tmp_path): """The PLA preset temps (220/60->55) reach the slice namespace, not just filament/quality.""" _install_ready_settings(tmp_path) @@ -463,7 +428,6 @@ def test_happy_path_carries_temps_into_slice_namespace(tmp_path): assert slice_ns.nozzle_temp == 220 assert slice_ns.bed_temp == 55 - def test_true_sim_e2e_reaches_printed_through_real_cmd_job(tmp_path): """TRUE end-to-end: real cmd_job under ctx.simulation, only the slicer faked. @@ -498,12 +462,10 @@ def fake_slice(ns=None, **kwargs): cmd_go(_args(sim=True), GoDeps(prompts=prompts, steps=steps)) assert any("Printing" in m for m in prompts.printed) - # --------------------------------------------------------------------------- # --json + non-TTY behavior # --------------------------------------------------------------------------- - def test_json_mode_emits_error_envelope_and_exits_5(tmp_path, capsys): _install_ready_settings(tmp_path) with pytest.raises(BambuError) as ei: @@ -517,7 +479,6 @@ def test_json_mode_emits_error_envelope_and_exits_5(tmp_path, capsys): assert payload["exit_code"] == 5 assert payload["failed_step"] == "parse" - def test_non_tty_stdin_aborts_exit_5(tmp_path, monkeypatch): _install_ready_settings(tmp_path) monkeypatch.setattr(sys.stdin, "isatty", lambda: False) @@ -526,13 +487,11 @@ def test_non_tty_stdin_aborts_exit_5(tmp_path, monkeypatch): assert ei.value.exit_code == 5 assert "interactive" in str(ei.value) - # --------------------------------------------------------------------------- # CLI-level routing: `plate go` reaches the handler past the DNS/network gate, # and `plate go --json` errors out (exit 5) even with an unconfigured printer. # --------------------------------------------------------------------------- - def test_cli_go_json_exits_5_even_unconfigured(monkeypatch, tmp_path, capsys): from bambu_cli.cli import main @@ -548,7 +507,6 @@ def test_cli_go_json_exits_5_even_unconfigured(monkeypatch, tmp_path, capsys): assert payload["command"] == "go" assert payload["failed_step"] == "parse" - def test_cli_go_non_tty_exits_5(monkeypatch, tmp_path): from bambu_cli.cli import main @@ -560,13 +518,11 @@ def test_cli_go_non_tty_exits_5(monkeypatch, tmp_path): main() assert ei.value.code == 5 - # --------------------------------------------------------------------------- # BLOCKER regression: a local .zip must be extracted and sliced with the user's # preset, NOT fall to the printer-ready branch and print at PLA defaults. # --------------------------------------------------------------------------- - def test_local_zip_extracts_and_slices_with_chosen_material(tmp_path): """User picks PETG + a local .zip: the SLICE namespace must carry PETG/255/70. @@ -596,7 +552,6 @@ def test_local_zip_extracts_and_slices_with_chosen_material(tmp_path): # The job runs on the SLICED .3mf, never the raw zip. assert job.calls[0].source == sliced - def test_local_zip_without_model_member_aborts(tmp_path): """A .zip with no supported member fails cleanly before any print.""" _install_ready_settings(tmp_path) @@ -611,13 +566,11 @@ def test_local_zip_without_model_member_aborts(tmp_path): assert ei.value.exit_code == 3 assert job.calls == [] - # --------------------------------------------------------------------------- # MAJOR regression: decline-both must not relocate / overwrite a user's own # pre-sliced file. Only files inside our temp workdir get moved into cwd. # --------------------------------------------------------------------------- - def test_decline_both_leaves_user_presliced_file_in_place(tmp_path): """A user-supplied local .3mf (never in the workdir) stays exactly where it is.""" _install_ready_settings(tmp_path) @@ -643,7 +596,6 @@ def test_decline_both_leaves_user_presliced_file_in_place(tmp_path): assert not os.path.exists(cwd / "mine.gcode.3mf") assert any(user_file in m for m in prompts.printed) - def test_decline_both_relocates_workdir_file_without_clobbering(tmp_path): """A file inside the temp workdir is moved to cwd, never overwriting a same-named file.""" _install_ready_settings(tmp_path) @@ -681,12 +633,10 @@ def fake_slice(ns=None, **kwargs): assert "cube.gcode-1.3mf" in kept_line assert os.path.exists(cwd / "cube.gcode-1.3mf") - # --------------------------------------------------------------------------- # MINOR regression: pre-sliced source flags "material settings not applied". # --------------------------------------------------------------------------- - def test_presliced_source_preview_notes_material_not_applied(tmp_path): _install_ready_settings(tmp_path) presliced = _sliced_3mf(tmp_path, name="ready.gcode.3mf") @@ -697,7 +647,6 @@ def test_presliced_source_preview_notes_material_not_applied(tmp_path): # And it does NOT claim the chosen PETG applied. assert not any("Material PETG" in m for m in prompts.printed) - def test_leading_dash_source_rejected_without_argparse_exit(tmp_path): """A local file named '-foo.stl' is rejected as a source, not detonated in argparse.""" _install_ready_settings(tmp_path) @@ -708,11 +657,9 @@ def test_leading_dash_source_rejected_without_argparse_exit(tmp_path): cmd_go(_args(), GoDeps(prompts=prompts, steps=steps)) assert ei.value.exit_code == 3 - # Phase 3: AMS-aware material default (through the injected ams_material seam) # --------------------------------------------------------------------------- - def test_ams_detected_material_becomes_default(tmp_path): """A loaded AMS material matching a preset key is offered as the prompt default.""" _install_ready_settings(tmp_path) @@ -740,7 +687,6 @@ def choice(self, message, choices, *, default=None): assert any("PETG —" in m and "detected in AMS" in m for m in prompts.printed) assert not any("PLA —" in m and "detected in AMS" in m for m in prompts.printed) - def test_ams_detection_failure_falls_back_to_pla(tmp_path): """When the reader returns None (any failure), the step falls back to PLA.""" _install_ready_settings(tmp_path) @@ -766,7 +712,6 @@ def choice(self, message, choices, *, default=None): assert seen_defaults["Material"] == "PLA" assert not any("detected in AMS" in m for m in prompts.printed) - def test_ams_unknown_material_falls_back_to_pla(tmp_path): """A loaded material with no matching preset key falls back to PLA.""" _install_ready_settings(tmp_path) @@ -790,7 +735,6 @@ def choice(self, message, choices, *, default=None): cmd_go(_args(), GoDeps(prompts=prompts, steps=steps)) assert seen_defaults["Material"] == "PLA" - def test_read_loaded_ams_material_matches_sim_active_slot(tmp_path, monkeypatch): """The real reader resolves the sim printer's active tray (slot 0 = PLA).""" from bambu_cli.interactive.session import _read_loaded_ams_material @@ -802,7 +746,6 @@ def test_read_loaded_ams_material_matches_sim_active_slot(tmp_path, monkeypatch) _context.set_current(RuntimeContext(settings=settings, simulation=True)) assert _read_loaded_ams_material(_args(sim=True)) == "PLA" - def test_read_loaded_ams_material_swallows_errors(tmp_path, monkeypatch): """The reader NEVER raises: a failing printer.status() yields None.""" from bambu_cli.interactive.session import _read_loaded_ams_material @@ -816,7 +759,6 @@ def status(self): monkeypatch.setattr("bambu_cli.context.RuntimeContext.printer", lambda self: BoomPrinter()) assert _read_loaded_ams_material(_args()) is None - def test_match_material_preset_maps_known_and_unknown(): from bambu_cli.interactive.session import _match_material_preset @@ -827,18 +769,15 @@ def test_match_material_preset_maps_known_and_unknown(): assert _match_material_preset(None) is None assert _match_material_preset("") is None - # --------------------------------------------------------------------------- # Phase 3: bare `plate` -> wizard on a TTY, help + exit 5 otherwise # --------------------------------------------------------------------------- - def _bare_plate_setup(monkeypatch, tmp_path): monkeypatch.setattr(sys, "argv", ["plate"]) monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", str(tmp_path / "no" / "config.json")) monkeypatch.setattr("bambu_cli.cli.setup_logging", lambda *a, **k: None) - def test_bare_plate_tty_launches_wizard(monkeypatch, tmp_path): from bambu_cli import commands as commands_mod from bambu_cli.cli import main @@ -856,7 +795,6 @@ def fake_cmd_go(args): main() # returns cleanly; the wizard handler ran instead of help assert called["cmd"] == "go" - def test_bare_plate_non_tty_prints_help_and_exits_5(monkeypatch, tmp_path): from bambu_cli import commands as commands_mod from bambu_cli.cli import main @@ -873,7 +811,6 @@ def boom(args): # the wizard must NOT run on a non-TTY main() assert ei.value.code == 5 - def test_bare_plate_tty_stdin_but_redirected_stdout_prints_help(monkeypatch, tmp_path): """A TTY stdin with a redirected stdout is a script pattern -> keep help path.""" from bambu_cli import commands as commands_mod @@ -888,7 +825,6 @@ def test_bare_plate_tty_stdin_but_redirected_stdout_prints_help(monkeypatch, tmp main() assert ei.value.code == 5 - def test_bare_plate_json_forces_help_path(monkeypatch, tmp_path): """Bare `plate --json` must NOT launch the wizard (machine-use flag).""" from bambu_cli import commands as commands_mod @@ -905,7 +841,6 @@ def test_bare_plate_json_forces_help_path(monkeypatch, tmp_path): main() assert ei.value.code == 5 # the existing bare `plate --json` error envelope path - def test_bare_plate_wizard_bambu_error_exits_with_its_code(monkeypatch, tmp_path): """A BambuError from the bare-plate wizard is handled -> exit with its code.""" from bambu_cli import commands as commands_mod @@ -923,7 +858,6 @@ def raise_bambu(args): main() assert ei.value.code == 3 - def test_bare_plate_wizard_ctrl_c_exits_5(monkeypatch, tmp_path): """Ctrl-C bubbling from the bare-plate wizard exits 5 with a cancel message.""" from bambu_cli import commands as commands_mod @@ -941,7 +875,6 @@ def raise_ctrl_c(args): main() assert ei.value.code == 5 - def test_help_epilog_advertises_go(capsys): from bambu_cli.cli import build_parser diff --git a/tests/test_job.py b/tests/test_job.py index 158b2a0..042a35e 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -13,11 +13,9 @@ import os import sys import zipfile -from unittest.mock import MagicMock import pytest - @contextlib.contextmanager def _capture_bambu_warnings(): """Collect WARNING+ records emitted on the 'bambu' logger.""" @@ -35,12 +33,6 @@ def emit(self, record): finally: log.removeHandler(handler) - -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import bambu # noqa: E402 from bambu_cli import job # noqa: E402 from bambu_cli import utils # noqa: E402 @@ -51,7 +43,6 @@ def emit(self, record): from bambu_cli.job import JobSteps, _run_job # noqa: E402 from bambu_cli.errors import BambuError - def default_steps(**overrides): """``JobSteps`` wired to the real command handlers, with optional fakes. @@ -71,48 +62,40 @@ def default_steps(**overrides): steps.update(overrides) return JobSteps(**steps) - def _parse(argv): return build_parser().parse_args(argv) - def _ctx(): return RuntimeContext() - def _read_json(capsys): out = capsys.readouterr().out return json.loads(out) - def fake_download(path): def _run(args): return path return _run - def fake_slice(path): def _run(args): return path return _run - def fake_upload(remote_name): def _run(args): return remote_name return _run - def fake_print(): def _run(args): return None return _run - def failing_step(command, exit_code, error, **extra): """Build a fake step that mimics a real cmd_* failure: records the legacy last-error payload, then raises BambuError like domain handlers do. @@ -125,7 +108,6 @@ def _run(args): return _run - @pytest.fixture(autouse=True) def _reset_last_error(): utils._LAST_ERROR_PAYLOAD = None @@ -134,12 +116,10 @@ def _reset_last_error(): utils._LAST_ERROR_PAYLOAD = None utils._LAST_DOWNLOAD_PAYLOAD = None - # --------------------------------------------------------------------------- # Delegated-step failure payloads # --------------------------------------------------------------------------- - def test_download_failure_detail_flows_through(tmp_path, capsys): url = "https://example.com/model.stl" args = _parse(["job", url, "--json"]) @@ -158,7 +138,6 @@ def test_download_failure_detail_flows_through(tmp_path, capsys): # Dual-write onto ctx.last_error. assert ctx.last_error["command"] == "download" - def test_slice_failure_detail_flows_through(tmp_path): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -182,7 +161,6 @@ def test_slice_failure_detail_flows_through(tmp_path): assert payload["slice_error"]["failed_step"] == "orca" assert ctx.last_error["command"] == "slice" - def test_upload_failure_detail_flows_through(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -197,7 +175,6 @@ def test_upload_failure_detail_flows_through(tmp_path, capsys): assert payload["upload_error"]["error"] == "FTPS connection refused" assert ctx.last_error["command"] == "upload" - def test_print_failure_detail_flows_through(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -217,12 +194,10 @@ def test_print_failure_detail_flows_through(tmp_path, capsys): assert "recovery_hint" in payload assert ctx.last_error["command"] == "print" - # --------------------------------------------------------------------------- # next_command payloads # --------------------------------------------------------------------------- - def test_uploaded_only_next_command(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -235,7 +210,6 @@ def test_uploaded_only_next_command(tmp_path, capsys): assert payload["printed"] is False assert payload["next_command"] == ["print", "model.3mf", "--confirm", "--json"] - def test_uploaded_not_printed_next_command(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -246,7 +220,6 @@ def test_uploaded_not_printed_next_command(tmp_path, capsys): assert payload["status"] == "uploaded_not_printed" assert payload["next_command"] == ["print", "model.3mf", "--confirm", "--json"] - def test_uploaded_next_command_includes_ams_and_flags(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -280,12 +253,10 @@ def test_uploaded_next_command_includes_ams_and_flags(tmp_path, capsys): "--skip-flow-cali", ] - def _bad_ams_argv(source, *extra): """--ams-mapping without --use-ams: rejected by `print`, so job must reject it too.""" return ["job", str(source), "--json", "--ams-mapping", "0,1", *extra] - @pytest.mark.parametrize( "extra", [ @@ -316,7 +287,6 @@ def test_print_options_validated_without_confirm(tmp_path, capsys, extra): assert payload["printed"] is False assert payload["next_command"] is None - def test_upload_only_next_command_is_itself_valid(tmp_path, capsys): """Whatever next_command we hand back must survive `print`'s own validation. @@ -337,7 +307,6 @@ def test_upload_only_next_command_is_itself_valid(tmp_path, capsys): _, error = _parse_print_options(replayed) assert error is None, f"job emitted a next_command that print rejects: {error}" - def test_valid_and_absent_print_options_still_pass(tmp_path, capsys): """The new validation must not reject the ordinary cases.""" ready = tmp_path / "model.3mf" @@ -349,7 +318,6 @@ def test_valid_and_absent_print_options_still_pass(tmp_path, capsys): _run_job(_ctx(), _parse(argv), default_steps(upload=fake_upload("model.3mf"))) assert _read_json(capsys)["status"] == "dry_run_local_skipped" - def test_printed_success(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -361,12 +329,10 @@ def test_printed_success(tmp_path, capsys): assert payload["printed"] is True assert payload["uploaded"] is True - # --------------------------------------------------------------------------- # Dry-run matrix # --------------------------------------------------------------------------- - @pytest.mark.parametrize( "filename,would_slice,would_extract", [ @@ -395,7 +361,6 @@ def test_dry_run_direct_url(filename, would_slice, would_extract, capsys): if not would_extract: assert payload["remote_name"] is not None - def test_dry_run_printables_model_url_predicts_slice(capsys): """Regression: a Printables model page dry-run must report would_slice=True. @@ -417,7 +382,6 @@ def test_dry_run_printables_model_url_predicts_slice(capsys): # being True must not fabricate one. assert payload["remote_name"] is None - def test_predictor_and_doer_share_slice_predicate(): """The dry-run URL prediction routes through the same predicate as the real run. @@ -448,7 +412,6 @@ def test_predictor_and_doer_share_slice_predicate(): # simulate by feeding the predicted extension back through it. assert _ext_would_slice(_file_extension(f"x{predicted_ext}")) is expected, url - def test_dry_run_extensionless_url_predicts_slice(capsys): """An extension-less direct link predicts slicing, matching the doer's fallback. @@ -464,7 +427,6 @@ def test_dry_run_extensionless_url_predicts_slice(capsys): assert payload["would_slice"] is True assert payload["would_extract"] is False - def test_dry_run_local_model_file(tmp_path, capsys): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -477,7 +439,6 @@ def test_dry_run_local_model_file(tmp_path, capsys): assert payload["would_download"] is False assert payload["remote_name"] - def test_dry_run_local_zip(tmp_path, capsys): zpath = tmp_path / "model.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -490,7 +451,6 @@ def test_dry_run_local_zip(tmp_path, capsys): assert payload["would_slice"] is True assert payload["would_upload"] is True - def test_dry_run_local_printer_ready_file(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -502,7 +462,6 @@ def test_dry_run_local_printer_ready_file(tmp_path, capsys): assert payload["would_slice"] is False assert payload["printable_path"] == _display_path(str(ready)) - def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys): """A local .gcode.3mf is print-ready: dry-run must report would_slice=False.""" ready = tmp_path / "plate.gcode.3mf" @@ -515,7 +474,6 @@ def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys): assert payload["would_upload"] is True assert payload["remote_name"] == "plate.gcode.3mf" - @pytest.mark.parametrize( "member,would_slice", [ @@ -542,7 +500,6 @@ def test_dry_run_local_zip_member_slice_matches_predicate(tmp_path, member, woul assert payload["would_slice"] is would_slice assert payload["would_upload"] is True - def test_dry_run_local_printer_ready_empty_file_fails(tmp_path): ready = tmp_path / "model.3mf" ready.write_bytes(b"") @@ -551,7 +508,6 @@ def test_dry_run_local_printer_ready_empty_file_fails(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_dry_run_would_create_output_dir(tmp_path, capsys): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -562,12 +518,10 @@ def test_dry_run_would_create_output_dir(tmp_path, capsys): assert payload["would_create_output_dir"] is True assert not missing_out.exists() - # --------------------------------------------------------------------------- # ZIP paths # --------------------------------------------------------------------------- - def test_zip_bad_archive_fails(tmp_path): bad_zip = tmp_path / "bad.zip" bad_zip.write_bytes(b"not a zip") @@ -576,7 +530,6 @@ def test_zip_bad_archive_fails(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_zip_no_supported_member_fails(tmp_path): zpath = tmp_path / "empty.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -586,7 +539,6 @@ def test_zip_no_supported_member_fails(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_zip_oversized_member_fails_in_dry_run(tmp_path, capsys): zpath = tmp_path / "big.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -596,7 +548,6 @@ def test_zip_oversized_member_fails_in_dry_run(tmp_path, capsys): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_zip_unsafe_member_filename_fails(tmp_path, capsys): # The sanitized member name is short/safe on its own, but the predicted # sliced output name (stem + "_sliced.3mf") pushes it past @@ -614,7 +565,6 @@ def test_zip_unsafe_member_filename_fails(tmp_path, capsys): assert "unsafe printer filename" in payload["error"].lower() assert payload["archive_entry"] is None - def test_zip_archive_entry_propagates_to_summary(tmp_path, capsys): zpath = tmp_path / "model.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -632,12 +582,10 @@ def test_zip_archive_entry_propagates_to_summary(tmp_path, capsys): assert payload["extracted_path"] is not None assert payload["uploaded"] is True - # --------------------------------------------------------------------------- # --output handling # --------------------------------------------------------------------------- - def test_output_created_when_needed(tmp_path, capsys): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -653,7 +601,6 @@ def test_output_created_when_needed(tmp_path, capsys): assert payload["workdir"] == _display_path(str(out_dir)) assert payload["uploaded"] is True - def test_output_ignored_for_printer_ready_local_file(tmp_path, capsys, caplog): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -665,7 +612,6 @@ def test_output_ignored_for_printer_ready_local_file(tmp_path, capsys, caplog): payload = _read_json(capsys) assert payload["uploaded"] is True - def test_output_invalid_dash_prefixed_value_fails(tmp_path): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -674,7 +620,6 @@ def test_output_invalid_dash_prefixed_value_fails(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR - def test_temp_workdir_cleanup_when_no_output_given(tmp_path): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -691,12 +636,10 @@ def _slice(slice_args): assert captured_workdir["workdir"] assert not os.path.exists(captured_workdir["workdir"]) - # --------------------------------------------------------------------------- # Late-binding default JobSteps still resolve real command handlers. # --------------------------------------------------------------------------- - def test_url_job_reuses_download_workdir_and_cleans_up(tmp_path): # Regression test: a URL-sourced job that slices used to allocate a # *second* temp dir for slicing (leaking the first, which held the @@ -723,7 +666,6 @@ def _slice(slice_args): assert captured["slice_workdir"] == captured["download_workdir"] assert not os.path.exists(captured["download_workdir"]) - def test_default_job_steps_delegate_through_commands(tmp_path, capsys, monkeypatch): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -735,7 +677,6 @@ def test_default_job_steps_delegate_through_commands(tmp_path, capsys, monkeypat assert payload["status"] == "uploaded" assert payload["remote_name"] == "model.3mf" - def test_cmd_job_composition_root_wires_the_real_steps(tmp_path, capsys, monkeypatch): # commands.cmd_job is the composition root: it owns the knowledge of which # handlers implement each stage and hands them to the orchestrator. @@ -749,12 +690,10 @@ def test_cmd_job_composition_root_wires_the_real_steps(tmp_path, capsys, monkeyp payload = _read_json(capsys) assert payload["status"] == "uploaded" - # --------------------------------------------------------------------------- # Source-validation failures (fail before any step runs, so no steps needed) # --------------------------------------------------------------------------- - def test_non_http_url_scheme_rejected(capsys): args = _parse(["job", "ftp://example.com/model.stl", "--json"]) with pytest.raises((SystemExit, BambuError)) as excinfo: @@ -764,7 +703,6 @@ def test_non_http_url_scheme_rejected(capsys): assert payload["failed_step"] == "validate" assert "invalid url source" in payload["error"].lower() - def test_http_url_with_embedded_credentials_rejected_and_redacted(capsys): # Username-only + IP host: still trips the embedded-credentials rejection, # but avoids the repo privacy-smoke's email / user:pass@host literal patterns. @@ -777,7 +715,6 @@ def test_http_url_with_embedded_credentials_rejected_and_redacted(capsys): # Userinfo must be stripped from the machine-readable failure. assert "user@" not in json.dumps(payload) - def test_local_file_not_found_fails(tmp_path, capsys): args = _parse(["job", str(tmp_path / "missing.stl"), "--json"]) with pytest.raises((SystemExit, BambuError)) as excinfo: @@ -787,7 +724,6 @@ def test_local_file_not_found_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "file not found" in payload["error"].lower() - def test_directory_source_fails(tmp_path, capsys): args = _parse(["job", str(tmp_path), "--json"]) with pytest.raises((SystemExit, BambuError)) as excinfo: @@ -795,7 +731,6 @@ def test_directory_source_fails(tmp_path, capsys): assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR assert _read_json(capsys)["failed_step"] == "validate" - def test_unsupported_local_file_type_fails(tmp_path, capsys): junk = tmp_path / "notes.txt" junk.write_text("hello", encoding="utf-8") @@ -807,7 +742,6 @@ def test_unsupported_local_file_type_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "unsupported source file type" in payload["error"].lower() - def test_unsafe_sliced_local_name_rejected_before_slicing(tmp_path, capsys): # A 150-char stem is fine on its own, but the predicted "_sliced.3mf" # exceeds MAX_DOWNLOAD_FILENAME_LENGTH and must be rejected before slicing. @@ -828,7 +762,6 @@ def _slice(_a): assert payload["failed_step"] == "validate" assert "unsafe printer filename" in payload["error"].lower() - def test_unsafe_printer_ready_local_name_rejected_before_upload(tmp_path, capsys): ready = tmp_path / (("a" * 200) + ".3mf") ready.write_bytes(b"x" * 10) @@ -847,12 +780,10 @@ def _upload(_a): assert payload["failed_step"] == "validate" assert "unsafe name" in payload["error"].lower() - # --------------------------------------------------------------------------- # Slice- and print-option validation # --------------------------------------------------------------------------- - def test_invalid_slice_option_fails(tmp_path, capsys): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -864,7 +795,6 @@ def test_invalid_slice_option_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "--copies" in payload["error"] - def test_ams_mapping_without_use_ams_fails(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -876,7 +806,6 @@ def test_ams_mapping_without_use_ams_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "--ams-mapping requires --use-ams" in payload["error"] - def test_ams_mapping_non_integer_fails(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -886,7 +815,6 @@ def test_ams_mapping_non_integer_fails(tmp_path, capsys): assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR assert "Invalid AMS mapping format" in _read_json(capsys)["error"] - def test_ams_mapping_negative_slot_fails(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -896,7 +824,6 @@ def test_ams_mapping_negative_slot_fails(tmp_path, capsys): assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR assert "zero or positive" in _read_json(capsys)["error"].lower() - def test_ams_mapping_slot_too_high_fails(tmp_path, capsys): """AMS has 4 slots/unit; reject indexes beyond a realistic multi-AMS max.""" ready = tmp_path / "model.3mf" @@ -909,7 +836,6 @@ def test_ams_mapping_slot_too_high_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "100" in payload["error"] or "slot" in payload["error"].lower() - def test_use_ams_without_mapping_fails(tmp_path, capsys): """--use-ams with no mapping must not silently omit ams_mapping for firmware defaults.""" ready = tmp_path / "model.3mf" @@ -922,12 +848,10 @@ def test_use_ams_without_mapping_fails(tmp_path, capsys): assert payload["failed_step"] == "validate" assert "--use-ams" in payload["error"] and "--ams-mapping" in payload["error"] - # --------------------------------------------------------------------------- # --name is URL-only; warn and ignore for a local source # --------------------------------------------------------------------------- - def test_name_ignored_for_local_file_warns(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -943,12 +867,10 @@ def test_name_ignored_for_local_file_warns(tmp_path, capsys): # The remote name comes from the file, not --name. assert payload["remote_name"] == "model.3mf" - # --------------------------------------------------------------------------- # Successful URL download -> continue (the archive-detection branch) # --------------------------------------------------------------------------- - def test_url_download_success_flows_into_slice_and_upload(tmp_path, capsys): downloaded = tmp_path / "model.stl" downloaded.write_bytes(b"solid x") @@ -970,7 +892,6 @@ def _download(_a): assert payload["uploaded"] is True assert payload["remote_name"] == "model.3mf" - def test_url_download_reports_extracted_archive_member(tmp_path, capsys): # Simulate cmd_download having transparently extracted a ZIP: it records a # _LAST_DOWNLOAD_PAYLOAD with an archive_entry, which job/send surfaces. @@ -998,7 +919,6 @@ def _download(_a): assert payload["extracted_path"] == _display_path(str(extracted)) assert payload["uploaded"] is True - def test_url_invalid_max_download_mb_fails(capsys): args = _parse(["job", "https://example.com/model.stl", "--json", "--max-download-mb", "0"]) with pytest.raises((SystemExit, BambuError)) as excinfo: @@ -1008,7 +928,6 @@ def test_url_invalid_max_download_mb_fails(capsys): assert payload["failed_step"] == "validate" assert "--max-download-mb must be a positive integer" in payload["error"] - def test_run_job_uses_injected_upload_step(tmp_path, capsys, monkeypatch): # The orchestrator drives whatever the caller wired in; it no longer # reaches up into bambu_cli.commands for a default. @@ -1019,7 +938,6 @@ def test_run_job_uses_injected_upload_step(tmp_path, capsys, monkeypatch): _run_job(_ctx(), args, default_steps()) assert _read_json(capsys)["status"] == "uploaded" - def test_run_job_raises_when_a_needed_step_is_missing(tmp_path): # A miswired caller fails loudly at the boundary rather than silently # importing a handler from a higher layer. @@ -1031,12 +949,10 @@ def test_run_job_raises_when_a_needed_step_is_missing(tmp_path): with pytest.raises(MissingJobStep, match="upload"): _run_job(_ctx(), args, JobSteps()) - # --------------------------------------------------------------------------- # generate_print_payload # --------------------------------------------------------------------------- - def test_generate_print_payload_includes_ams_mapping(): payload = json.loads(job.generate_print_payload("m.3mf", use_ams=True, ams_mapping=[0, 1])) assert payload["print"]["use_ams"] is True @@ -1046,7 +962,6 @@ def test_generate_print_payload_includes_ams_mapping(): assert payload["print"]["bed_leveling"] is True assert payload["print"]["flow_cali"] is True - def test_generate_print_payload_flags_and_url_encoding(): payload = json.loads( job.generate_print_payload( @@ -1064,13 +979,11 @@ def test_generate_print_payload_flags_and_url_encoding(): assert " " not in print_cmd["url"] # basename is percent-encoded assert print_cmd["subtask_name"] == "part name.3mf" - def test_generate_print_payload_omits_ams_mapping_without_use_ams(): payload = json.loads(job.generate_print_payload("m.3mf", use_ams=False, ams_mapping=[0, 1])) assert payload["print"]["use_ams"] is False assert "ams_mapping" not in payload["print"] - def test_parse_print_options_requires_use_ams_pairing(): from argparse import Namespace @@ -1094,7 +1007,6 @@ def test_parse_print_options_requires_use_ams_pairing(): mapping, err = job._parse_print_options(Namespace(use_ams=True, ams_mapping="nope")) assert mapping is None and err is not None - def test_predicted_sliced_remote_name_copies(): name = job._predicted_sliced_remote_name("model.stl", copies=1) assert name.endswith("_sliced.3mf") @@ -1102,12 +1014,10 @@ def test_predicted_sliced_remote_name_copies(): name3 = job._predicted_sliced_remote_name("/tmp/foo.stl", copies=3) assert "x3" in name3 or "foo" in name3 - # --------------------------------------------------------------------------- # Deep-audit regressions: job/orchestrate.py + job/support.py # --------------------------------------------------------------------------- - def test_copies_ignored_warns_for_printer_ready(tmp_path, capsys): """--copies only multiplies models during slicing; a printer-ready file must warn (and flag copies_ignored) instead of silently printing one copy.""" @@ -1120,7 +1030,6 @@ def test_copies_ignored_warns_for_printer_ready(tmp_path, capsys): assert payload.get("copies_ignored") is True assert any("--copies only applies" in r.getMessage() for r in records) - def test_copies_ignored_flagged_in_dry_run(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -1130,7 +1039,6 @@ def test_copies_ignored_flagged_in_dry_run(tmp_path, capsys): assert payload["status"] == "dry_run_local_skipped" assert payload.get("copies_ignored") is True - def test_copies_one_does_not_flag_printer_ready(tmp_path, capsys): ready = tmp_path / "model.3mf" ready.write_bytes(b"x" * 10) @@ -1139,7 +1047,6 @@ def test_copies_one_does_not_flag_printer_ready(tmp_path, capsys): payload = _read_json(capsys) assert "copies_ignored" not in payload - def test_zip_oserror_routes_through_structured_failure(tmp_path, capsys, monkeypatch): """An OSError opening the ZIP must emit the structured job-failure summary (failed_step='extract'), not escape as a bare traceback.""" @@ -1161,7 +1068,6 @@ def _boom(path, *a, **kw): assert payload["failed_step"] == "extract" assert payload["status"] == "error" - def test_dry_run_empty_model_file_fails(tmp_path): """Symmetric with the printer-ready branch: a 0-byte sliceable model must fail dry-run instead of reporting would_slice success.""" @@ -1172,7 +1078,6 @@ def test_dry_run_empty_model_file_fails(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_zip_printer_ready_member_copies_ignored_flagged(tmp_path, capsys): """Symmetry: a printer-ready ZIP member with --copies must warn + flag copies_ignored, matching the non-ZIP printer-ready branch.""" @@ -1189,7 +1094,6 @@ def test_zip_printer_ready_member_copies_ignored_flagged(tmp_path, capsys): assert payload.get("copies_ignored") is True assert any("--copies only applies" in r.getMessage() for r in records) - def test_zip_sliceable_member_copies_not_flagged(tmp_path, capsys): zpath = tmp_path / "bundle.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -1201,7 +1105,6 @@ def test_zip_sliceable_member_copies_not_flagged(tmp_path, capsys): assert payload["would_slice"] is True assert "copies_ignored" not in payload - def test_zip_only_empty_member_fails_dry_run(tmp_path): """A ZIP whose only model member is 0 bytes must fail dry-run (knowable offline), not report success — symmetric with the empty-file checks.""" @@ -1213,7 +1116,6 @@ def test_zip_only_empty_member_fails_dry_run(tmp_path): _run_job(_ctx(), args, JobSteps()) assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR - def test_dry_run_output_dir_writability_uses_real_probe(tmp_path, capsys, monkeypatch): """The dry-run output-dir writability check must use a real create-probe (tempfile.mkstemp), NOT os.access(W_OK) which ignores Windows ACLs. Model an diff --git a/tests/test_audit_fixes_pr4.py b/tests/test_jsonio.py similarity index 94% rename from tests/test_audit_fixes_pr4.py rename to tests/test_jsonio.py index 007b7da..f9a3905 100644 --- a/tests/test_audit_fixes_pr4.py +++ b/tests/test_jsonio.py @@ -1,30 +1,16 @@ -"""Regression tests for the deep-audit findings fixed in fix/audit-cli-json-camera. - -Each test targets one finding and is sabotage-verified (stashing the fix makes it -fail). Kept as pure-logic / unit tests where possible; the CLI-envelope and camera -paths are driven through their real functions with the network/printer stubbed. -""" +"""jsonio redaction, home-path display, AMS sentinels, and ZIP extract edges.""" import argparse import os -import sys import zipfile from unittest.mock import MagicMock import pytest -# paho-mqtt is optional/heavy; stub it so importing the package never fails. -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - - # --------------------------------------------------------------------------- # jsonio.redact_url_credentials — scheme-relative URLs # --------------------------------------------------------------------------- - def test_redact_scheme_relative_url_strips_userinfo(): from bambu_cli.jsonio import redact_url_credentials @@ -34,7 +20,6 @@ def test_redact_scheme_relative_url_strips_userinfo(): assert redact_url_credentials("//user:pass" + at + "host.com/x") == "//host.com/x" assert redact_url_credentials("//u:p" + at + "host.com:8443/a?b=c") == "//host.com:8443/a?b=c" - def test_redact_scheme_relative_url_ipv6_userinfo(): from bambu_cli.jsonio import redact_url_credentials @@ -42,7 +27,6 @@ def test_redact_scheme_relative_url_ipv6_userinfo(): # netloc-only IPv6 with userinfo: host must stay bracketed, creds gone. assert redact_url_credentials("//user:pass" + at + "[::1]:990/x") == "//[::1]:990/x" - def test_redact_preserves_existing_schemeless_and_full_url_behavior(): from bambu_cli.jsonio import redact_url_credentials @@ -55,7 +39,6 @@ def test_redact_preserves_existing_schemeless_and_full_url_behavior(): assert redact_url_credentials("/home/x" + at + "y") == "/home/x" + at + "y" assert redact_url_credentials("no-at-sign") == "no-at-sign" - def test_emit_json_uses_jsonio_redactor(capsys): """emit_json must strip userinfo, not the weaker ***@ placeholder.""" from bambu_cli import utils @@ -68,12 +51,10 @@ def test_emit_json_uses_jsonio_redactor(capsys): assert "***@" not in payload assert "https://host.com/x.stl" in payload - # --------------------------------------------------------------------------- # utils._display_path — home-prefix separator boundary # --------------------------------------------------------------------------- - def test_display_path_requires_separator_boundary(monkeypatch): import bambu_cli.utils as utils @@ -85,12 +66,10 @@ def test_display_path_requires_separator_boundary(monkeypatch): assert utils._display_path("/home/alice/model.stl") == "~/model.stl" assert utils._display_path("/home/alice") == "~" - # --------------------------------------------------------------------------- # utils._resolve_ip — do not cache failures # --------------------------------------------------------------------------- - def test_resolve_ip_does_not_cache_failure(monkeypatch): import bambu_cli.utils as utils @@ -117,16 +96,13 @@ def _ok(host, *a, **k): assert utils._RESOLVE_IP_CACHE.get("printer.local") == "10.0.0.5" utils._RESOLVE_IP_CACHE.clear() - # --------------------------------------------------------------------------- # ams.parse_ams — external-spool sentinel + wizard active-tray selection # --------------------------------------------------------------------------- - def _ams_status(tray_now, units): return {"ams": {"tray_now": str(tray_now), "ams": units}} - def test_parse_ams_external_spool_sentinel_not_active(): from bambu_cli.ams import parse_ams @@ -136,7 +112,6 @@ def test_parse_ams_external_spool_sentinel_not_active(): assert parsed["active_tray"] is None assert all(not t["active"] for u in parsed["units"] for t in u["trays"]) - def _patch_ams_status(monkeypatch, status): """Make _read_loaded_ams_material see ``status`` from the printer.""" from bambu_cli.context import RuntimeContext @@ -147,7 +122,6 @@ def _patch_ams_status(monkeypatch, status): fake_ctx.printer.return_value = fake_printer monkeypatch.setattr(RuntimeContext, "for_request", classmethod(lambda cls, args: fake_ctx)) - def test_wizard_ams_material_multi_unit_picks_active_not_earlier_unit(monkeypatch): from bambu_cli.interactive.session import _read_loaded_ams_material @@ -164,7 +138,6 @@ def test_wizard_ams_material_multi_unit_picks_active_not_earlier_unit(monkeypatc # Before the fix, the earlier-unit fallback returned PETG. assert _read_loaded_ams_material(argparse.Namespace()) == "PLA" - def test_wizard_ams_material_external_spool_sentinel_no_false_active(monkeypatch): from bambu_cli.interactive.session import _read_loaded_ams_material @@ -177,12 +150,10 @@ def test_wizard_ams_material_external_spool_sentinel_no_false_active(monkeypatch _patch_ams_status(monkeypatch, status) assert _read_loaded_ams_material(argparse.Namespace()) == "PLA" - # --------------------------------------------------------------------------- # download.extract._extract_zip_model — encrypted / Deflate64 -> ValueError # --------------------------------------------------------------------------- - def test_extract_encrypted_zip_raises_valueerror(tmp_path): from bambu_cli.download.extract import _extract_zip_model @@ -230,7 +201,6 @@ def open(self, *a, **k): finally: extract.zipfile.ZipFile = real_zipfile - def test_extract_deflate64_raises_valueerror(tmp_path): from bambu_cli.download.extract import _extract_zip_model diff --git a/tests/test_mqtt_print_and_setup.py b/tests/test_mqtt_print_and_setup.py index c1532d6..04c538e 100644 --- a/tests/test_mqtt_print_and_setup.py +++ b/tests/test_mqtt_print_and_setup.py @@ -7,17 +7,11 @@ import json import ssl -import sys from argparse import Namespace from unittest.mock import MagicMock, patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.protocols import camera as camera_mod # noqa: E402 from bambu_cli import commands as commands_mod # noqa: E402 from bambu_cli import netsafety # noqa: E402 @@ -33,7 +27,6 @@ from bambu_cli.setup_cmd import wizard as wizard_mod # noqa: E402 from tests.bambu_test_base import _test_printer # noqa: E402 - def test_get_version_with_mock_client(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -54,7 +47,6 @@ def loop_start(): mods = mqtt_mod.get_version(printer, timeout=1, retries=0) assert mods == [{"name": "ota", "sw_ver": "1"}] - def test_get_version_connect_rc_fail(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -70,13 +62,11 @@ def connect(*a, **k): ): assert mqtt_mod.get_version(printer, timeout=0.01, retries=0) is None - def test_execute_print_simulation_missing_file(): printer = _test_printer(simulation_mode=True) with pytest.raises(BambuError): mqtt_mod.execute_print_command(printer, "{}", "missing.3mf", dry_run=False) - def test_remove_partial_and_download_path(tmp_path): p = tmp_path / "x.stl" p.write_text("hi", encoding="utf-8") @@ -85,13 +75,11 @@ def test_remove_partial_and_download_path(tmp_path): fsutil._remove_partial_file(partial) fsutil._remove_partial_file(str(tmp_path / "nope")) - def test_migrate_noop_no_inline(tmp_path): cfg = tmp_path / "c.json" cfg.write_text(json.dumps({"printer_ip": "1.1.1.1", "serial": "s"}), encoding="utf-8") assert migrate_mod.migrate_access_code(str(cfg))["status"] == "noop" - def test_migrate_error_target_exists(tmp_path): cfg = tmp_path / "c.json" target = tmp_path / "code" @@ -100,7 +88,6 @@ def test_migrate_error_target_exists(tmp_path): res = migrate_mod.migrate_access_code(str(cfg), str(target)) assert res["status"] == "error" - def test_cmd_migrate_json(tmp_path, capsys, monkeypatch): cfg = tmp_path / "c.json" code = tmp_path / "ac" @@ -111,7 +98,6 @@ def test_cmd_migrate_json(tmp_path, capsys, monkeypatch): out = capsys.readouterr().out assert "migrated" in out - def test_camera_missing_pin_raises(): printer = _test_printer(insecure_tls=False, cert_fingerprint=None) with pytest.raises(ssl.SSLError, match="No cert_fingerprint"), patch("socket.create_connection") as conn: @@ -124,55 +110,46 @@ def test_camera_missing_pin_raises(): with patch("ssl.create_default_context", return_value=ctx): camera_mod._grab_camera_frame_direct(printer, timeout=1) - def test_slicer_normalize_wall_type(): assert slicer_mod._normalize_wall_type("archaic") == "classic" assert isinstance(slicer_mod._normalize_wall_type("inner outer"), (str, type(None))) - def test_slicer_executable_problem_missing(): assert slicer_mod._slicer_executable_problem("/no/such/orca") is not None - def test_naming_portable_and_extension(): assert naming_mod._file_extension("a.STL") == ".stl" assert fsutil._portable_basename("a/b\\c.stl") in ("c.stl", "b\\c.stl") or "c" in fsutil._portable_basename( "a/b/c.stl" ) - def test_validation_rejects_credentials(): # Username-only + loopback: still trips embedded-credential rejection without # matching privacy_smoke's email / user:pass@host literal patterns. with pytest.raises(BambuError): validation_mod._validate_http_url_or_exit("http://user@127.0.0.1/a.stl") - def test_netsafety_https_connection_class(): # Instantiation only — connect is mocked at higher level c = netsafety.SafeHTTPSConnection("example.com", 443) assert c.host == "example.com" - def test_slicer_sliced_output_path(): p = slicer_mod._sliced_output_path("/tmp/foo.stl", "/out", copies=1) assert p.endswith(".3mf") or "foo" in p - def test_slicer_validate_options_ok(): args = Namespace(copies=1, infill=15, pattern="grid", walls=None, wall_type=None) # Valid args must return None (no error message). err = slicer_mod._validate_slice_options(args) assert err is None - def test_slicer_validate_options_invalid(): # Invalid infill must produce an error string, not None. args = Namespace(copies=1, infill=150, pattern="grid", walls=None, wall_type=None) err = slicer_mod._validate_slice_options(args) assert isinstance(err, str) and len(err) > 0 - def test_utils_sequence_id(): from bambu_cli import utils @@ -180,7 +157,6 @@ def test_utils_sequence_id(): b = utils.get_sequence_id() assert a != b - def test_printer_list_delete_sim(): from bambu_cli.printer import BambuPrinter @@ -195,7 +171,6 @@ def test_printer_list_delete_sim(): # status() must return a dict in sim mode (never None). assert isinstance(p.status(), dict) - def test_printer_upload_sim(tmp_path): from bambu_cli.printer import BambuPrinter @@ -204,7 +179,6 @@ def test_printer_upload_sim(tmp_path): p = BambuPrinter("1.1.1.1", "S", "c", simulation_mode=True) assert p.upload_file(str(f), "/model/a.3mf") is True - def test_execute_print_simulation_ok(): from bambu_cli.protocols.ftps import _SIM_FTP_FILES @@ -214,7 +188,6 @@ def test_execute_print_simulation_ok(): mqtt_mod.execute_print_command(printer, "{}", "ok.3mf", dry_run=False) assert "ok.3mf" in _SIM_FTP_FILES - def test_execute_print_dry_run_success(): printer = _test_printer(simulation_mode=False) mock_ftp = MagicMock() @@ -226,7 +199,6 @@ def test_execute_print_dry_run_success(): mqtt_mod.execute_print_command(printer, "{}", "ok.3mf", dry_run=True) mock_ftp.nlst.assert_called() - def test_monitor_non_sim_reaches_terminal(capsys): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -255,7 +227,6 @@ def loop_start(): lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()] assert any("terminal" in ln or "FINISH" in ln for ln in lines) - def test_monitor_merges_deltas_into_streamed_state(capsys): """A delta must not stream as gcode_state=UNKNOWN at 0% — it updates the merged state.""" printer = _test_printer(simulation_mode=False) @@ -292,7 +263,6 @@ def loop_start(): assert all(e["total_layer_num"] == 200 for e in events) assert events[-1]["gcode_state"] == "FINISH" - def test_execute_print_printer_error_code(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -314,7 +284,6 @@ def loop_start(): ): mqtt_mod.execute_print_command(printer, "{}", "x.3mf", dry_run=False, command_timeout=1) - def test_cmd_light_failure_raises(): args = Namespace(action="on", json=False) printer = MagicMock() @@ -326,7 +295,6 @@ def test_cmd_light_failure_raises(): with pytest.raises(BambuError): commands_mod.cmd_light(args) - @pytest.mark.parametrize( ("cmd_name", "args"), [ @@ -372,7 +340,6 @@ def test_json_envelope_survives_logger_failure(cmd_name, args, capsys): # The handler really was called; safe_log_error absorbed its RuntimeError. broken_logger.error.assert_called_once() - def test_slicer_process_profile_compatible(tmp_path): p = tmp_path / "p.json" p.write_text(json.dumps({"compatible_printers": ["X"]}), encoding="utf-8") @@ -381,7 +348,6 @@ def test_slicer_process_profile_compatible(tmp_path): # A printer not in the list must return False. assert slicer_mod._process_profile_compatible(str(p), "Y") is False - def test_setup_noninteractive_full_success(tmp_path, capsys): cfg = tmp_path / "config.json" code = tmp_path / "access_code" @@ -417,7 +383,6 @@ def test_setup_noninteractive_full_success(tmp_path, capsys): data = json.loads(out) assert data.get("command") in ("setup", "config") or data.get("status") - def test_setup_conflicting_access_flags(): args = Namespace( printer_ip="10.0.0.1", @@ -430,7 +395,6 @@ def test_setup_conflicting_access_flags(): with pytest.raises(BambuError): wizard_mod._cmd_setup_noninteractive(args) - def test_setup_placeholder_ip(): args = Namespace( printer_ip="192.168.0.XXX", @@ -449,7 +413,6 @@ def test_setup_placeholder_ip(): with pytest.raises(BambuError): wizard_mod._cmd_setup_noninteractive(args) - def test_send_command_on_connect_fail_rc(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -465,7 +428,6 @@ def connect(*a, **k): ): assert mqtt_mod.send_command(printer, "{}", timeout=0.01, retries=0) is False - def test_execute_print_real_accept(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -499,7 +461,6 @@ def loop_start(): "a MagicMock default means the MQTT accept path is unwired" ) - def test_cmd_pause_success(capsys): args = Namespace(json=True, confirm=True) printer = MagicMock() @@ -516,7 +477,6 @@ def test_cmd_pause_success(capsys): out = capsys.readouterr().out assert "paused" in out.lower() or '"status"' in out - def test_setup_noninteractive_writes_config(tmp_path, capsys): cfg = tmp_path / "config.json" code = tmp_path / "access_code" @@ -550,7 +510,6 @@ def test_setup_noninteractive_writes_config(tmp_path, capsys): assert data["printer_ip"] == "192.168.1.50" assert data["serial"] == "01P00A000000000" - def test_printer_error_hex_rendering(): assert mqtt_mod._printer_error_hex(83935248) == "0x0500C010" assert mqtt_mod._printer_error_hex(1234) == "0x000004D2" @@ -558,7 +517,6 @@ def test_printer_error_hex_rendering(): assert mqtt_mod._printer_error_hex(True) is None assert mqtt_mod._printer_error_hex(None) is None - def test_execute_print_printer_error_code_records_hex(): from bambu_cli import utils as utils_mod @@ -587,7 +545,6 @@ def loop_start(): assert payload["printer_error_code"] == 83935248 assert payload["printer_error_code_hex"] == "0x0500C010" - def test_execute_print_connect_refused_is_network_error(): """rc != 0 (bad CONNACK / wrong access code) must fail, not report success. @@ -620,7 +577,6 @@ def loop_start(): # The publish must never have happened on a refused connection. assert not client.publish.called - def test_execute_print_rejected_result_is_printer_error(): """A project_file ack with result=fail must not report success.""" from bambu_cli import utils as utils_mod @@ -656,7 +612,6 @@ def loop_start(): assert payload["printed"] is False assert "invalid ams_mapping" in payload["error"] - def test_execute_print_stale_error_before_ack_is_not_blamed(): """A latched print_error from a prior job (a lone periodic report arriving before our project_file ack) must not be attributed to this print.""" @@ -697,7 +652,6 @@ def loop_start(): # Must NOT raise: the stale error predates our ack and is not ours. mqtt_mod.execute_print_command(printer, "{}", "x.3mf", dry_run=False, command_timeout=1) - def test_execute_print_error_after_ack_is_blamed(): """An error arriving with/after our project_file ack is still reported.""" printer = _test_printer(simulation_mode=False) @@ -724,7 +678,6 @@ def loop_start(): ): mqtt_mod.execute_print_command(printer, "{}", "x.3mf", dry_run=False, command_timeout=1) - def test_execute_print_on_connect_publishes_once_but_resubscribes(): """paho auto-reconnect re-firing on_connect must not re-publish the print, but MUST resubscribe on every (re)connect (clean_session drops the sub).""" @@ -753,7 +706,6 @@ def loop_start(): report_subscribes = [c for c in client.subscribe.call_args_list if c.args and str(c.args[0]).endswith("/report")] assert len(report_subscribes) == 2 - def test_send_command_on_connect_publishes_once(): """send_command must not re-publish on a paho auto-reconnect either.""" printer = _test_printer(simulation_mode=False) diff --git a/tests/test_naming_and_validation.py b/tests/test_naming_and_validation.py index 65cdab7..1da0801 100644 --- a/tests/test_naming_and_validation.py +++ b/tests/test_naming_and_validation.py @@ -2,22 +2,14 @@ from __future__ import annotations -import sys from argparse import Namespace -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.download import naming as N # noqa: E402 from bambu_cli.download import validation as V # noqa: E402 from bambu_cli.errors import BambuError # noqa: E402 - def test_has_command_injection_chars(): assert N._has_command_injection_chars("G28") is False assert N._has_command_injection_chars("G28\nM104") is True @@ -26,7 +18,6 @@ def test_has_command_injection_chars(): assert N._has_command_injection_chars("") is False assert N._has_command_injection_chars(None) is False - def test_safe_remote_name_rejects_controls_and_paths(): assert N._safe_remote_name("model.3mf") == "model.3mf" assert N._safe_remote_name("a/b.3mf") is None @@ -39,13 +30,11 @@ def test_safe_remote_name_rejects_controls_and_paths(): assert N._safe_remote_name("CON.3mf") is None assert N._safe_remote_name("a" * 200 + ".3mf") is None - def test_sanitize_download_filename_reserved_and_controls(): assert "\n" not in N._sanitize_download_filename("x\ny.stl") name = N._sanitize_download_filename("CON.stl") assert name.upper().startswith("_") or name != "CON.stl" - # Names that broke one of the two functions, or plausibly could. Kept as one corpus # so the round-trip property below covers every case the individual tests assert. _HOSTILE_NAMES = [ @@ -99,7 +88,6 @@ def test_sanitize_download_filename_reserved_and_controls(): "Ünïcodé Mödel.stl", ] - @pytest.mark.parametrize("raw", _HOSTILE_NAMES) def test_sanitized_names_are_always_accepted_by_the_remote_check(raw): """The repairer must never emit a name the printer-side check refuses. @@ -113,7 +101,6 @@ def test_sanitized_names_are_always_accepted_by_the_remote_check(raw): fixed = N._sanitize_download_filename(raw) assert N._safe_remote_name(fixed) is not None, f"{raw!r} repaired to {fixed!r}, which _safe_remote_name rejects" - @pytest.mark.parametrize("raw", _HOSTILE_NAMES) def test_sanitize_is_idempotent(raw): """Re-sanitizing must be a no-op, or the same model downloaded twice could land @@ -121,7 +108,6 @@ def test_sanitize_is_idempotent(raw): once = N._sanitize_download_filename(raw) assert N._sanitize_download_filename(once) == once - @pytest.mark.parametrize("raw", _HOSTILE_NAMES) def test_sanitized_names_carry_no_dangerous_characters(raw): """Separators and the FTP command delimiters must never survive repair.""" @@ -131,7 +117,6 @@ def test_sanitized_names_carry_no_dangerous_characters(raw): assert fixed == fixed.strip(" ."), f"{fixed!r} has a leading/trailing space or dot" assert fixed not in (".", "..", "") - def test_reserved_device_names_are_caught_before_the_first_dot(): """Regression: `aux.gcode.3mf` passed both functions because splitext() left the stem as `aux.gcode`. Windows reserves the segment before the first dot.""" @@ -144,12 +129,10 @@ def test_reserved_device_names_are_caught_before_the_first_dot(): assert N._sanitize_download_filename("aux.gcode.3mf") == "_aux.gcode.3mf" assert N._safe_remote_name("aux.gcode.3mf") is None - # Inputs that correctly yield "model.stl": the degenerate ones, plus two whose real # basename simply *is* model.stl. Anything else must keep something of the original. _EXPECTED_FALLBACKS = {".", "..", "", " ", "...", "C:\\Windows\\model.stl", "/abs/path/model.stl"} - @pytest.mark.parametrize("raw", [n for n in _HOSTILE_NAMES if n not in _EXPECTED_FALLBACKS]) def test_repair_never_degrades_a_usable_name(raw): """`_sanitize_download_filename` ends with a validate-or-fall-back-to-model.stl @@ -160,7 +143,6 @@ def test_repair_never_degrades_a_usable_name(raw): """ assert N._sanitize_download_filename(raw) != "model.stl" - def test_name_budget_is_bytes_not_characters(): """160 CJK characters is 480 UTF-8 bytes, which ext4 refuses (ENAMETOOLONG). @@ -181,51 +163,43 @@ def test_name_budget_is_bytes_not_characters(): # The rejecter has to use the same rule, or the round-trip breaks. assert N._safe_remote_name(raw) is None - def test_ordinary_names_are_left_alone(): """Repair must not churn names that were already fine -- users would see files renamed for no reason.""" for name in ("USB-C Cover.stl", "part #3.stl", "50%off.stl", "benchy.gcode.3mf"): assert N._sanitize_download_filename(name) == name - def test_content_disposition_percent_is_not_double_decoded(): """A literal `%20` in a plain `filename=` param is not an escape sequence, so it must survive. Adding unquote() to the shared sanitizer would have decoded it.""" got = N._filename_from_content_disposition('attachment; filename="save%20file.stl"') assert got == "save%20file.stl" - def test_content_disposition_rfc5987_still_decodes(): """The RFC 5987 `filename*` path does its own decoding and must keep working.""" got = N._filename_from_content_disposition("attachment; filename*=UTF-8''%E6%97%A5%E6%9C%AC.3mf") assert got == "日本.3mf" - def test_is_print_ready_name(): assert N._is_print_ready_name("a.3mf") is True assert N._is_print_ready_name("a.gcode") is True assert N._is_print_ready_name("a.stl") is False - def test_looks_like_and_normalize_url(): assert V._looks_like_url("https://example.com/x.stl") is True assert V._looks_like_url("/local/path.stl") is False assert V._normalize_url_input("example.com/x.stl").startswith("http") - def test_validate_http_url_rejects_file_scheme(): with pytest.raises((BambuError, SystemExit)): V._validate_http_url_or_exit("file:///etc/passwd") - def test_max_download_mb_error_and_validate(): args = Namespace(max_download_mb=0) assert V._max_download_mb_error(args) with pytest.raises((BambuError, SystemExit)): V._validate_max_download_mb_or_exit(args) - def test_ams_helpers(): from bambu_cli import ams @@ -236,7 +210,6 @@ def test_ams_helpers(): assert ams._normalize_color(None) is None assert ams.parse_ams({}) is None - def test_print_ready_error_message_and_reject(): msg = N._print_ready_error_message("model.stl", "print") assert "model.stl" in msg @@ -245,13 +218,11 @@ def test_print_ready_error_message_and_reject(): with pytest.raises((BambuError, SystemExit)): N._reject_non_print_ready("model.stl", "print") - def test_looks_like_url_requires_scheme_or_domain_shape(): assert V._looks_like_url("not a url") is False assert V._is_http_url("https://example.com/a.stl") is True assert V._is_http_url("ftp://example.com/a.stl") is False - def test_reject_oversized_download_when_content_length_set(): args = Namespace(max_download_mb=1, json=False) with pytest.raises((BambuError, SystemExit)): diff --git a/tests/test_netsafety.py b/tests/test_netsafety.py index fb8f81e..60687da 100644 --- a/tests/test_netsafety.py +++ b/tests/test_netsafety.py @@ -16,10 +16,6 @@ import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) - from bambu_cli import netsafety # noqa: E402 from bambu_cli.netsafety import ( # noqa: E402 MAX_DOWNLOAD_REDIRECT_HOPS, @@ -31,18 +27,15 @@ ) from tests.bambu_test_base import settings_ctx # noqa: E402 - @pytest.fixture(autouse=True) def _clear_dns_cache(): netsafety._dns_cache.clear() yield netsafety._dns_cache.clear() - def _addrinfo(ip, port=443): return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port))] - # --------------------------------------------------------------------------- # Public IPs connect; the connection targets the *resolved IP*, not the host # (TOCTOU / DNS-rebinding defense). @@ -57,7 +50,6 @@ def test_public_ip_connects_to_resolved_ip_not_hostname(): assert result is sentinel conn.assert_called_once_with(("8.8.8.8", 443), 5, None) - # --------------------------------------------------------------------------- # Private / non-global IPs are refused unless explicitly allowed. # --------------------------------------------------------------------------- @@ -70,7 +62,6 @@ def test_private_ip_refused_and_never_connects(): _get_safe_connection("internal.example.com", 443, 5, None) conn.assert_not_called() - def test_allow_private_ips_permits_private_connection(): sentinel = object() with ( @@ -82,7 +73,6 @@ def test_allow_private_ips_permits_private_connection(): assert result is sentinel conn.assert_called_once_with(("10.0.0.5", 443), 5, None) - # --------------------------------------------------------------------------- # CLI wiring: --allow-private-ips must reach RuntimeContext via main() # (settings_ctx alone is not enough — the flag was previously dead). @@ -104,7 +94,6 @@ def capture(_args): main() assert seen.get("allow") is True - def test_main_default_denies_private_ips(monkeypatch, tmp_path): import bambu_cli.bambu as bambu from bambu_cli.cli import main @@ -122,7 +111,6 @@ def capture(_args): main() assert seen.get("allow") is False - def test_main_allow_private_ips_reaches_get_safe_connection(monkeypatch, tmp_path): """End-to-end: flag → Settings → netsafety permits a private resolved IP.""" import bambu_cli.bambu as bambu @@ -147,7 +135,6 @@ def capture(_args): assert outcomes.get("result") is sentinel assert outcomes.get("connected") is True - def test_ipv4_mapped_ipv6_private_address_refused(): # ::ffff:192.168.0.1 must be unwrapped and evaluated as the private v4 addr. with ( @@ -158,7 +145,6 @@ def test_ipv4_mapped_ipv6_private_address_refused(): _get_safe_connection("rebind.example.com", 443, 5, None) conn.assert_not_called() - # --------------------------------------------------------------------------- # Resolution / candidate-iteration edge cases # --------------------------------------------------------------------------- @@ -169,7 +155,6 @@ def test_dns_failure_becomes_urlerror(): ): _get_safe_connection("nx.example.com", 443, 5, None) - def test_unparseable_ip_skipped_then_valid_ip_used(): sentinel = object() addrs = [ @@ -184,7 +169,6 @@ def test_unparseable_ip_skipped_then_valid_ip_used(): assert result is sentinel conn.assert_called_once_with(("8.8.4.4", 443), 5, None) - def test_all_ips_fail_connection_invalidates_cache(): # A valid public IP that refuses TCP must raise and drop the cache entry so # the next attempt re-resolves rather than serving a dead cached address. @@ -200,7 +184,6 @@ def test_all_ips_fail_connection_invalidates_cache(): _get_safe_connection("dead.example.com", 443, 5, None) assert ga.call_count == 2 - # --------------------------------------------------------------------------- # DNS cache behavior # --------------------------------------------------------------------------- @@ -214,7 +197,6 @@ def test_dns_cache_hit_skips_second_resolution(): _get_safe_connection("cached.example.com", 443, 5, None) assert ga.call_count == 1 - def test_dns_cache_expiry_triggers_reresolution(): from bambu_cli.constants import DNS_CACHE_TTL @@ -231,7 +213,6 @@ def test_dns_cache_expiry_triggers_reresolution(): _get_safe_connection("ttl.example.com", 443, 5, None) assert ga.call_count == 2 - def test_dns_cache_evicted_when_oversized(): # >1000 entries triggers a full clear before inserting the new one. for i in range(1001): @@ -245,7 +226,6 @@ def test_dns_cache_evicted_when_oversized(): # Cache was cleared, leaving only the freshly resolved host. assert list(netsafety._dns_cache) == [("fresh.example.com", 443)] - # --------------------------------------------------------------------------- # build_safe_opener composition # --------------------------------------------------------------------------- @@ -256,7 +236,6 @@ def test_build_safe_opener_disables_proxies(): opener = build_safe_opener() assert not any(getattr(h, "proxies", None) for h in opener.handlers) - def test_build_safe_opener_registers_safe_handlers(): opener = build_safe_opener() types_present = {type(h) for h in opener.handlers} @@ -264,7 +243,6 @@ def test_build_safe_opener_registers_safe_handlers(): assert SafeHTTPSHandler in types_present assert SafeHTTPRedirectHandler in types_present - # --------------------------------------------------------------------------- # Redirect hop cap # --------------------------------------------------------------------------- @@ -275,7 +253,6 @@ def test_redirect_hop_cap_rejects_over_limit(): with pytest.raises(urllib.error.URLError, match="Too many redirects"): handler.redirect_request(req, None, 302, "Found", {}, "https://example.com/next") - def test_safe_https_connect_wraps_socket(): conn = netsafety.SafeHTTPSConnection("example.com", 443) conn.timeout = 5 @@ -290,7 +267,6 @@ def test_safe_https_connect_wraps_socket(): assert conn.sock is wrapped ctx.wrap_socket.assert_called_once() - def test_safe_http_connect(): conn = netsafety.SafeHTTPConnection("example.com", 80) conn.timeout = 5 @@ -300,7 +276,6 @@ def test_safe_http_connect(): conn.connect() assert conn.sock is sock - def test_safe_https_connect_closes_on_wrap_failure(): conn = netsafety.SafeHTTPSConnection("example.com", 443) conn.timeout = 5 @@ -313,10 +288,8 @@ def test_safe_https_connect_closes_on_wrap_failure(): conn.connect() sock.close.assert_called() - # --- polite client (per-host throttle + Retry-After) ------------------------- - def _http_error(code, retry_after=None): import email.message @@ -325,13 +298,11 @@ def _http_error(code, retry_after=None): hdrs["Retry-After"] = retry_after return urllib.error.HTTPError("https://api.printables.com/graphql/", code, "rate limited", hdrs, None) - def _fake_req(url="https://api.printables.com/graphql/"): req = MagicMock() req.full_url = url return req - def test_polite_open_retries_on_429_and_honors_retry_after(monkeypatch): monkeypatch.setattr(netsafety, "MIN_HOST_REQUEST_INTERVAL", 1.0) sentinel = object() @@ -377,7 +348,6 @@ def test_polite_open_retries_on_429_and_honors_retry_after(monkeypatch): netsafety.polite_open(opener, _fake_req(), timeout=5, sleep=slept.append) assert opener.open.call_count == 1 - def test_throttle_host_enforces_min_interval_per_host(monkeypatch): monkeypatch.setattr(netsafety, "MIN_HOST_REQUEST_INTERVAL", 1.0) netsafety._last_request_at.clear() @@ -409,7 +379,6 @@ def test_throttle_host_enforces_min_interval_per_host(monkeypatch): netsafety._throttle_host("api.printables.com", sleep=slept.append) assert slept == [] - def test_polite_open_tolerates_non_string_full_url(): sentinel = object() slept = [] diff --git a/tests/test_netsafety_handlers.py b/tests/test_netsafety_handlers.py index 074169b..4b8cd64 100644 --- a/tests/test_netsafety_handlers.py +++ b/tests/test_netsafety_handlers.py @@ -6,7 +6,7 @@ from __future__ import annotations import urllib.request -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest diff --git a/tests/test_coverage_platform_paths.py b/tests/test_platform_paths.py similarity index 94% rename from tests/test_coverage_platform_paths.py rename to tests/test_platform_paths.py index b0dd942..eb5ec46 100644 --- a/tests/test_coverage_platform_paths.py +++ b/tests/test_platform_paths.py @@ -1,7 +1,4 @@ -"""Platform/config/camera/slicer branch behavior (no hardware). - -Renamed historically from coverage padding; every test asserts an outcome. -""" +"""Platform/config/camera/slicer branch behavior (no hardware).""" from __future__ import annotations @@ -13,11 +10,6 @@ import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.commands import snapshot as snapshot_mod # noqa: E402 from bambu_cli import commands as commands_mod # noqa: E402 from bambu_cli import config as config_mod # noqa: E402 @@ -30,7 +22,6 @@ from bambu_cli.setup_cmd import preflight as preflight_mod # noqa: E402 from tests.bambu_test_base import _test_printer # noqa: E402 - @pytest.mark.parametrize("platform", ["win32", "darwin", "linux"]) def test_default_config_path_platforms(platform, monkeypatch, tmp_path): monkeypatch.setattr(config_mod.sys, "platform", platform) @@ -44,7 +35,6 @@ def test_default_config_path_platforms(platform, monkeypatch, tmp_path): path = config_mod._default_config_path() assert "bambu" in path.replace("\\", "/") - def test_convert_step_gmsh_missing(monkeypatch): monkeypatch.setattr(slicer_mod.step_convert.shutil, "which", lambda *_a, **_k: None) @@ -59,7 +49,6 @@ def _no_gmsh(*_a, **_k): assert path is None assert created is False - def test_camera_simulation_snapshot(tmp_path, capsys): out = tmp_path / "snap.jpg" args = Namespace(output=str(out), json=True, direct=True) @@ -72,7 +61,6 @@ def test_camera_simulation_snapshot(tmp_path, capsys): assert payload.get("command") == "snapshot" assert payload.get("size_bytes", 0) > 0 - def test_preflight_permission_check(tmp_path): f = tmp_path / "secret" f.write_text("x", encoding="utf-8") @@ -93,7 +81,6 @@ def test_preflight_permission_check(tmp_path): res_ok = preflight_mod._file_permission_check(str(f), "secret-file") assert res_ok["status"] == "ok" - def test_common_setup_json_error(capsys): args = Namespace(json=True) common_mod._setup_json_error(args, "boom", foo=1) @@ -101,14 +88,12 @@ def test_common_setup_json_error(capsys): assert data["status"] == "error" assert data["error"] == "boom" - def test_ftps_connection_error_path_cleanup(): ftp = ftps_mod.ImplicitFTPS() ftp.printer = _test_printer(cert_fingerprint="aa" * 32, insecure_tls=False) with patch.object(ftps_mod.socket, "create_connection", side_effect=OSError("fail")), pytest.raises(OSError): ftp.connect("1.1.1.1", 990, 1) - def test_mqtt_require_missing_dependency(): from bambu_cli.protocols import mqtt_tls @@ -120,7 +105,6 @@ def test_mqtt_require_missing_dependency(): finally: mqtt_tls.mqtt = prev - def test_cmd_gcode_success(): args = Namespace(code="G28", json=False, confirm=True) printer = MagicMock() @@ -138,6 +122,5 @@ def test_cmd_gcode_success(): assert "G28" in payload assert "gcode_line" in payload - def test_downloader_exposes_cmd_download(): assert callable(getattr(downloader_mod, "cmd_download", None) or getattr(downloader_mod, "_cmd_download", None)) diff --git a/tests/test_printables_adapter.py b/tests/test_printables_adapter.py index abccf0a..a8ec5ba 100644 --- a/tests/test_printables_adapter.py +++ b/tests/test_printables_adapter.py @@ -20,17 +20,11 @@ from __future__ import annotations import json -import sys import urllib.error -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.printables import ( # noqa: E402 PrintablesAdapter, is_printables_url, @@ -40,10 +34,8 @@ MODEL_URL = "https://www.printables.com/model/12345-test-model" - # --- fakes ------------------------------------------------------------------- - class _FakeResponse: def __init__(self, body): self._body = body if isinstance(body, bytes) else json.dumps(body).encode() @@ -57,7 +49,6 @@ def __enter__(self): def __exit__(self, *_exc): return False - class _FakeOpener: """Yields the queued responses in order; raises if one is an Exception.""" @@ -74,23 +65,18 @@ def open(self, req, timeout=None): raise nxt return _FakeResponse(nxt) - def _adapter(*responses): opener = _FakeOpener(*responses) return PrintablesAdapter(opener_factory=lambda: opener), opener - def _model(stls=None, gcodes=None, name="Test Model"): return {"data": {"print": {"name": name, "stls": stls or [], "gcodes": gcodes or []}}} - def _link(url): return {"data": {"getDownloadLink": {"ok": True, "output": {"link": url}}}} - # --- URL detection ----------------------------------------------------------- - @pytest.mark.parametrize( "url", [ @@ -102,7 +88,6 @@ def _link(url): def test_recognises_model_urls(url): assert is_printables_url(url) is True - @pytest.mark.parametrize( "url", [ @@ -119,7 +104,6 @@ def test_recognises_model_urls(url): def test_rejects_non_model_urls(url): assert is_printables_url(url) is False - def test_non_printables_url_resolves_to_a_typed_refusal_without_network(): adapter, opener = _adapter() # no responses queued: any call would assert result = adapter.resolve("https://www.thingiverse.com/thing:12345") @@ -127,10 +111,8 @@ def test_non_printables_url_resolves_to_a_typed_refusal_without_network(): assert result.reason == "not_a_printables_url" assert opener.requests == [] - # --- happy paths ------------------------------------------------------------- - @patch("bambu_cli.logging_utils._BACKEND") def test_resolves_stl_to_a_download_url(_log): adapter, opener = _adapter( @@ -143,7 +125,6 @@ def test_resolves_stl_to_a_download_url(_log): assert result.filename == "part1.stl" assert len(opener.requests) == 2 - @patch("bambu_cli.logging_utils._BACKEND") def test_picks_the_largest_of_several_stls(_log): adapter, _ = _adapter( @@ -157,7 +138,6 @@ def test_picks_the_largest_of_several_stls(_log): ) assert adapter.resolve(MODEL_URL).filename == "part2.stl" - @patch("bambu_cli.logging_utils._BACKEND") def test_falls_back_to_step_when_no_stl(_log): adapter, _ = _adapter( @@ -168,7 +148,6 @@ def test_falls_back_to_step_when_no_stl(_log): assert result.ok is True assert result.filename == "part1.step" - @patch("bambu_cli.logging_utils._BACKEND") def test_falls_back_to_3mf_and_warns_it_cannot_be_resliced(mock_log): adapter, _ = _adapter( @@ -180,7 +159,6 @@ def test_falls_back_to_3mf_and_warns_it_cannot_be_resliced(mock_log): assert result.filename == "part1.3mf" assert any("falling back to 3MF" in c[0][0] for c in mock_log.warning.call_args_list) - @patch("bambu_cli.logging_utils._BACKEND") def test_stl_is_preferred_over_step_and_3mf(_log): adapter, _ = _adapter( @@ -195,10 +173,8 @@ def test_stl_is_preferred_over_step_and_3mf(_log): ) assert adapter.resolve(MODEL_URL).filename == "small.stl" - # --- failure taxonomy -------------------------------------------------------- - @patch("bambu_cli.logging_utils._BACKEND") def test_network_error_is_reported_as_unavailable(_log): adapter, _ = _adapter(urllib.error.URLError("Network unreachable")) @@ -208,7 +184,6 @@ def test_network_error_is_reported_as_unavailable(_log): assert "Network" in result.error assert result.remedy - @patch("bambu_cli.logging_utils._BACKEND") def test_missing_model_is_reported_as_model_unavailable(_log): adapter, _ = _adapter({"data": {"print": None}}) @@ -217,7 +192,6 @@ def test_missing_model_is_reported_as_model_unavailable(_log): assert result.reason == "printables_model_unavailable" assert "12345" in result.error - @patch("bambu_cli.logging_utils._BACKEND") def test_model_without_printable_files_is_model_unavailable(_log): adapter, _ = _adapter(_model(stls=[{"id": "1", "name": "readme.txt", "fileSize": 10}])) @@ -226,7 +200,6 @@ def test_model_without_printable_files_is_model_unavailable(_log): assert result.reason == "printables_model_unavailable" assert "No STL, STEP, or 3MF" in result.error - @patch("bambu_cli.logging_utils._BACKEND") def test_refused_download_link_surfaces_the_servers_reason(_log): adapter, _ = _adapter( @@ -238,7 +211,6 @@ def test_refused_download_link_surfaces_the_servers_reason(_log): assert result.reason == "printables_model_unavailable" assert "Download limit reached" in result.error - @patch("bambu_cli.logging_utils._BACKEND") def test_non_json_body_is_reported_as_a_contract_change(_log): adapter, _ = _adapter(b"we redesigned our API") @@ -248,7 +220,6 @@ def test_non_json_body_is_reported_as_a_contract_change(_log): # The remedy must tell the user this is not something retrying will fix. assert "manually" in result.remedy or "browser" in result.remedy - @patch("bambu_cli.logging_utils._BACKEND") def test_file_record_without_id_is_a_contract_change_not_a_crash(_log): # id/name are what the download step needs; losing them means the schema moved. @@ -257,7 +228,6 @@ def test_file_record_without_id_is_a_contract_change_not_a_crash(_log): assert result.ok is False assert result.reason == "printables_contract_changed" - @patch("bambu_cli.logging_utils._BACKEND") def test_ok_link_with_no_url_is_a_contract_change(_log): adapter, _ = _adapter( @@ -268,7 +238,6 @@ def test_ok_link_with_no_url_is_a_contract_change(_log): assert result.ok is False assert result.reason == "printables_contract_changed" - # --- containment: the reason this package exists ----------------------------- # Every payload here is something Printables could plausibly start returning. @@ -292,7 +261,6 @@ def test_ok_link_with_no_url_is_a_contract_change(_log): pytest.param(b"\x00\x01\x02 not json", id="binary-garbage"), ] - @pytest.mark.parametrize("payload", HOSTILE_PAYLOADS) @patch("bambu_cli.logging_utils._BACKEND") def test_malformed_api_response_never_raises(_log, payload): @@ -303,7 +271,6 @@ def test_malformed_api_response_never_raises(_log, payload): assert result.error assert result.as_tuple() == (None, None) - # Factories, not instances: pytest derives parameter ids from the values at # collection time, and building an HTTPError up here made it probe attributes # that blow up on Python 3.9 (KeyError: 'file' via tempfile.__getattr__). @@ -330,7 +297,6 @@ def test_unexpected_exception_is_contained_not_propagated(_log, make_error): assert result.ok is False assert result.error - @patch("bambu_cli.logging_utils._BACKEND") def test_keyboard_interrupt_is_never_swallowed(_log): # Containment must not break Ctrl-C. @@ -338,10 +304,8 @@ def test_keyboard_interrupt_is_never_swallowed(_log): with pytest.raises(KeyboardInterrupt): adapter.resolve(MODEL_URL) - # --- legacy tuple surface ---------------------------------------------------- - @patch("bambu_cli.logging_utils._BACKEND") def test_resolve_printables_url_keeps_the_tuple_contract(_log): adapter, _ = _adapter( @@ -353,13 +317,11 @@ def test_resolve_printables_url_keeps_the_tuple_contract(_log): "part1.stl", ) - @patch("bambu_cli.logging_utils._BACKEND") def test_resolve_printables_url_returns_none_pair_on_failure(_log): adapter, _ = _adapter({"data": {"print": None}}) assert resolve_printables_url(MODEL_URL, adapter=adapter) == (None, None) - @patch("bambu_cli.logging_utils._BACKEND") def test_resolve_printables_exposes_the_reason_the_tuple_hides(_log): # Same failure, two surfaces: the tuple can only say "no", the resolution diff --git a/tests/test_printables_headers.py b/tests/test_printables_headers.py index fdc293d..bbb6921 100644 --- a/tests/test_printables_headers.py +++ b/tests/test_printables_headers.py @@ -10,28 +10,20 @@ """ import json -import sys from unittest.mock import MagicMock, patch -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) - from bambu_cli import constants, netsafety # noqa: E402 from bambu_cli.printables import PrintablesAdapter, resolve_printables_url # noqa: E402 - def _clear_ua_caches(): netsafety.platecli_user_agent.cache_clear() netsafety._default_user_agent.cache_clear() - def _gql_response(payload): resp = MagicMock() resp.read.return_value = json.dumps(payload).encode() return resp - @patch("bambu_cli.logging_utils._BACKEND") def test_printables_gql_headers_are_honest_and_unforged(mock_logger): # The opener is injected rather than patched: the adapter's whole point is @@ -82,7 +74,6 @@ def test_printables_gql_headers_are_honest_and_unforged(mock_logger): assert req.get_header("Origin") is None assert req.get_header("Referer") is None - def test_user_agent_for_url_policy(): try: _clear_ua_caches() @@ -114,7 +105,6 @@ def test_user_agent_for_url_policy(): finally: _clear_ua_caches() - def test_platecli_user_agent_uses_version_source_of_truth(): had = "VERSION" in constants.__dict__ prev = constants.__dict__.get("VERSION") diff --git a/tests/test_properties_safety.py b/tests/test_properties_safety.py index e16926b..c95629c 100644 --- a/tests/test_properties_safety.py +++ b/tests/test_properties_safety.py @@ -13,20 +13,14 @@ import argparse import ipaddress import socket -import sys import urllib.error import zipfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from hypothesis import HealthCheck, assume, given, settings, strategies as st -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.constants import ( # noqa: E402 MAX_AMS_SLOT_INDEX, MAX_BED_TEMP_C, @@ -53,12 +47,10 @@ # Characters that must never appear in a sanitized local download filename. _FORBIDDEN_IN_SANITIZED = set('/\\:\0\r\n\x00<>"|?*') | {chr(c) for c in range(32)} - # --------------------------------------------------------------------------- # download/naming.py # --------------------------------------------------------------------------- - @given(st.text(min_size=0, max_size=400)) @_PROP def test_prop_sanitize_never_contains_path_or_control_chars(raw: str) -> None: @@ -75,7 +67,6 @@ def test_prop_sanitize_never_contains_path_or_control_chars(raw: str) -> None: assert ord(ch) >= 32 or ch not in ("\x00", "\r", "\n") assert ch not in '<>:"/\\|?*' - @given(st.text(min_size=0, max_size=500)) @_PROP def test_prop_sanitize_length_bounded(raw: str) -> None: @@ -96,7 +87,6 @@ def test_prop_sanitize_length_bounded(raw: str) -> None: # Residual path: cannot stem-trim an extension-dominated name. assert len(out) >= len(ext) - @given(st.text(min_size=0, max_size=300)) @_PROP def test_prop_sanitize_idempotent(raw: str) -> None: @@ -105,7 +95,6 @@ def test_prop_sanitize_idempotent(raw: str) -> None: twice = N._sanitize_download_filename(once) assert twice == once - @given(st.text(min_size=0, max_size=200), st.sampled_from(["\r", "\n", "\0"])) @_PROP def test_prop_injection_chars_always_detected(prefix: str, inj: str) -> None: @@ -114,14 +103,12 @@ def test_prop_injection_chars_always_detected(prefix: str, inj: str) -> None: value = prefix[:80] + inj + prefix[80:100] assert N._has_command_injection_chars(value) is True - @given(st.text(alphabet=st.characters(blacklist_characters="\r\n\0"), min_size=0, max_size=120)) @_PROP def test_prop_no_injection_chars_when_absent(value: str) -> None: """Strings without CR/LF/NUL never report command-injection characters.""" assert N._has_command_injection_chars(value) is False - @given(st.text(min_size=0, max_size=250)) @_PROP def test_prop_safe_remote_name_reject_or_portable(raw: str) -> None: @@ -138,7 +125,6 @@ def test_prop_safe_remote_name_reject_or_portable(raw: str) -> None: assert not any(c in out for c in '<>:"/\\|?*') assert out == N._portable_basename(out) - @given( st.text(min_size=1, max_size=100).map(lambda s: s.replace("\0", "x")), st.sampled_from(["\r", "\n", "\0", "/", "\\", ":", "*", "?", '"', "<", ">", "|"]), @@ -150,7 +136,6 @@ def test_prop_safe_remote_name_rejects_dangerous_chars(stem: str, bad: str) -> N name = f"m{stem[:40]}{bad}x.3mf" assert N._safe_remote_name(name) is None - @given(st.integers(min_value=MAX_DOWNLOAD_FILENAME_LENGTH + 1, max_value=MAX_DOWNLOAD_FILENAME_LENGTH + 80)) @_PROP def test_prop_safe_remote_name_rejects_overlong(n: int) -> None: @@ -159,12 +144,10 @@ def test_prop_safe_remote_name_rejects_overlong(n: int) -> None: assert len(name) > MAX_DOWNLOAD_FILENAME_LENGTH assert N._safe_remote_name(name) is None - # --------------------------------------------------------------------------- # download/validation.py + URL scheme invariants # --------------------------------------------------------------------------- - @given(st.sampled_from(["file", "ftp", "ftps", "data", "javascript", "gopher", "ssh", ""])) @_PROP def test_prop_validate_http_url_rejects_non_http_schemes(scheme: str) -> None: @@ -176,7 +159,6 @@ def test_prop_validate_http_url_rejects_non_http_schemes(scheme: str) -> None: with pytest.raises((BambuError, SystemExit)): V._validate_http_url_or_exit(url) - @given(st.sampled_from(["http", "https"])) @_PROP def test_prop_validate_http_url_rejects_missing_host(scheme: str) -> None: @@ -184,7 +166,6 @@ def test_prop_validate_http_url_rejects_missing_host(scheme: str) -> None: with pytest.raises((BambuError, SystemExit)): V._validate_http_url_or_exit(f"{scheme}:///path/only.stl") - @given( st.sampled_from(["http", "https"]), st.text(alphabet=st.characters(min_codepoint=ord("a"), max_codepoint=ord("z")), min_size=1, max_size=12), @@ -197,7 +178,6 @@ def test_prop_validate_http_url_rejects_embedded_credentials(scheme: str, user: with pytest.raises((BambuError, SystemExit)): V._validate_http_url_or_exit(url) - @given( st.one_of( st.integers(max_value=0), @@ -217,7 +197,6 @@ def test_prop_max_download_mb_rejects_non_positive(value) -> None: assert err is not None assert "positive" in err.lower() or "integer" in err.lower() or "max-download" in err.lower() - @given(st.integers(min_value=1, max_value=4096)) @_PROP def test_prop_max_download_mb_accepts_positive(value: int) -> None: @@ -225,23 +204,19 @@ def test_prop_max_download_mb_accepts_positive(value: int) -> None: args = argparse.Namespace(max_download_mb=value) assert V._max_download_mb_error(args) is None - @given(st.sampled_from(["http://example.com/a.stl", "https://cdn.example.org/x.3mf"])) @_PROP def test_prop_is_http_url_true_for_valid(url: str) -> None: assert V._is_http_url(url) is True - # --------------------------------------------------------------------------- # netsafety.py — is_global gating # --------------------------------------------------------------------------- - def _addrinfo(ip: str, port: int = 443): family = socket.AF_INET6 if ":" in ip else socket.AF_INET return [(family, socket.SOCK_STREAM, 6, "", (ip, port))] - @given(st.ip_addresses(v=4).filter(lambda a: not a.is_global)) @_PROP def test_prop_non_global_ipv4_never_connected(ip: ipaddress.IPv4Address) -> None: @@ -255,7 +230,6 @@ def test_prop_non_global_ipv4_never_connected(ip: ipaddress.IPv4Address) -> None netsafety._get_safe_connection("evil.example", 443, 5, None) conn.assert_not_called() - @given(st.ip_addresses(v=6).filter(lambda a: not a.is_global and not a.ipv4_mapped)) @_PROP def test_prop_non_global_ipv6_never_connected(ip: ipaddress.IPv6Address) -> None: @@ -269,7 +243,6 @@ def test_prop_non_global_ipv6_never_connected(ip: ipaddress.IPv6Address) -> None netsafety._get_safe_connection("evil6.example", 443, 5, None) conn.assert_not_called() - # Cloud metadata and classic private ranges — explicit samples beyond pure generation. @pytest.mark.parametrize( "ip", @@ -300,12 +273,10 @@ def test_explicit_non_global_and_metadata_refused(ip: str) -> None: netsafety._get_safe_connection("meta.internal", 80, 5, None) conn.assert_not_called() - # --------------------------------------------------------------------------- # slicer options + AMS mapping + 3mf validation # --------------------------------------------------------------------------- - @given(st.integers().filter(lambda t: t < MIN_NOZZLE_TEMP_C or t > MAX_NOZZLE_TEMP_C)) @_PROP def test_prop_out_of_range_nozzle_temp_rejected(temp: int) -> None: @@ -316,7 +287,6 @@ def test_prop_out_of_range_nozzle_temp_rejected(temp: int) -> None: assert err is not None assert "nozzle" in err.lower() - @given(st.integers().filter(lambda t: t < MIN_BED_TEMP_C or t > MAX_BED_TEMP_C)) @_PROP def test_prop_out_of_range_bed_temp_rejected(temp: int) -> None: @@ -326,7 +296,6 @@ def test_prop_out_of_range_bed_temp_rejected(temp: int) -> None: assert err is not None assert "bed" in err.lower() - @given(st.integers(min_value=MIN_NOZZLE_TEMP_C, max_value=MAX_NOZZLE_TEMP_C)) @_PROP def test_prop_in_range_nozzle_temp_ok(temp: int) -> None: @@ -334,7 +303,6 @@ def test_prop_in_range_nozzle_temp_ok(temp: int) -> None: err = slicer_options._validate_slice_options(args) assert err is None - @given(st.integers().filter(lambda v: v < 0 or v > 100)) @_PROP def test_prop_out_of_range_infill_rejected(infill: int) -> None: @@ -343,7 +311,6 @@ def test_prop_out_of_range_infill_rejected(infill: int) -> None: assert err is not None assert "infill" in err.lower() - @given(st.integers(max_value=0)) @_PROP def test_prop_non_positive_copies_rejected(copies: int) -> None: @@ -352,7 +319,6 @@ def test_prop_non_positive_copies_rejected(copies: int) -> None: assert err is not None assert "copies" in err.lower() - @given(st.lists(st.integers().filter(lambda s: s < 0 or s > MAX_AMS_SLOT_INDEX), min_size=1, max_size=6)) @_PROP def test_prop_out_of_range_ams_slots_rejected(slots: list[int]) -> None: @@ -364,7 +330,6 @@ def test_prop_out_of_range_ams_slots_rejected(slots: list[int]) -> None: assert err is not None assert "ams" in err.lower() or "slot" in err.lower() or "mapping" in err.lower() - @given(st.lists(st.integers(min_value=0, max_value=MAX_AMS_SLOT_INDEX), min_size=1, max_size=8)) @_PROP def test_prop_valid_ams_slots_accepted(slots: list[int]) -> None: @@ -374,7 +339,6 @@ def test_prop_valid_ams_slots_accepted(slots: list[int]) -> None: assert err is None assert mapping == slots - @settings( max_examples=40, deadline=None, @@ -402,7 +366,6 @@ def test_prop_random_bytes_never_valid_3mf(tmp_path: Path, data: bytes) -> None: if not (has_ct and (has_model or has_plate)): assert slicer_output._is_valid_sliced_3mf(str(path)) is False - def test_incomplete_zip_structures_never_valid_3mf(tmp_path: Path) -> None: """Zips missing Content_Types or model/plate members are never valid.""" cases = [ @@ -419,7 +382,6 @@ def test_incomplete_zip_structures_never_valid_3mf(tmp_path: Path) -> None: zf.writestr(name, body) assert slicer_output._is_valid_sliced_3mf(str(path)) is False - def test_minimal_valid_3mf_shapes_accepted(tmp_path: Path) -> None: """Documented acceptance: Content_Types + model, or Content_Types + plate gcode.""" a = tmp_path / "model_only.3mf" @@ -434,12 +396,10 @@ def test_minimal_valid_3mf_shapes_accepted(tmp_path: Path) -> None: zf.writestr("Metadata/plate_2.gcode", "G28\n") assert slicer_output._is_valid_sliced_3mf(str(b)) is True - # --------------------------------------------------------------------------- # print payload invariants (job pure logic) # --------------------------------------------------------------------------- - @given( st.text( alphabet=st.characters(min_codepoint=33, max_codepoint=126, blacklist_characters="/\\"), diff --git a/tests/test_setup_helpers.py b/tests/test_setup_helpers.py index 11adf78..2fe653b 100644 --- a/tests/test_setup_helpers.py +++ b/tests/test_setup_helpers.py @@ -4,36 +4,27 @@ import json import socket -import sys from argparse import Namespace from unittest.mock import MagicMock, patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.errors import BambuError # noqa: E402 from bambu_cli.setup_cmd import common as common_mod # noqa: E402 from bambu_cli.setup_cmd import wizard as wizard_mod # noqa: E402 - def test_service_info_parsed_addresses(): info = MagicMock() info.parsed_addresses = lambda: ["10.0.0.9"] info.addresses = [] assert wizard_mod._service_info_address(info) == "10.0.0.9" - def test_service_info_raw_ipv4(): info = MagicMock() info.parsed_addresses = None info.addresses = [socket.inet_aton("192.168.1.5")] assert wizard_mod._service_info_address(info) == "192.168.1.5" - def test_service_info_no_address(): info = MagicMock() info.parsed_addresses = lambda: [] @@ -41,22 +32,18 @@ def test_service_info_no_address(): with pytest.raises(ValueError): wizard_mod._service_info_address(info) - def test_parse_mdns_identity_model_prefix(): serial, model = wizard_mod._parse_mdns_printer_identity("BBLP-P1S-01P00A123456789._bblp._tcp.local.") assert model in ("P1S", "P1P") or serial - def test_parse_mdns_identity_plain(): serial, model = wizard_mod._parse_mdns_printer_identity("something-else.local") assert model == "P1P" - def test_normalize_model_nozzle(): assert common_mod._normalize_model("x1c", "P1P") == "X1C" assert common_mod._normalize_nozzle("0.6") == "0.6" - def test_build_and_write_setup_config(tmp_path, monkeypatch): cfg_path = tmp_path / "config.json" code_path = tmp_path / "access_code" @@ -82,7 +69,6 @@ def test_build_and_write_setup_config(tmp_path, monkeypatch): summary = common_mod._setup_summary(config) assert summary.get("printer_ip_configured") is True or "printer_ip" in summary - def _rerun_setup(cfg_path, monkeypatch, **overrides): """Build a fresh wizard config and write it over cfg_path, as a setup re-run does.""" monkeypatch.setattr(common_mod, "_config_path", lambda: str(cfg_path)) @@ -99,7 +85,6 @@ def _rerun_setup(cfg_path, monkeypatch, **overrides): common_mod._write_setup_config(common_mod._build_setup_config(**kwargs)) return json.loads(cfg_path.read_text(encoding="utf-8")) - def test_setup_rerun_preserves_unmanaged_keys(tmp_path, monkeypatch): """Re-running setup must not delete keys the wizard does not manage. @@ -134,7 +119,6 @@ def test_setup_rerun_preserves_unmanaged_keys(tmp_path, monkeypatch): assert data["printer_ip"] == "10.1.2.3" assert data["serial"] == "SNABC" - def test_setup_rerun_does_not_resurrect_inline_access_code(tmp_path, monkeypatch): """Moving an inline access_code into a file must REMOVE the inline copy. @@ -159,7 +143,6 @@ def test_setup_rerun_does_not_resurrect_inline_access_code(tmp_path, monkeypatch assert data["access_code_file"] == str(code_path) assert data["camera_port"] == "127.0.0.1:1985:1984" # unmanaged key still preserved - def test_setup_rerun_clears_declined_insecure_tls(tmp_path, monkeypatch): """Declining insecure_tls must turn it off, not preserve the old true. @@ -173,7 +156,6 @@ def test_setup_rerun_clears_declined_insecure_tls(tmp_path, monkeypatch): assert "insecure_tls" not in data assert data["camera_port"] == "127.0.0.1:1985:1984" - def test_setup_rerun_survives_unreadable_existing_config(tmp_path, monkeypatch): """A corrupt existing config must not abort setup; it warns and writes fresh.""" cfg_path = tmp_path / "config.json" @@ -183,7 +165,6 @@ def test_setup_rerun_survives_unreadable_existing_config(tmp_path, monkeypatch): assert data["printer_ip"] == "10.1.2.3" assert any("could not be preserved" in str(c) for c in mock_logger.warning.call_args_list) - def test_setup_first_run_with_no_existing_config(tmp_path, monkeypatch): """No config on disk is the normal first run, not an error.""" cfg_path = tmp_path / "nested" / "config.json" @@ -191,24 +172,20 @@ def test_setup_first_run_with_no_existing_config(tmp_path, monkeypatch): assert data["printer_ip"] == "10.1.2.3" assert data["serial"] == "SNABC" - def test_setup_summary_and_path_details(): details = common_mod._setup_path_details(access_code_file="/tmp/x") assert "access_code_file" in details - def test_validate_access_code_file_missing(tmp_path): args = Namespace(json=False) with pytest.raises(BambuError): # path that looks invalid with leading dash common_mod._validate_setup_access_code_file(args, "-bad") - def test_default_access_code_file_path(): p = common_mod._default_access_code_file_path() assert "access_code" in p or "bambu" in p - def test_noninteractive_access_code_env(monkeypatch, tmp_path): cfg = tmp_path / "c.json" monkeypatch.setenv("BAMBU_TEST_CODE", "99887766") @@ -236,7 +213,6 @@ def test_noninteractive_access_code_env(monkeypatch, tmp_path): data = json.loads(cfg.read_text(encoding="utf-8")) assert data["printer_ip"] == "10.0.0.3" - def test_noninteractive_access_code_file(tmp_path, monkeypatch): cfg = tmp_path / "c.json" code = tmp_path / "code" @@ -263,7 +239,6 @@ def test_noninteractive_access_code_file(tmp_path, monkeypatch): wizard_mod._cmd_setup_noninteractive(args) assert cfg.is_file() - def test_service_info_parsed_addresses_raises(): info = MagicMock() diff --git a/tests/test_sim_transport_setup.py b/tests/test_sim_transport_setup.py index 0b63d19..40bb0f2 100644 --- a/tests/test_sim_transport_setup.py +++ b/tests/test_sim_transport_setup.py @@ -17,11 +17,6 @@ import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import fsutil # noqa: E402 from bambu_cli import netsafety # noqa: E402 from bambu_cli.download import extract as extract_mod # noqa: E402 @@ -35,10 +30,8 @@ pytestmark = pytest.mark.security - # --- MQTT sim / status helpers ------------------------------------------------ - def test_sim_mqtt_client_callbacks_fire(): client = mqtt_mod._SimMqttClient() connected = [] @@ -57,19 +50,16 @@ def test_sim_mqtt_client_callbacks_fire(): assert client.socket() is None assert connected and published - def test_get_status_simulation_includes_ams(): status = mqtt_mod.get_status(_test_printer(simulation_mode=True)) assert status["gcode_state"] == "IDLE" assert "ams" in status assert status["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA" - def test_get_version_simulation(): mods = mqtt_mod.get_version(_test_printer(simulation_mode=True)) assert mods[0]["name"] == "ota" - def test_status_event_int_coercion(): ev = mqtt_mod._status_event({"gcode_state": "RUNNING", "mc_percent": "42", "layer_num": None}, "update") assert ev["event"] == "update" @@ -77,7 +67,6 @@ def test_status_event_int_coercion(): assert ev["layer_num"] == 0 assert ev["command"] == "status" - def test_monitor_status_simulation_ndjson(capsys): args = Namespace(json=True, sim=True) with patch.object(mqtt_mod.time, "sleep", return_value=None): @@ -89,11 +78,9 @@ def test_monitor_status_simulation_ndjson(capsys): assert events[-1]["event"] == "terminal" assert events[-1]["gcode_state"] == "FINISH" - def test_send_command_simulation_true(): assert mqtt_mod.send_command(_test_printer(simulation_mode=True), "{}") is True - def test_probe_cert_fingerprint_reads_der(): der = b"\x30\x82probe" expected = hashlib.sha256(der).hexdigest() @@ -114,10 +101,8 @@ def test_probe_cert_fingerprint_reads_der(): fp = mqtt_mod.probe_cert_fingerprint("10.0.0.1", 990, timeout=1) assert fp == expected - # --- FTPS sim / naming -------------------------------------------------------- - def test_sim_ftp_store_list_delete(): ftp = ftps_mod._SimFtp() with ftp as f: @@ -130,13 +115,11 @@ def test_sim_ftp_store_list_delete(): f.quit() f.close() - def test_sim_ftp_size_missing(): ftp = ftps_mod._SimFtp() with pytest.raises((OSError, Exception)): ftp.size("/model/nope.3mf") - def test_noncolliding_path_creates_sibling(tmp_path): p = tmp_path / "model.stl" p.write_text("a", encoding="utf-8") @@ -146,17 +129,14 @@ def test_noncolliding_path_creates_sibling(tmp_path): assert out.endswith(".stl") assert Path(out).parent == tmp_path - def test_get_ftp_simulation(): printer = _test_printer(simulation_mode=True) with printer.get_ftp_client(timeout=5) as client: assert client is not None assert "simulated_file.3mf" in client.nlst() - # --- netsafety extras --------------------------------------------------------- - def test_safe_http_handler_open_methods(): opener = netsafety.build_safe_opener() assert any(isinstance(h, netsafety.SafeHTTPSHandler) for h in opener.handlers) @@ -165,7 +145,6 @@ def test_safe_http_handler_open_methods(): # Even the CDN-compatibility UA must carry an honest, attributable token. assert "platecli/" in ua - def test_link_local_refused(): with ( patch.object(netsafety.socket, "getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.1.1", 443))]), @@ -175,10 +154,8 @@ def test_link_local_refused(): netsafety._get_safe_connection("ll.example", 443, 5, None) conn.assert_not_called() - # --- ZIP extract safety ------------------------------------------------------- - def test_extract_zip_rejects_no_model(tmp_path): zpath = tmp_path / "empty.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -187,7 +164,6 @@ def test_extract_zip_rejects_no_model(tmp_path): with pytest.raises(ValueError, match="supported model"): extract_mod._extract_zip_model(str(zpath), str(tmp_path), args) - def test_extract_zip_selects_stl(tmp_path): zpath = tmp_path / "m.zip" with zipfile.ZipFile(zpath, "w") as zf: @@ -198,15 +174,12 @@ def test_extract_zip_selects_stl(tmp_path): assert str(path).endswith(".stl") assert Path(path).is_file() - def test_sanitize_windows_reserved_names(): name = naming_mod._sanitize_download_filename("CON.stl") assert "CON" not in name.upper() or name != "CON.stl" - # --- migrate / preflight ------------------------------------------------------ - def test_migrate_access_code_writes_file(tmp_path): cfg_path = tmp_path / "config.json" code_path = tmp_path / "access_code" @@ -229,7 +202,6 @@ def test_migrate_access_code_writes_file(tmp_path): if sys.platform != "win32": assert (code_path.stat().st_mode & 0o777) == 0o600 - def test_migrate_noop_when_file_already_configured(tmp_path): cfg_path = tmp_path / "config.json" cfg_path.write_text( @@ -239,29 +211,24 @@ def test_migrate_noop_when_file_already_configured(tmp_path): result = migrate_mod.migrate_access_code(config_path=str(cfg_path)) assert result["status"] == "noop" - def test_preflight_collect_checks_has_python(): checks = preflight_mod.collect_preflight_checks() assert any(c.get("id") == "python" or c.get("name") == "python" or "Python" in str(c) for c in checks) - def test_preflight_placeholder_ip_is_error(): with settings_ctx(printer_ip="192.168.0.XXX"): checks = preflight_mod.collect_preflight_checks() statuses = [c.get("status") or c.get("level") for c in checks] assert "error" in statuses or any("placeholder" in str(c).lower() or "printer" in str(c).lower() for c in checks) - # --- camera pin missing already covered; docker URL localhost ----------------- - def test_camera_stream_url_localhost_default(): from bambu_cli.context import Settings s = Settings.from_config({"camera_port": "1985:1984"}) assert "localhost" in s.camera_stream_url - def test_send_command_retry_then_fail(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -270,7 +237,6 @@ def test_send_command_retry_then_fail(): assert mqtt_mod.send_command(printer, "{}", timeout=0.01, retries=1) is False assert client.connect.call_count >= 2 - def test_get_status_timeout_returns_none(): printer = _test_printer(simulation_mode=False) client = MagicMock() @@ -294,14 +260,12 @@ def connect(*a, **k): result = mqtt_mod.get_status(printer, timeout=0.01, retries=0) assert result is None - def test_common_looks_like_placeholder(): from bambu_cli.setup_cmd import common as common assert common._looks_like_placeholder("192.168.0.XXX", {"192.168.0.XXX"}) assert not common._looks_like_placeholder("10.0.0.5", {"192.168.0.XXX"}) - def test_common_secure_write_json(tmp_path): from bambu_cli.setup_cmd import common as common @@ -311,13 +275,11 @@ def test_common_secure_write_json(tmp_path): if sys.platform != "win32": assert (path.stat().st_mode & 0o777) == 0o600 - def test_migrate_cmd_file_not_found(): args = Namespace(access_code_file=None, json=False) with patch.object(migrate_mod, "_config_path", return_value="/no/such/config.json"), pytest.raises(BambuError): migrate_mod._cmd_migrate_access_code(args) - def test_migrate_cmd_noop_logs(tmp_path, capsys): cfg = tmp_path / "c.json" cfg.write_text(json.dumps({"access_code_file": "x", "printer_ip": "1.1.1.1", "serial": "s"}), encoding="utf-8") @@ -326,7 +288,6 @@ def test_migrate_cmd_noop_logs(tmp_path, capsys): migrate_mod._cmd_migrate_access_code(args) assert "noop" in capsys.readouterr().out - def test_secure_write_json_is_atomic_and_backs_up(tmp_path): """config.json writes keep a .bak; access-code writes do not. @@ -357,7 +318,6 @@ def test_secure_write_json_is_atomic_and_backs_up(tmp_path): assert not (tmp_path / "access_code.bak").exists() assert list(tmp_path.glob("*.tmp")) == [] - def test_secure_write_falls_back_when_rename_crosses_a_device_boundary(tmp_path): """A redirected directory must not make config/secret writes impossible. @@ -387,14 +347,12 @@ def test_secure_write_falls_back_when_rename_crosses_a_device_boundary(tmp_path) if sys.platform != "win32": assert (sec.stat().st_mode & 0o777) == 0o600 - def _winerror_oserror(): """An OSError shaped like Windows ERROR_NOT_SAME_DEVICE (winerror 17).""" exc = OSError("The system cannot move the file to a different disk drive") exc.winerror = 17 return exc - def test_cross_device_fallback_overwrites_existing_content(tmp_path): """The in-place path must fully replace, not append to, a shorter/longer file.""" from bambu_cli.setup_cmd import common as common @@ -405,7 +363,6 @@ def test_cross_device_fallback_overwrites_existing_content(tmp_path): common._secure_write_text(str(sec), "SHORT") assert sec.read_text() == "SHORT" - def test_secure_write_still_raises_on_unrelated_oserror(tmp_path): """Only cross-device failures degrade; a real error must not be swallowed.""" from bambu_cli.setup_cmd import common as common @@ -416,7 +373,6 @@ def test_secure_write_still_raises_on_unrelated_oserror(tmp_path): common._secure_write_json(str(path), {"a": 1}) assert list(tmp_path.glob("*.tmp")) == [] - def test_secure_write_json_leaves_original_intact_on_serialization_failure(tmp_path): from bambu_cli.setup_cmd import common as common diff --git a/tests/test_slicer_pure.py b/tests/test_slicer_pure.py index 3130c2f..160e667 100644 --- a/tests/test_slicer_pure.py +++ b/tests/test_slicer_pure.py @@ -3,23 +3,19 @@ from __future__ import annotations import json -import sys from argparse import Namespace from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) - -from bambu_cli import slicer as S # noqa: E402 +from bambu_cli import slicer as S from bambu_cli.errors import BambuError # noqa: E402 def test_normalize_wall_type_aliases(): assert S._normalize_wall_type(None) in (None, "") - assert S._normalize_wall_type("archaic") in ("classic", "archaic") or True + assert S._normalize_wall_type("archaic") in ("classic", "archaic") assert S._normalize_wall_type("inner/outer") is not None diff --git a/tests/test_tls_pinning.py b/tests/test_tls_pinning.py index 0840c6d..cc4e161 100644 --- a/tests/test_tls_pinning.py +++ b/tests/test_tls_pinning.py @@ -7,16 +7,10 @@ import hashlib import ssl -import sys from unittest.mock import MagicMock, patch import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.protocols import ftps as ftps_mod # noqa: E402 from bambu_cli.protocols import mqtt_tls as mqtt_tls # noqa: E402 from tests.bambu_test_base import _test_printer # noqa: E402 @@ -27,7 +21,6 @@ _FP = hashlib.sha256(_DER).hexdigest() _FP_OTHER = "ab" * 32 - def _tls_sock(der=_DER): tls = MagicMock(name="tls_sock") state = {"ready": False} @@ -45,7 +38,6 @@ def getpeercert(binary_form=False): tls._pin_state = state return tls - def test_mqtt_create_client_with_pin_uses_pinning_context(): mock_client = MagicMock(name="mqtt_client") with patch.object(mqtt_tls, "mqtt") as mock_mqtt_mod: @@ -60,7 +52,6 @@ def test_mqtt_create_client_with_pin_uses_pinning_context(): mock_client.tls_insecure_set.assert_called_once_with(True) mock_client.tls_set.assert_not_called() - def test_pinning_context_match_handshakes_then_verifies(): tls = _tls_sock(_DER) ctx = mqtt_tls.pinning_ssl_context(_FP) @@ -70,7 +61,6 @@ def test_pinning_context_match_handshakes_then_verifies(): tls.do_handshake.assert_called_once() assert tls._pin_state["ready"] is True - def test_pinning_context_mismatch_raises_sslerror(): tls = _tls_sock(b"\x00wrong-cert") ctx = mqtt_tls.pinning_ssl_context(_FP) @@ -81,7 +71,6 @@ def test_pinning_context_mismatch_raises_sslerror(): ctx.wrap_socket(object()) tls.do_handshake.assert_called_once() - def test_pinning_context_missing_peer_cert_raises(): tls = _tls_sock(None) ctx = mqtt_tls.pinning_ssl_context(_FP) @@ -91,7 +80,6 @@ def test_pinning_context_missing_peer_cert_raises(): ): ctx.wrap_socket(object()) - def test_pinning_context_malformed_pin_raises(): tls = _tls_sock(_DER) ctx = mqtt_tls.pinning_ssl_context("а" + "b" * 63) @@ -101,7 +89,6 @@ def test_pinning_context_malformed_pin_raises(): ): ctx.wrap_socket(object()) - def test_ftps_pin_match_on_connect(): mock_raw = MagicMock() mock_raw.family = 2 @@ -128,7 +115,6 @@ def test_ftps_pin_match_on_connect(): assert mock_ctx.verify_mode == ssl.CERT_NONE mock_ctx.wrap_socket.assert_called_once() - def test_ftps_pin_mismatch_on_connect(): mock_raw = MagicMock() mock_raw.family = 2 @@ -150,7 +136,6 @@ def test_ftps_pin_mismatch_on_connect(): ): ftp.connect("192.168.1.1", 990, 5) - def test_ftps_data_channel_pin_mismatch(): """Data-channel wrap must re-check the pin (not only the control channel).""" ftp = ftps_mod.ImplicitFTPS() @@ -175,7 +160,6 @@ def test_ftps_data_channel_pin_mismatch(): ): ftp.ntransfercmd("STOR /model/x.3mf") - def test_ftps_data_channel_pin_mismatch_closes_socket(): """Fingerprint mismatch must close the data socket before re-raising (no FD leak).""" ftp = ftps_mod.ImplicitFTPS() @@ -203,7 +187,6 @@ def test_ftps_data_channel_pin_mismatch_closes_socket(): data_tls.close.assert_called_once() - def test_ftps_data_channel_malformed_pin_closes_socket(): """A malformed (non-ASCII) pin must also fail closed as an ssl.SSLError and close the data socket — not escape as a raw TypeError that skips the diff --git a/tests/test_tui_confirm.py b/tests/test_tui_confirm.py index cb590c2..a2af3e5 100644 --- a/tests/test_tui_confirm.py +++ b/tests/test_tui_confirm.py @@ -10,17 +10,10 @@ import argparse import os -import sys import zipfile -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from textual.widgets import Input, Static # noqa: E402 @@ -38,7 +31,6 @@ _IDLE = StatusSnapshot(ok=True, raw={"gcode_state": "IDLE", "mc_percent": 0}, ams={"units": []}) - class FakeStatusProvider: def __init__(self, snapshots=None): self._snapshots = list(snapshots) if snapshots else [_IDLE] @@ -48,7 +40,6 @@ def fetch(self, args): self.calls += 1 return self._snapshots[min(self.calls - 1, len(self._snapshots) - 1)] - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -61,7 +52,6 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _isolated_cwd(tmp_path, monkeypatch): """Never let a declined print drop its preserved file in the repo. @@ -71,14 +61,12 @@ def _isolated_cwd(tmp_path, monkeypatch): """ monkeypatch.chdir(tmp_path) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - def _install_ready_settings(tmp_path, **overrides): from dataclasses import replace @@ -100,19 +88,16 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings)) return settings - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _make_stl(tmp_path, name="cube.stl"): p = tmp_path / name p.write_text("solid cube\nendsolid cube\n", encoding="utf-8") return str(p) - def _sliced_3mf(path, name="cube.gcode.3mf"): p = path / name with zipfile.ZipFile(p, "w") as zf: @@ -125,7 +110,6 @@ def _sliced_3mf(path, name="cube.gcode.3mf"): ) return str(p) - def _slicer_into_workdir(tmp_path): """A fake slicer that writes the .3mf inside the workdir, like the real one.""" @@ -134,7 +118,6 @@ def _slice(ns=None, **kwargs): return _slice - def _deps(steps, ams_detector=None, **kwargs): return TuiDeps( status_provider=FakeStatusProvider(), @@ -143,17 +126,14 @@ def _deps(steps, ams_detector=None, **kwargs): **kwargs, ) - async def _settle(pilot): await pilot.pause() await pilot.app.workers.wait_for_complete() await pilot.pause() - def _text(widget) -> str: return widget_text(widget) - async def _prepare_to_preview(pilot, source): """dashboard → n → source → prepared preview; returns the prepare screen.""" await pilot.press("n") @@ -167,7 +147,6 @@ async def _prepare_to_preview(pilot, source): assert screen.result is not None, "prepare did not reach the preview" return screen - async def _open_modal(pilot, screen): screen.open_confirm() await _settle(pilot) @@ -175,15 +154,12 @@ async def _open_modal(pilot, screen): assert isinstance(modal, ConfirmModal) return modal - async def _press_button(pilot, modal, button_id): modal.query_one(button_id).press() await _settle(pilot) - # --------------------------------------------------------------------------- - async def test_start_print_passes_confirm_true_exactly_once(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -203,7 +179,6 @@ async def test_start_print_passes_confirm_true_exactly_once(tmp_path): assert [ns.confirm for ns in job.calls] == [True] assert job.calls[0].source.endswith(".gcode.3mf") - async def test_upload_only_passes_confirm_false(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -225,7 +200,6 @@ async def test_upload_only_passes_confirm_false(tmp_path): # Upload-only must not open the monitor (nothing is printing). assert not any(isinstance(s, MonitorScreen) for s in app.screen_stack) - async def test_no_other_path_reaches_the_job_runner(tmp_path): """Preparing, backing out and declining never call the job pipeline.""" _install_ready_settings(tmp_path) @@ -248,7 +222,6 @@ async def test_no_other_path_reaches_the_job_runner(tmp_path): assert job.calls == [] - async def test_backing_out_of_the_modal_keeps_the_prepared_file(tmp_path): """Esc in the modal returns ownership: the file and preview survive.""" _install_ready_settings(tmp_path) @@ -268,7 +241,6 @@ async def test_backing_out_of_the_modal_keeps_the_prepared_file(tmp_path): assert os.path.exists(printable) assert screen.query_one("#print-button").disabled is False - async def test_decline_preserves_the_sliced_file_and_names_it(tmp_path, monkeypatch): """Cancel keeps the sliced file (wizard's decline path) and says where.""" _install_ready_settings(tmp_path) @@ -294,7 +266,6 @@ async def test_decline_preserves_the_sliced_file_and_names_it(tmp_path, monkeypa assert os.path.dirname(os.path.abspath(kept)) == str(cwd) assert not os.path.exists(workdir) - async def test_ams_mapping_only_when_detected_material_is_kept(tmp_path): """use_ams rides on the detected-material rule, through the real screens.""" _install_ready_settings(tmp_path) @@ -317,7 +288,6 @@ def detector(args, on_active_slot=None): assert job.calls[0].use_ams is True assert job.calls[0].ams_mapping == "2" - async def test_ams_mapping_absent_when_user_changes_material(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -347,7 +317,6 @@ def detector(args, on_active_slot=None): assert job.calls[0].confirm is True assert not getattr(job.calls[0], "use_ams", False) - async def test_failed_job_keeps_the_modal_open_and_the_file(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -368,7 +337,6 @@ async def test_failed_job_keeps_the_modal_open_and_the_file(tmp_path): assert modal.query_one("#confirm-print").disabled is False await _press_button(pilot, modal, "#confirm-cancel") - async def test_quit_is_refused_while_a_job_worker_is_in_flight(tmp_path): import threading @@ -406,7 +374,6 @@ def slow_job(ns=None, **kwargs): finally: gate.set() - async def test_start_print_opens_the_monitor(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -420,10 +387,8 @@ async def test_start_print_opens_the_monitor(tmp_path): await _press_button(pilot, modal, "#confirm-print") assert isinstance(pilot.app.screen, MonitorScreen) - # --- the summary grid renders filenames as data, not Rich markup ----------- - async def test_confirm_summary_shows_a_bracketed_filename_verbatim(tmp_path): """The one screen that names the file must not let Rich eat part of it. @@ -445,7 +410,6 @@ async def test_confirm_summary_shows_a_bracketed_filename_verbatim(tmp_path): assert "model [remix].stl" in summary - def test_confirm_summary_survives_a_markup_shaped_value(): """A closing-tag shape must render, not raise MarkupError mid-modal.""" from bambu_cli.tui.screens.confirm import _summary_table diff --git a/tests/test_tui_dashboard.py b/tests/test_tui_dashboard.py index 439ace8..37527c3 100644 --- a/tests/test_tui_dashboard.py +++ b/tests/test_tui_dashboard.py @@ -9,16 +9,9 @@ from __future__ import annotations import argparse -import sys -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from bambu_cli import context as _context # noqa: E402 @@ -60,7 +53,6 @@ }, ) - class FakeStatusProvider: """Returns scripted snapshots; records how many fetches happened.""" @@ -73,13 +65,11 @@ def fetch(self, args): idx = min(self.calls - 1, len(self._snapshots) - 1) return self._snapshots[idx] - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _all_text(app): """Concatenate the rendered text of the two dashboard panels. @@ -93,14 +83,12 @@ def _all_text(app): parts.append(widget_text(widget)) return "\n".join(parts) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - async def test_dashboard_renders_status_and_ams(): provider = FakeStatusProvider([_IDLE_SNAPSHOT]) app = PlateApp(_args(), TuiDeps(status_provider=provider)) @@ -113,7 +101,6 @@ async def test_dashboard_renders_status_and_ams(): assert "TPU" in text assert provider.calls >= 1 - async def test_r_key_triggers_a_refresh(): provider = FakeStatusProvider([_IDLE_SNAPSHOT]) app = PlateApp(_args(), TuiDeps(status_provider=provider)) @@ -124,7 +111,6 @@ async def test_r_key_triggers_a_refresh(): await pilot.pause() assert provider.calls > before - async def test_q_key_quits(): provider = FakeStatusProvider([_IDLE_SNAPSHOT]) app = PlateApp(_args(), TuiDeps(status_provider=provider)) @@ -135,7 +121,6 @@ async def test_q_key_quits(): # The app is no longer running after quit. assert app.is_running is False - async def test_unreachable_printer_renders_error_state(): bad = StatusSnapshot(ok=False, error="Printer unreachable (timeout).") provider = FakeStatusProvider([bad]) @@ -145,7 +130,6 @@ async def test_unreachable_printer_renders_error_state(): text = _all_text(app) assert "unreachable" in text.lower() - async def test_dashboard_against_sim_transport(): """End-to-end sim path: real StatusService + simulation-mode printer.""" _context.set_current( @@ -169,7 +153,6 @@ async def test_dashboard_against_sim_transport(): assert "IDLE" in text assert "PLA" in text - # --- printer-supplied text is data, never Rich markup ---------------------- # # Every cell in these two panels carries strings the *printer* chose: the name @@ -178,7 +161,6 @@ async def test_dashboard_against_sim_transport(): # renders as "model .stl") and can raise ``MarkupError`` mid-render on a name # shaped like a closing tag. Passing ``Text`` is what stops both. - def _bracketed_file_snapshot(name): return StatusSnapshot( ok=True, @@ -192,7 +174,6 @@ def _bracketed_file_snapshot(name): ams={"units": []}, ) - def _tray_type_snapshot(ftype): return StatusSnapshot( ok=True, @@ -210,7 +191,6 @@ def _tray_type_snapshot(ftype): }, ) - async def test_status_panel_renders_a_bracketed_filename_verbatim(): """A "[remix]" tag in the running file's name must survive to the screen.""" provider = FakeStatusProvider([_bracketed_file_snapshot("model [remix].stl")]) @@ -220,7 +200,6 @@ async def test_status_panel_renders_a_bracketed_filename_verbatim(): text = _all_text(app) assert "model [remix].stl" in text - async def test_status_panel_survives_a_markup_shaped_filename(): """A closing-tag shape must not blow the render up (MarkupError).""" provider = FakeStatusProvider([_bracketed_file_snapshot("a[/b]c.gcode")]) @@ -230,7 +209,6 @@ async def test_status_panel_survives_a_markup_shaped_filename(): text = _all_text(app) # raises rich.errors.MarkupError against a str cell assert "a[/b]c.gcode" in text - async def test_ams_panel_renders_a_bracketed_filament_type_verbatim(): provider = FakeStatusProvider([_tray_type_snapshot("PLA [matte]")]) app = PlateApp(_args(), TuiDeps(status_provider=provider)) @@ -239,7 +217,6 @@ async def test_ams_panel_renders_a_bracketed_filament_type_verbatim(): text = _all_text(app) assert "PLA [matte]" in text - async def test_ams_panel_survives_a_markup_shaped_filament_type(): provider = FakeStatusProvider([_tray_type_snapshot("a[/b]c")]) app = PlateApp(_args(), TuiDeps(status_provider=provider)) @@ -248,7 +225,6 @@ async def test_ams_panel_survives_a_markup_shaped_filament_type(): text = _all_text(app) assert "a[/b]c" in text - async def test_dashboard_timer_disarms_on_suspend_and_unmount(): """A leaked interval would trip ResourceWarning-as-error in CI.""" provider = FakeStatusProvider([_IDLE_SNAPSHOT]) @@ -263,7 +239,6 @@ async def test_dashboard_timer_disarms_on_suspend_and_unmount(): assert dash._timer is not None assert dash._timer is None - async def test_quit_releases_the_status_provider(): closed = [] diff --git a/tests/test_tui_entry.py b/tests/test_tui_entry.py index b211e03..df80fae 100644 --- a/tests/test_tui_entry.py +++ b/tests/test_tui_entry.py @@ -12,28 +12,20 @@ import importlib.util import json as _json import sys -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli import utils # noqa: E402 from bambu_cli.constants import LOCAL_COMMANDS # noqa: E402 from bambu_cli.errors import BambuError # noqa: E402 from bambu_cli.tui import entry as entry_mod # noqa: E402 from bambu_cli.tui.entry import cmd_tui # noqa: E402 - def _args(**kwargs) -> argparse.Namespace: base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - @pytest.fixture(autouse=True) def _reset_json_state(): utils._JSON_EMITTED = False @@ -42,13 +34,11 @@ def _reset_json_state(): utils._JSON_EMITTED = False utils._LAST_ERROR_PAYLOAD = None - @pytest.fixture def _tty(monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True) yield - def test_json_mode_emits_error_envelope_and_exits_5(capsys): with pytest.raises(BambuError) as ei: cmd_tui(_args(json=True)) @@ -59,7 +49,6 @@ def test_json_mode_emits_error_envelope_and_exits_5(capsys): assert payload["exit_code"] == 5 assert payload["failed_step"] == "parse" - def test_non_tty_stdin_aborts_exit_5(monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: False) with pytest.raises(BambuError) as ei: @@ -67,7 +56,6 @@ def test_non_tty_stdin_aborts_exit_5(monkeypatch): assert ei.value.exit_code == 5 assert "interactive" in str(ei.value) - def test_missing_textual_extra_aborts_exit_1(monkeypatch, _tty): # Simulate the extra not being installed: find_spec returns None only for # 'textual'. This drives the missing-extra branch without uninstalling. @@ -84,13 +72,11 @@ def fake_find_spec(name, *args, **kwargs): assert ei.value.exit_code == 1 assert "platecli[tui]" in str(ei.value) - def test_tui_in_local_commands_routing(): # Routing: like `go`, tui must be a LOCAL_COMMAND so it renders its own # guidance instead of hard-failing on an unconfigured printer IP. assert "tui" in LOCAL_COMMANDS - def test_tui_launches_app_when_all_guards_pass(monkeypatch, _tty): # With a TTY, no --json, and Textual "available", cmd_tui delegates to the # app runner exactly once with the parsed args — without really opening a UI. diff --git a/tests/test_tui_monitor.py b/tests/test_tui_monitor.py index b65d58f..21fc8f3 100644 --- a/tests/test_tui_monitor.py +++ b/tests/test_tui_monitor.py @@ -10,18 +10,11 @@ import argparse import os -import sys import zipfile from pathlib import Path -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from textual.widgets import Input, Static # noqa: E402 @@ -38,7 +31,6 @@ from bambu_cli.tui.widgets.job_progress import JobProgress # noqa: E402 from tests.tui_text import widget_text # noqa: E402 - def _snap(state, percent, layer=0, total=100, remaining=0): return StatusSnapshot( ok=True, @@ -54,7 +46,6 @@ def _snap(state, percent, layer=0, total=100, remaining=0): ams={"units": []}, ) - class ScriptedStatus: """Replays scripted snapshots and counts every fetch.""" @@ -66,7 +57,6 @@ def fetch(self, args): self.calls += 1 return self._snapshots[min(self.calls - 1, len(self._snapshots) - 1)] - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -79,7 +69,6 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _isolated_cwd(tmp_path, monkeypatch): """Never let a declined print drop its preserved file in the repo. @@ -89,14 +78,12 @@ def _isolated_cwd(tmp_path, monkeypatch): """ monkeypatch.chdir(tmp_path) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - def _install_ready_settings(tmp_path, **overrides): from dataclasses import replace @@ -118,13 +105,11 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings, simulation=bool(overrides.get("_sim")))) return settings - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _sliced_3mf(path, name="cube.gcode.3mf"): p = Path(path) / name with zipfile.ZipFile(p, "w") as zf: @@ -137,17 +122,14 @@ def _sliced_3mf(path, name="cube.gcode.3mf"): ) return str(p) - def _slicer_into_workdir(ns=None, **kwargs): return _sliced_3mf(ns.output) - async def _settle(pilot): await pilot.pause() await pilot.app.workers.wait_for_complete() await pilot.pause() - async def _pump(pilot, times=10, delay=0.02): """Give the poll worker real (short) wall time without waiting on workers.""" import asyncio @@ -156,7 +138,6 @@ async def _pump(pilot, times=10, delay=0.02): await pilot.pause() await asyncio.sleep(delay) - async def _wait_until(condition, pilot, timeout=5.0): import asyncio @@ -166,14 +147,11 @@ async def _wait_until(condition, pilot, timeout=5.0): await pilot.pause() await asyncio.sleep(0.01) - def _text(widget) -> str: return widget_text(widget) - # --- MonitorService unit level (no pilot) ---------------------------------- - def test_monitor_terminal_states_match_the_cli_monitor(): from bambu_cli.protocols.mqtt import TERMINAL_GCODE_STATES from bambu_cli.tui.services import terminal_gcode_states @@ -181,7 +159,6 @@ def test_monitor_terminal_states_match_the_cli_monitor(): assert terminal_gcode_states() is TERMINAL_GCODE_STATES assert set(TERMINAL_GCODE_STATES) == {"FINISH", "FAILED", "STOP", "IDLE"} - def test_monitor_service_is_terminal(): service = MonitorService(ScriptedStatus([_snap("RUNNING", 10)])) assert service.is_terminal(_snap("RUNNING", 10)) is False @@ -191,7 +168,6 @@ def test_monitor_service_is_terminal(): # declaring a print finished because MQTT hiccuped. assert service.is_terminal(StatusSnapshot(ok=False, error="timeout")) is False - def test_job_progress_lines_and_formatting(): from bambu_cli.tui.services import format_remaining, job_progress_lines, progress_percent @@ -207,10 +183,8 @@ def test_job_progress_lines_and_formatting(): assert progress_percent(StatusSnapshot(ok=False, error="x")) == 0 assert dict(job_progress_lines(StatusSnapshot(ok=False, error="boom")))["Status"] == "boom" - # --- pilot ----------------------------------------------------------------- - def _deps(monitor_provider, **kwargs): """Deps whose MONITOR polls ``monitor_provider``. @@ -224,7 +198,6 @@ def _deps(monitor_provider, **kwargs): **kwargs, ) - async def test_monitor_progresses_and_stops_on_terminal_state(tmp_path): _install_ready_settings(tmp_path) provider = ScriptedStatus([_snap("RUNNING", 10, layer=5), _snap("RUNNING", 60, layer=60), _snap("FINISH", 100)]) @@ -250,7 +223,6 @@ async def test_monitor_progresses_and_stops_on_terminal_state(tmp_path): # And it really did poll three times (RUNNING, RUNNING, FINISH). assert settled == 3 - async def test_escape_detaches_without_stopping_anything(tmp_path): _install_ready_settings(tmp_path) provider = ScriptedStatus([_snap("RUNNING", 10)]) @@ -273,7 +245,6 @@ async def test_escape_detaches_without_stopping_anything(tmp_path): # ...and nothing was sent to the printer to stop the print. assert stop.calls == [] - async def test_monitor_survives_an_unreadable_status(tmp_path): _install_ready_settings(tmp_path) provider = ScriptedStatus([StatusSnapshot(ok=False, error="Printer unreachable (timeout)."), _snap("FINISH", 100)]) @@ -288,7 +259,6 @@ async def test_monitor_survives_an_unreadable_status(tmp_path): await _wait_until(lambda: screen.finished, pilot) assert provider.calls == 2 - async def test_m_does_not_stack_monitor_screens(tmp_path): _install_ready_settings(tmp_path) provider = ScriptedStatus([_snap("FINISH", 100)]) @@ -301,7 +271,6 @@ async def test_m_does_not_stack_monitor_screens(tmp_path): await _settle(pilot) assert sum(isinstance(s, MonitorScreen) for s in app.screen_stack) == 1 - async def test_full_sim_end_to_end_dashboard_to_finish(tmp_path): """Plan §7 acceptance: dashboard → n → presets → prepare → confirm → FINISH.""" _install_ready_settings(tmp_path) diff --git a/tests/test_tui_polish.py b/tests/test_tui_polish.py index 2d696b3..464bebb 100644 --- a/tests/test_tui_polish.py +++ b/tests/test_tui_polish.py @@ -8,18 +8,12 @@ import argparse import os -import sys import zipfile from pathlib import Path from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from textual.widgets import Footer, Input, Static # noqa: E402 @@ -38,7 +32,6 @@ _SMALL = (80, 24) - def _snap(state="IDLE", percent=0): return StatusSnapshot( ok=True, @@ -65,7 +58,6 @@ def _snap(state="IDLE", percent=0): }, ) - class ScriptedStatus: def __init__(self, snapshots=None): self._snapshots = list(snapshots) if snapshots else [_snap()] @@ -75,7 +67,6 @@ def fetch(self, args): self.calls += 1 return self._snapshots[min(self.calls - 1, len(self._snapshots) - 1)] - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -88,7 +79,6 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _isolated_cwd(tmp_path, monkeypatch): """Never let a declined print drop its preserved file in the repo. @@ -98,14 +88,12 @@ def _isolated_cwd(tmp_path, monkeypatch): """ monkeypatch.chdir(tmp_path) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - def _install_ready_settings(tmp_path, **overrides): from dataclasses import replace @@ -127,13 +115,11 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings)) return settings - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _sliced_3mf(path, name="cube.gcode.3mf"): p = Path(path) / name with zipfile.ZipFile(p, "w") as zf: @@ -144,28 +130,23 @@ def _sliced_3mf(path, name="cube.gcode.3mf"): ) return str(p) - def _slicer_into_workdir(ns=None, **kwargs): return _sliced_3mf(ns.output) - def _deps(**kwargs): kwargs.setdefault("status_provider", ScriptedStatus()) kwargs.setdefault("ams_detector", lambda args: None) kwargs.setdefault("poll_interval", 0.01) return TuiDeps(**kwargs) - async def _settle(pilot): await pilot.pause() await pilot.app.workers.wait_for_complete() await pilot.pause() - def _text(widget) -> str: return widget_text(widget) - async def _prepared_modal(pilot, app, source): """dashboard -> n -> prepare(source) -> confirm modal (returns the modal).""" await pilot.press("n") @@ -182,7 +163,6 @@ async def _prepared_modal(pilot, app, source): assert isinstance(modal, ConfirmModal) return modal - def _footer_keys(app) -> set[str]: """The keys the Footer of the active screen advertises.""" keys: set[str] = set() @@ -192,12 +172,10 @@ def _footer_keys(app) -> set[str]: keys.add(active.binding.key) return keys - # --------------------------------------------------------------------------- # Help overlay # --------------------------------------------------------------------------- - def test_help_text_lists_every_section_and_key(): text = help_text() for section, rows in HELP_ROWS: @@ -207,7 +185,6 @@ def test_help_text_lists_every_section_and_key(): assert description in text assert "confirm dialog" in text # the safety model is spelled out - def test_help_rows_only_document_keys_that_are_really_bound(): """The overlay must not advertise a key nothing answers to.""" from textual.binding import Binding @@ -234,7 +211,6 @@ def test_help_rows_only_document_keys_that_are_really_bound(): for key in documented: assert normalized.get(key, key) in bound, f"help documents unbound key {key!r}" - async def test_question_mark_opens_and_closes_the_help_overlay(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -251,7 +227,6 @@ async def test_question_mark_opens_and_closes_the_help_overlay(tmp_path): # ... and the dashboard is back underneath. assert isinstance(app.screen, DashboardScreen) - async def test_help_closes_with_escape_and_with_q_without_quitting(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -271,7 +246,6 @@ async def test_help_closes_with_escape_and_with_q_without_quitting(tmp_path): assert app.is_running is True assert isinstance(app.screen, DashboardScreen) - async def test_help_is_reachable_from_every_screen(tmp_path): _install_ready_settings(tmp_path) stl = tmp_path / "cube.stl" @@ -316,12 +290,10 @@ async def test_help_is_reachable_from_every_screen(tmp_path): await _settle(pilot) assert isinstance(app.screen, HelpScreen) - # --------------------------------------------------------------------------- # Footer consistency # --------------------------------------------------------------------------- - async def test_footer_advertises_only_keys_that_work_on_that_screen(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -340,7 +312,6 @@ async def test_footer_advertises_only_keys_that_work_on_that_screen(tmp_path): assert "r" not in prepare_keys assert "n" not in prepare_keys - async def test_monitor_and_preflight_footers(tmp_path): settings = _install_ready_settings(tmp_path) import shutil @@ -361,12 +332,10 @@ async def test_monitor_and_preflight_footers(tmp_path): assert isinstance(app.screen, PreflightErrorScreen) assert {"escape", "q", "question_mark"} <= _footer_keys(app) - # --------------------------------------------------------------------------- # 80x24 # --------------------------------------------------------------------------- - async def test_main_flow_at_80x24(tmp_path): """Every screen renders and works at the smallest supported terminal.""" _install_ready_settings(tmp_path) @@ -421,7 +390,6 @@ async def test_main_flow_at_80x24(tmp_path): assert len(job.calls) == 1 assert job.calls[0].confirm is True - async def test_long_error_text_fits_at_80x24(tmp_path): """A very long slicer error wraps inside the prepare panel.""" from bambu_cli.errors import BambuError @@ -447,12 +415,10 @@ async def test_long_error_text_fits_at_80x24(tmp_path): assert status.outer_size.width <= 80 assert app.screen.container_size.width <= 80 - # --------------------------------------------------------------------------- # Wiring gaps (previously uncovered branches) # --------------------------------------------------------------------------- - def test_tuideps_defaults_build_the_real_collaborators(): from bambu_cli.interactive.core import GoSteps as CoreGoSteps from bambu_cli.interactive.core import read_loaded_ams_material @@ -471,7 +437,6 @@ def test_tuideps_defaults_build_the_real_collaborators(): assert deps.get_ams_detector() is read_loaded_ams_material assert deps.get_poll_interval() == 3.0 - def test_tuideps_injection_beats_every_default(): sentinel = object() deps = TuiDeps( @@ -489,7 +454,6 @@ def test_tuideps_injection_beats_every_default(): assert deps.get_monitor_service() is sentinel assert deps.get_poll_interval() == 0.5 - def test_run_app_constructs_and_runs_the_app(monkeypatch): """``run_app`` is the production entry: it builds PlateApp and runs it.""" from bambu_cli.tui import app as app_mod @@ -506,7 +470,6 @@ def fake_run(self): assert ran["deps"] is deps assert ran["args"].sim is True - async def test_refresh_key_is_a_no_op_on_screens_without_status(tmp_path): """`r` delegates to the active screen only when it can refresh.""" _install_ready_settings(tmp_path) @@ -528,7 +491,6 @@ async def test_refresh_key_is_a_no_op_on_screens_without_status(tmp_path): await _settle(pilot) assert sum(isinstance(s, PrepareScreen) for s in app.screen_stack) == 1 - async def test_second_start_press_cannot_double_submit(tmp_path): """Guards on the modal's re-entrant paths (running job blocks everything).""" import threading @@ -572,7 +534,6 @@ def slow_job(ns=None, **kwargs): finally: gate.set() - async def test_unexpected_job_error_is_reported_with_captured_output(tmp_path): """A non-BambuError from the job surfaces, with a tail of what it printed.""" _install_ready_settings(tmp_path) @@ -606,7 +567,6 @@ def noisy_job(ns=None, **kwargs): modal.query_one("#confirm-cancel").press() await _settle(pilot) - def test_output_tail_is_bounded(): from bambu_cli.tui.screens.confirm import _with_output_tail @@ -620,7 +580,6 @@ def test_output_tail_is_bounded(): long_line = _with_output_tail("boom", "x" * 500, width=20) assert long_line.splitlines()[1] == "x" * 20 - async def test_app_exit_with_the_modal_open_cleans_the_workdir(tmp_path): """Quitting mid-decision deletes the temp workdir (the wizard's finally).""" _install_ready_settings(tmp_path) @@ -648,7 +607,6 @@ async def test_app_exit_with_the_modal_open_cleans_the_workdir(tmp_path): assert not os.path.exists(workdir) - async def test_dashboard_ignores_a_refresh_while_one_is_in_flight(tmp_path): import threading @@ -680,12 +638,10 @@ def fetch(self, args): finally: gate.set() - # --------------------------------------------------------------------------- # Service-level branches (pure; no pilot) # --------------------------------------------------------------------------- - def test_status_service_captures_every_failure_shape(monkeypatch): from bambu_cli.tui.services import StatusService, _short_error @@ -734,7 +690,6 @@ def status(self): assert StatusService().fetch(_args()).error == "Printer unreachable (OSError)." assert _short_error(ValueError("plain")) == "plain" - def test_status_service_holds_one_printer_and_releases_mqtt(): from bambu_cli.tui.services import StatusService @@ -773,7 +728,6 @@ def status(self): service.close() assert printer.releases == 1 - def test_status_lines_show_targets_and_file(): from bambu_cli.tui.services import status_lines @@ -794,7 +748,6 @@ def test_status_lines_show_targets_and_file(): assert rows["Bed"] == "55°C → 60°C" assert rows["File"] == "cube.gcode.3mf" - def test_job_progress_names_the_file_and_survives_junk(): from bambu_cli.tui.services import job_progress_lines, progress_percent @@ -805,7 +758,6 @@ def test_job_progress_names_the_file_and_survives_junk(): assert dict(job_progress_lines(snapshot))["File"] == "cube.gcode.3mf" assert progress_percent(snapshot) == 0 # a non-numeric percent is not a crash - def test_pipeline_and_monitor_services_build_their_own_collaborators(): from bambu_cli.interactive.core import GoSteps as CoreGoSteps from bambu_cli.tui.services import MonitorService, PipelineService, StatusService @@ -813,7 +765,6 @@ def test_pipeline_and_monitor_services_build_their_own_collaborators(): assert isinstance(PipelineService()._get_steps(), CoreGoSteps) assert isinstance(MonitorService()._provider(), StatusService) - def test_pipeline_cleanup_tolerates_none_and_missing_dirs(tmp_path): from bambu_cli.interactive.core import WizardState, make_workdir from bambu_cli.tui.services import PipelineService @@ -826,7 +777,6 @@ def test_pipeline_cleanup_tolerates_none_and_missing_dirs(tmp_path): assert not os.path.exists(workdir) service.cleanup_workdir(workdir) # already gone: still fine - def test_pressed_helper_falls_back_when_nothing_is_selected(): from bambu_cli.tui.screens.prepare import _pressed @@ -836,12 +786,10 @@ class NoSelection: assert _pressed(NoSelection(), ["PLA", "PETG"]) == "PLA" assert _pressed(NoSelection(), ["draft", "standard"], default="standard") == "standard" - # --------------------------------------------------------------------------- # Prepare-screen wiring # --------------------------------------------------------------------------- - async def test_print_button_opens_the_confirm_modal(tmp_path): """The preview's button — not just the API — reaches the modal.""" _install_ready_settings(tmp_path) @@ -867,7 +815,6 @@ async def test_print_button_opens_the_confirm_modal(tmp_path): app.screen.query_one("#confirm-cancel").press() await _settle(pilot) - async def test_confirm_dismissed_without_an_outcome_returns_ownership(tmp_path): """Defensive path: a screen dismissal with no outcome destroys nothing.""" _install_ready_settings(tmp_path) @@ -892,7 +839,6 @@ async def test_confirm_dismissed_without_an_outcome_returns_ownership(tmp_path): assert prepare.result is handed assert os.path.exists(printable) - async def test_open_confirm_is_refused_without_a_prepared_file(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -905,7 +851,6 @@ async def test_open_confirm_is_refused_without_a_prepared_file(tmp_path): await _settle(pilot) assert isinstance(app.screen, PrepareScreen) - async def test_app_level_help_and_refresh_actions_are_idempotent(tmp_path): _install_ready_settings(tmp_path) provider = ScriptedStatus() @@ -923,7 +868,6 @@ async def test_app_level_help_and_refresh_actions_are_idempotent(tmp_path): await _settle(pilot) assert sum(isinstance(s, HelpScreen) for s in app.screen_stack) == 1 - async def test_preflight_screen_quit_goes_through_the_app_guard(tmp_path): settings = _install_ready_settings(tmp_path) import shutil @@ -944,7 +888,6 @@ async def test_preflight_screen_quit_goes_through_the_app_guard(tmp_path): await pilot.pause() assert app.is_running is False - async def test_monitor_worker_stops_when_the_screen_goes_away_mid_interval(tmp_path): """Leaving between polls ends the loop immediately, not after the interval.""" _install_ready_settings(tmp_path) @@ -961,7 +904,6 @@ async def test_monitor_worker_stops_when_the_screen_goes_away_mid_interval(tmp_p await pilot.app.workers.wait_for_complete() assert isinstance(app.screen, DashboardScreen) - async def test_prepare_ignores_a_second_start_while_one_is_running(tmp_path): import threading @@ -999,7 +941,6 @@ def slow_slice(ns=None, **kwargs): finally: gate.set() - async def test_help_is_refused_while_a_job_is_in_flight(tmp_path): """No overlay may cover the confirm modal while its job worker runs.""" import threading @@ -1033,7 +974,6 @@ def slow_job(ns=None, **kwargs): finally: gate.set() - async def test_job_outcome_lands_even_if_an_overlay_covers_the_modal(tmp_path): """Second layer: an overlay pushed over the modal cannot strand the result. @@ -1074,7 +1014,6 @@ def slow_job(ns=None, **kwargs): finally: gate.set() - async def test_upload_only_outcome_lands_from_under_an_overlay(tmp_path): """Same repair for upload-only: the prepare screen gets its message.""" import threading @@ -1107,7 +1046,6 @@ def slow_job(ns=None, **kwargs): finally: gate.set() - async def test_dashboard_shows_the_progress_bar_only_while_a_job_runs(tmp_path): """A 0% bar on an idle printer reads as a stalled print, so it stays hidden.""" from bambu_cli.tui.widgets.job_progress import JobProgress @@ -1128,7 +1066,6 @@ async def test_dashboard_shows_the_progress_bar_only_while_a_job_runs(tmp_path): await _settle(pilot) assert bar.display is False # FINISH is not an active state - async def test_confirm_modal_says_what_it_is_about_to_print(tmp_path): """The riskiest dialog in the app must show more than a temp path.""" _install_ready_settings(tmp_path) diff --git a/tests/test_tui_prepare.py b/tests/test_tui_prepare.py index 1ce6ba4..bf8766d 100644 --- a/tests/test_tui_prepare.py +++ b/tests/test_tui_prepare.py @@ -11,17 +11,10 @@ import argparse import os -import sys import zipfile -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from textual.widgets import Input, RadioButton, Static # noqa: E402 @@ -37,12 +30,10 @@ _IDLE = StatusSnapshot(ok=True, raw={"gcode_state": "IDLE", "mc_percent": 0}, ams={"units": []}) - class FakeStatusProvider: def fetch(self, args): return _IDLE - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -55,7 +46,6 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _isolated_cwd(tmp_path, monkeypatch): """Never let a declined print drop its preserved file in the repo. @@ -65,14 +55,12 @@ def _isolated_cwd(tmp_path, monkeypatch): """ monkeypatch.chdir(tmp_path) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - def _install_ready_settings(tmp_path, **overrides): from dataclasses import replace @@ -94,19 +82,16 @@ def _install_ready_settings(tmp_path, **overrides): _context.set_current(RuntimeContext(settings=settings)) return settings - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _make_stl(tmp_path, name="cube.stl"): p = tmp_path / name p.write_text("solid cube\nendsolid cube\n", encoding="utf-8") return str(p) - def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): p = tmp_path / name with zipfile.ZipFile(p, "w") as zf: @@ -119,7 +104,6 @@ def _sliced_3mf(tmp_path, name="cube.gcode.3mf"): ) return str(p) - def _deps(steps=None, ams_detector=None): return TuiDeps( status_provider=FakeStatusProvider(), @@ -127,14 +111,12 @@ def _deps(steps=None, ams_detector=None): ams_detector=ams_detector if ams_detector is not None else (lambda args: None), ) - async def _settle(pilot): """Let queued messages AND thread workers finish before asserting.""" await pilot.pause() await pilot.app.workers.wait_for_complete() await pilot.pause() - async def _open_prepare(pilot): await pilot.press("n") await _settle(pilot) @@ -142,21 +124,17 @@ async def _open_prepare(pilot): assert isinstance(screen, PrepareScreen) return screen - async def _submit_source(pilot, screen, source): screen.query_one("#source-input", Input).value = source screen.query_one("#source-input", Input).focus() await pilot.press("enter") await _settle(pilot) - def _text(widget) -> str: return widget_text(widget) - # --------------------------------------------------------------------------- - async def test_n_opens_prepare_screen(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -168,7 +146,6 @@ async def test_n_opens_prepare_screen(tmp_path): await _settle(pilot) assert sum(isinstance(s, PrepareScreen) for s in app.screen_stack) == 1 - async def test_invalid_source_shows_inline_error(tmp_path): _install_ready_settings(tmp_path) slicer = Recorder() @@ -187,7 +164,6 @@ async def test_invalid_source_shows_inline_error(tmp_path): await pilot.pause() assert _text(screen.query_one("#source-error", Static)) == "" - async def test_valid_local_stl_reaches_preview(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -214,7 +190,6 @@ async def test_valid_local_stl_reaches_preview(tmp_path): # Leaving the screen took the temp workdir with it. assert not os.path.exists(workdir) - async def test_presliced_3mf_shows_material_not_applied_caveat(tmp_path): _install_ready_settings(tmp_path) presliced = _sliced_3mf(tmp_path, name="ready.gcode.3mf") @@ -237,7 +212,6 @@ async def test_presliced_3mf_shows_material_not_applied_caveat(tmp_path): assert "PETG" not in preview assert slicer.calls == [] - async def test_ams_detected_material_is_preselected(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps(ams_detector=lambda args: "PETG")) @@ -252,7 +226,6 @@ async def test_ams_detected_material_is_preselected(tmp_path): assert petg.value is True assert pla.value is False - async def test_no_ams_detection_keeps_pla_default(tmp_path): _install_ready_settings(tmp_path) # A detector that fails entirely (the real one returns None on any error). @@ -266,7 +239,6 @@ async def test_no_ams_detection_keeps_pla_default(tmp_path): for name in ("pla", "petg", "abs", "tpu"): assert "(detected in AMS)" not in str(screen.query_one(f"#material-{name}", RadioButton).label) - async def test_pipeline_failure_renders_inline_and_app_survives(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -282,7 +254,6 @@ async def test_pipeline_failure_renders_inline_and_app_survives(tmp_path): assert screen.result is None assert app.is_running is True - async def test_supports_checkbox_and_quality_reach_the_slicer(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -304,7 +275,6 @@ async def test_supports_checkbox_and_quality_reach_the_slicer(tmp_path): assert slicer.calls[0].supports is True assert slicer.calls[0].quality == "draft" - async def test_preflight_failure_points_at_plate_setup(tmp_path): settings = _install_ready_settings(tmp_path) import shutil @@ -326,7 +296,6 @@ async def test_preflight_failure_points_at_plate_setup(tmp_path): await _settle(pilot) assert not isinstance(app.screen, PreflightErrorScreen) - async def test_unconfigured_printer_blocks_prepare(tmp_path): _install_ready_settings(tmp_path, printer_ip="0.0.0.0") app = PlateApp(_args(), _deps()) @@ -337,7 +306,6 @@ async def test_unconfigured_printer_blocks_prepare(tmp_path): assert isinstance(app.screen, PreflightErrorScreen) assert "plate setup" in _text(app.screen.query_one("#preflight-problem", Static)) - async def test_escape_returns_to_dashboard_from_prepare(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -348,7 +316,6 @@ async def test_escape_returns_to_dashboard_from_prepare(tmp_path): await _settle(pilot) assert not isinstance(app.screen, PrepareScreen) - async def test_unexpected_pipeline_error_is_reported_not_raised(tmp_path): """A non-BambuError from a collaborator still renders inline (thread worker).""" _install_ready_settings(tmp_path) @@ -364,7 +331,6 @@ async def test_unexpected_pipeline_error_is_reported_not_raised(tmp_path): assert screen.result is None assert app.is_running is True - async def test_second_prepare_cleans_up_the_first_workdir(tmp_path): """Re-preparing must not leak the previous run's temp workdir.""" _install_ready_settings(tmp_path) @@ -383,7 +349,6 @@ async def test_second_prepare_cleans_up_the_first_workdir(tmp_path): assert first_workdir != second_workdir assert not os.path.exists(first_workdir) - async def _open_prepare_nowait(pilot): """Open the prepare screen WITHOUT waiting for its workers to finish. @@ -397,7 +362,6 @@ async def _open_prepare_nowait(pilot): assert isinstance(screen, PrepareScreen) return screen - async def _wait_for(condition, pilot, timeout=5.0): """Pump the UI until ``condition()`` is true (never sleeps blindly).""" import asyncio @@ -408,7 +372,6 @@ async def _wait_for(condition, pilot, timeout=5.0): await pilot.pause() await asyncio.sleep(0.02) - async def test_escaping_mid_prepare_does_not_leak_the_workdir(tmp_path): """Leaving the screen while the pipeline is still running still cleans up. @@ -451,7 +414,6 @@ def slow_slice(ns=None, **kwargs): assert not os.path.exists(seen["workdir"]), f"leaked temp workdir {seen['workdir']}" - async def test_manual_material_choice_survives_late_ams_detection(tmp_path): """A slow AMS read must never overwrite a choice the user already made. @@ -489,7 +451,6 @@ def slow_detector(args, on_active_slot=None): finally: gate.set() - def test_message_less_prepare_failure_is_not_diagnosed_as_unreachable(tmp_path): """A blank OSError from the slicer is a prepare failure, not a dead printer.""" from bambu_cli.tui.services import PipelineService @@ -504,7 +465,6 @@ def test_message_less_prepare_failure_is_not_diagnosed_as_unreachable(tmp_path): assert result.error == "Preparing the model failed (OSError)." assert not os.path.exists(result.state.workdir) - # --------------------------------------------------------------------------- # Layout: the form on the left, what the run produced on the right # @@ -517,7 +477,6 @@ def test_message_less_prepare_failure_is_not_diagnosed_as_unreachable(tmp_path): _WIDE = (100, 30) _NARROW = (80, 24) - def _column_of(screen, selector): """Which prepare column owns a widget ('prepare-inputs'/'prepare-output').""" for ancestor in screen.query_one(selector).ancestors: @@ -525,7 +484,6 @@ def _column_of(screen, selector): return ancestor.id return None - async def test_wide_terminal_puts_the_form_beside_the_results(tmp_path): _install_ready_settings(tmp_path) app = PlateApp(_args(), _deps()) @@ -557,7 +515,6 @@ async def test_wide_terminal_puts_the_form_beside_the_results(tmp_path): assert output.region.y == inputs.region.y assert not screen.query_one("#prepare-columns").has_class("narrow") - async def test_wide_terminal_shows_the_estimate_without_scrolling(tmp_path): """The point of the restructure: preview + Start print visible with the form.""" _install_ready_settings(tmp_path) @@ -578,7 +535,6 @@ async def test_wide_terminal_shows_the_estimate_without_scrolling(tmp_path): # …and nothing had to scroll to get there. assert screen.query_one("#prepare-body").scroll_offset.y == 0 - async def test_narrow_terminal_stacks_the_columns(tmp_path): """Two 40-column halves cannot hold a material label, so 80x24 stacks.""" _install_ready_settings(tmp_path) @@ -599,7 +555,6 @@ async def test_narrow_terminal_stacks_the_columns(tmp_path): assert material.outer_size.width <= 80 assert material.outer_size.height == len(("PLA", "PETG", "ABS", "TPU")) + 2 # + border - async def test_detected_material_label_survives_the_form_scrollbar(tmp_path): """A short terminal must not truncate WHICH material was detected. @@ -625,7 +580,6 @@ async def test_detected_material_label_survives_the_form_scrollbar(tmp_path): needed = Text.from_markup(str(button.label)).cell_len + 4 assert screen.query_one("#material-set").content_size.width >= needed - async def test_narrow_terminal_scrolls_the_finished_run_into_view(tmp_path): """Stacked, the results start below the fold; the finished run must come up.""" _install_ready_settings(tmp_path) @@ -648,13 +602,11 @@ async def test_narrow_terminal_scrolls_the_finished_run_into_view(tmp_path): assert app.screen.region.contains_region(button.region) assert preview.outer_size.width <= 80 - # --------------------------------------------------------------------------- # The results column: a titled box that says what will land in it, and a # label/value grid whose wrapped values stay out of the label column. # --------------------------------------------------------------------------- - def _render_at(renderable, width): """Plain text of a Rich renderable at an exact console width. @@ -669,7 +621,6 @@ def _render_at(renderable, width): console.print(renderable) return capture.get() - def test_summary_grid_wraps_a_value_under_the_value_column(): """The defect: f"{label:<11}{value}" continued a wrap in the label column. @@ -690,7 +641,6 @@ def test_summary_grid_wraps_a_value_under_the_value_column(): assert line.index("nozzle") == value_column, line assert line[:value_column].strip() == "", line - def test_summary_grid_renders_a_bracketed_filename_verbatim(): """A "[" in a filename is not Rich markup: str cells would eat it.""" from bambu_cli.tui.widgets.summary import summary_grid @@ -702,14 +652,12 @@ def test_summary_grid_renders_a_bracketed_filename_verbatim(): closing = _render_at(summary_grid([("Model", "a[/b]c.gcode")]), 60) assert "a[/b]c.gcode" in closing - def test_summary_grid_tolerates_no_rows(): from bambu_cli.tui.widgets.summary import summary_grid assert _render_at(summary_grid(None), 40).strip() == "" assert _render_at(summary_grid([]), 40).strip() == "" - async def test_results_column_is_a_titled_box_with_a_placeholder(tmp_path): """Before any run the column must not read as a half-rendered widget.""" _install_ready_settings(tmp_path) @@ -730,7 +678,6 @@ async def test_results_column_is_a_titled_box_with_a_placeholder(tmp_path): assert "Estimate" not in placeholder assert _text(screen.query_one("#preview", Static)) == "" - async def test_placeholder_is_replaced_by_the_real_status(tmp_path): _install_ready_settings(tmp_path) stl = _make_stl(tmp_path) @@ -746,7 +693,6 @@ async def test_placeholder_is_replaced_by_the_real_status(tmp_path): assert "Nothing prepared yet" not in status assert "Start print" in status - async def test_preview_never_starts_a_line_in_the_label_column(tmp_path): """The screen, not just the helper: a wrap must not invent a field name.""" _install_ready_settings(tmp_path) @@ -769,7 +715,6 @@ async def test_preview_never_starts_a_line_in_the_label_column(tmp_path): if line and not line.startswith(" "): assert line.split()[0] in labels, line - async def test_preview_shows_a_bracketed_filename_verbatim(tmp_path): """End to end: the preview Static must not markup-parse a filename.""" _install_ready_settings(tmp_path) @@ -785,7 +730,6 @@ async def test_preview_shows_a_bracketed_filename_verbatim(tmp_path): assert "benchy [remix] v2.stl" in _text(screen.query_one("#preview", Static)) - async def test_form_groups_are_one_width_that_fills_the_column(tmp_path): """One form, not four boxes of unrelated size with a ragged right edge.""" _install_ready_settings(tmp_path) @@ -801,7 +745,6 @@ async def test_form_groups_are_one_width_that_fills_the_column(tmp_path): } assert set(widths.values()) == {column}, widths - async def test_narrow_terminal_scrolls_a_failure_into_view(tmp_path): """Stacked, a failure lands below the fold too — and looks like nothing ran.""" from bambu_cli.errors import BambuError diff --git a/tests/test_tui_settings.py b/tests/test_tui_settings.py index 89fbc91..f791e0e 100644 --- a/tests/test_tui_settings.py +++ b/tests/test_tui_settings.py @@ -16,18 +16,11 @@ import argparse import json import os -import sys import zipfile from pathlib import Path -from unittest.mock import MagicMock import pytest -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - pytest.importorskip("textual") from textual.widgets import Button, Input, OptionList, Select, Static # noqa: E402 @@ -44,7 +37,6 @@ _IDLE = StatusSnapshot(ok=True, raw={"gcode_state": "IDLE", "mc_percent": 0}, ams={"units": []}) - class ScriptedStatus: def __init__(self): self.calls = 0 @@ -53,7 +45,6 @@ def fetch(self, args): self.calls += 1 return _IDLE - class Recorder: def __init__(self, return_value=None, raises=None): self.calls = [] @@ -66,19 +57,16 @@ def __call__(self, ns=None, **kwargs): raise self.raises return self.return_value - @pytest.fixture(autouse=True) def _isolated_cwd(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - @pytest.fixture(autouse=True) def _reset_context(): saved = _context.get_current() yield _context.set_current(saved) - def _install_ready_settings(tmp_path, profiles=None): from bambu_cli.context import RuntimeContext, Settings @@ -98,13 +86,11 @@ def _install_ready_settings(tmp_path, profiles=None): _context.set_current(RuntimeContext(settings=settings)) return settings - def _args(**kwargs): base = {"cmd": "tui", "sim": False, "json": False, "verbose": False} base.update(kwargs) return argparse.Namespace(**base) - def _sliced_3mf(path, name="cube.gcode.3mf"): p = Path(path) / name with zipfile.ZipFile(p, "w") as zf: @@ -115,26 +101,21 @@ def _sliced_3mf(path, name="cube.gcode.3mf"): ) return str(p) - def _slicer_into_workdir(ns=None, **kwargs): return _sliced_3mf(ns.output) - async def _settle(pilot): await pilot.pause() await pilot.app.workers.wait_for_complete() await pilot.pause() - def _text(widget) -> str: return widget_text(widget) - # --------------------------------------------------------------------------- # Pure: SliceOverrides / apply_overrides # --------------------------------------------------------------------------- - def _slice_ns(): from bambu_cli.interactive.presets import preset_to_job_args from bambu_cli.job.predict import _slice_args_for_job @@ -142,7 +123,6 @@ def _slice_ns(): preset = preset_to_job_args("PLA", "standard", False, "cube.stl") return _slice_args_for_job("cube.stl", preset, "/tmp/out") - def test_empty_overrides_leave_the_namespace_byte_identical(): """The wizard guarantee: no overrides ⇒ nothing about the slice changes.""" ns = _slice_ns() @@ -154,7 +134,6 @@ def test_empty_overrides_leave_the_namespace_byte_identical(): assert apply_overrides(ns, None) is ns assert vars(ns) == before - def test_apply_overrides_sets_the_same_dests_the_cli_parser_would(): from bambu_cli.cli import build_parser @@ -187,7 +166,6 @@ def test_apply_overrides_sets_the_same_dests_the_cli_parser_would(): for dest in ("layer_height", "walls", "seam_position", "set_process", "set_filament"): assert getattr(decorated, dest) == getattr(cli, dest), dest - def test_apply_overrides_appends_to_existing_generic_overrides(): ns = _slice_ns() ns.set_process = ["already=1"] @@ -195,7 +173,6 @@ def test_apply_overrides_appends_to_existing_generic_overrides(): assert decorated.set_process == ["already=1", "top_shell_layers=5"] assert ns.set_process == ["already=1"] # source list not mutated - def test_overrides_summary_and_counts(): empty = SliceOverrides() assert empty.is_empty() and empty.count() == 0 and empty.summary() == "" @@ -205,7 +182,6 @@ def test_overrides_summary_and_counts(): assert summary.startswith("4 set (") assert "+1" in summary - def test_overrides_problem_uses_the_slice_safety_bounds(): assert overrides_problem(SliceOverrides()) is None assert overrides_problem(SliceOverrides(fields={"nozzle_temp": 220})) is None @@ -215,12 +191,10 @@ def test_overrides_problem_uses_the_slice_safety_bounds(): assert overrides_problem(SliceOverrides(filament={"nozzle_temperature": "999"})) is not None assert "empty setting name" in (overrides_problem(SliceOverrides(process={"": "x"})) or "") - # --------------------------------------------------------------------------- # Pure: settings model # --------------------------------------------------------------------------- - def test_every_field_maps_onto_a_real_slice_parser_dest(): """No invented vocabulary: each field is a dest the CLI slice parser has.""" from bambu_cli.cli import build_parser @@ -229,7 +203,6 @@ def test_every_field_maps_onto_a_real_slice_parser_dest(): for field in sm.SETTING_FIELDS: assert hasattr(cli, field.dest), f"{field.dest} is not a slice parser dest" - def _slice_parser_actions(): """The real ``slice`` subparser actions, keyed by dest (never hand-copied).""" from bambu_cli.cli import build_parser @@ -240,7 +213,6 @@ def _slice_parser_actions(): return {a.dest: a for a in choices["slice"]._actions} # noqa: SLF001 raise AssertionError("slice subparser not found") - def test_choice_fields_never_offer_what_the_cli_would_reject(): """Every choice the form offers must be accepted by the slice parser.""" actions = _slice_parser_actions() @@ -263,7 +235,6 @@ def test_choice_fields_never_offer_what_the_cli_would_reject(): assert set(sm.field_for("ironing").choices) == set(actions["ironing"].choices) assert "archaic" not in sm.field_for("wall_type").choices - def test_seam_and_ironing_reject_values_the_cli_would_refuse(): seam = sm.field_for("seam_position") value, error = sm.parse_field_value(seam, "rear") # a plausible-but-invalid guess @@ -274,13 +245,11 @@ def test_seam_and_ironing_reject_values_the_cli_would_refuse(): assert value is None and "expected one of" in error assert sm.parse_field_value(ironing, "topmost") == ("topmost", None) - def test_field_groups_cover_every_field_once(): grouped = [f.dest for _group, fields in sm.fields_by_group() for f in fields] assert sorted(grouped) == sorted(f.dest for f in sm.SETTING_FIELDS) assert len(grouped) == len(set(grouped)) - def test_parse_field_value_types_and_blanks(): layer = sm.field_for("layer_height") walls = sm.field_for("walls") @@ -299,18 +268,15 @@ def test_parse_field_value_types_and_blanks(): # Range is NOT the model's business — safety bounds live in one place. assert sm.parse_field_value(sm.field_for("nozzle_temp"), "999") == (999, None) - def test_collect_field_overrides_reports_every_bad_field(): parsed, errors = sm.collect_field_overrides({"walls": "4", "infill": "abc", "layer_height": "x"}) assert parsed == {"walls": 4} assert len(errors) == 2 - # --------------------------------------------------------------------------- # Pilot: the screen and the plumbing # --------------------------------------------------------------------------- - def _profiles_with_keys(tmp_path): profiles = tmp_path / "profiles" (profiles / "process").mkdir(parents=True) @@ -324,7 +290,6 @@ def _profiles_with_keys(tmp_path): ) return profiles - async def _add_override(pilot, settings, key, value, bucket=None): """Drive the real add path: name the key, pick a bucket, set the value.""" settings.query_one("#override-key", Input).value = key @@ -335,18 +300,15 @@ async def _add_override(pilot, settings, key, value, bucket=None): settings.query_one("#override-add", Button).press() await pilot.pause() - def _pending(settings): option_list = settings.query_one("#override-current", OptionList) return [str(option_list.get_option_at_index(i).prompt) for i in range(option_list.option_count)] - def _deps(steps, **kwargs): kwargs.setdefault("status_provider", ScriptedStatus()) kwargs.setdefault("ams_detector", lambda args: None) return TuiDeps(steps=steps, **kwargs) - async def _open_settings(pilot, app, stl=None): await pilot.press("n") await _settle(pilot) @@ -358,14 +320,12 @@ async def _open_settings(pilot, app, stl=None): assert isinstance(settings, SettingsScreen) return prepare, settings - async def _prepare_with(pilot, app, prepare, source): prepare.query_one("#source-input", Input).value = str(source) prepare.query_one("#source-input", Input).focus() await pilot.press("enter") await _settle(pilot) - async def test_form_values_land_on_the_slice_namespace(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -401,7 +361,6 @@ def capture(ns=None, **kwargs): assert ns.infill == 15 assert "Overrides" in preview and "3 set" in preview - async def test_non_numeric_field_is_refused_inline(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -417,7 +376,6 @@ async def test_non_numeric_field_is_refused_inline(tmp_path): assert "whole number" in _text(settings.query_one("#settings-error", Static)) assert prepare.overrides.is_empty() - async def test_unsafe_temperature_is_refused_inline(tmp_path): """nozzle 999 °C: the slice safety bounds refuse it before anything runs.""" from bambu_cli.interactive.core import GoSteps @@ -443,7 +401,6 @@ async def test_unsafe_temperature_is_refused_inline(tmp_path): assert prepare.overrides.fields == {"nozzle_temp": 215} assert slicer.calls == [] - async def test_cancel_keeps_previous_overrides(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -466,7 +423,6 @@ async def test_cancel_keeps_previous_overrides(tmp_path): await _settle(pilot) assert prepare.overrides.fields == {"walls": 5} # cancel changed nothing - async def test_changing_settings_after_a_preview_forces_a_re_prepare(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -494,7 +450,6 @@ async def test_changing_settings_after_a_preview_forces_a_re_prepare(tmp_path): assert "prepare again" in _text(prepare.query_one("#prepare-status", Static)) assert not os.path.exists(workdir) - async def test_presliced_source_disables_the_settings_button(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -515,7 +470,6 @@ async def test_presliced_source_disables_the_settings_button(tmp_path): assert "material settings not applied" in preview assert "Overrides" not in preview - async def test_settings_screen_at_80x24(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -534,7 +488,6 @@ async def test_settings_screen_at_80x24(tmp_path): await _settle(pilot) assert isinstance(app.screen, PrepareScreen) - async def test_s_key_opens_settings_from_the_prepare_screen(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -551,12 +504,10 @@ async def test_s_key_opens_settings_from_the_prepare_screen(tmp_path): await _settle(pilot) assert isinstance(app.screen, SettingsScreen) - # --------------------------------------------------------------------------- # Read-back: overrides in the temp profiles OrcaSlicer was handed # --------------------------------------------------------------------------- - def test_overrides_reach_the_temp_profiles_the_slicer_receives(tmp_path, monkeypatch): """End-to-end against the fake slicer: read the values back out of the files. @@ -623,7 +574,6 @@ def test_overrides_reach_the_temp_profiles_the_slicer_receives(tmp_path, monkeyp assert "filament_flow_ratio" not in process_profile assert "sparse_infill_pattern" not in filament_profile - async def test_override_buttons_and_enter_key(tmp_path): """The buttons and the Enter key drive the same paths the actions do.""" from bambu_cli.interactive.core import GoSteps @@ -667,7 +617,6 @@ async def test_override_buttons_and_enter_key(tmp_path): assert prepare.overrides.fields == {"walls": 4} assert prepare.overrides.process == {} - async def test_pending_override_can_be_reloaded_and_removed(tmp_path): """The pending list is editable: click to load it back, remove one at a time.""" from bambu_cli.interactive.core import GoSteps @@ -708,7 +657,6 @@ async def test_pending_override_can_be_reloaded_and_removed(tmp_path): assert prepare.overrides.process == {} assert prepare.overrides.filament == {"filament_flow_ratio": "0.95"} - async def test_cancel_button_discards(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -723,7 +671,6 @@ async def test_cancel_button_discards(tmp_path): assert isinstance(app.screen, PrepareScreen) assert prepare.overrides.is_empty() - async def test_settings_is_refused_while_a_prepare_is_running(tmp_path): import threading @@ -763,11 +710,9 @@ def slow_slice(ns=None, **kwargs): finally: gate.set() - def test_field_for_unknown_dest_is_none(): assert sm.field_for("not_a_real_dest") is None - async def test_s_key_cannot_bypass_the_pre_sliced_settings_gate(tmp_path): """The key path is gated exactly like the button, not just the button. @@ -803,7 +748,6 @@ async def test_s_key_cannot_bypass_the_pre_sliced_settings_gate(tmp_path): assert "pre-sliced" in _text(prepare.query_one("#settings-summary", Static)) assert prepare.settings_lock_reason() is not None - async def test_settings_lock_reason_is_clear_once_a_sliced_result_exists(tmp_path): """A normally sliced model keeps the settings screen reachable by key.""" from bambu_cli.interactive.core import GoSteps @@ -825,7 +769,6 @@ async def test_settings_lock_reason_is_clear_once_a_sliced_result_exists(tmp_pat await _settle(pilot) assert isinstance(app.screen, SettingsScreen) - async def test_bucket_picker_routes_a_filament_key(tmp_path): """THE gotcha, end to end: the bucket dropdown is what routes the override. @@ -863,7 +806,6 @@ def capture(ns=None, **kwargs): assert ns.set_filament == ["filament_flow_ratio=0.9"] assert ns.set_process == ["top_shell_layers=5"] - async def test_named_choice_fields_are_dropdowns(tmp_path): """The closed-option flags are picked, not typed — nothing to mistype.""" from bambu_cli.interactive.core import GoSteps @@ -887,7 +829,6 @@ async def test_named_choice_fields_are_dropdowns(tmp_path): # Only the field that was picked is set; the other three stay absent. assert prepare.overrides.fields == {"seam_position": "aligned"} - async def test_remove_with_nothing_selected_says_so(tmp_path): from bambu_cli.interactive.core import GoSteps @@ -901,7 +842,6 @@ async def test_remove_with_nothing_selected_says_so(tmp_path): await pilot.pause() assert "Select a pending override" in _text(settings.query_one("#settings-error", Static)) - async def test_pending_values_round_trip_back_into_the_editor(tmp_path): """Reloading a pending override restores its key, bucket and value verbatim. @@ -949,7 +889,6 @@ def reload(key): settings._load_pending("") await pilot.pause() - async def test_an_empty_value_is_a_real_override(tmp_path): """Clearing a setting is legitimate — ``--set key=`` does exactly this.""" from bambu_cli.interactive.core import GoSteps @@ -965,7 +904,6 @@ async def test_an_empty_value_is_a_real_override(tmp_path): await _settle(pilot) assert prepare.overrides.process == {"machine_start_gcode": ""} - async def test_settings_screen_fits_80x24(tmp_path): """The screen gained controls; it still has to work on the smallest terminal. @@ -997,7 +935,6 @@ async def test_settings_screen_fits_80x24(tmp_path): await _settle(pilot) assert prepare.overrides.process == {"spiral_mode": "1"} - async def test_option_prompts_bypass_rich_markup(tmp_path): """Bucket tags must survive rendering, not just exist in the string. diff --git a/tests/test_wizard_guided.py b/tests/test_wizard_guided.py index 26b2f10..ffc357e 100644 --- a/tests/test_wizard_guided.py +++ b/tests/test_wizard_guided.py @@ -7,16 +7,10 @@ from argparse import Namespace from unittest.mock import MagicMock, patch -_mock_mqtt = MagicMock() -sys.modules.setdefault("paho", _mock_mqtt) -sys.modules.setdefault("paho.mqtt", _mock_mqtt) -sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) - from bambu_cli.errors import BambuError # noqa: E402 from bambu_cli.setup_cmd import common as common_mod # noqa: E402 from bambu_cli.setup_cmd import wizard as wizard_mod # noqa: E402 - def test_cmd_setup_routes_noninteractive(tmp_path): cfg = tmp_path / "config.json" code = tmp_path / "ac" @@ -49,7 +43,6 @@ def test_cmd_setup_routes_noninteractive(tmp_path): assert data["printer_ip"] == "10.0.0.8" assert data["serial"] == "SN1234567890ABC" - def test_build_setup_config_helper(): cfg = common_mod._build_setup_config( ip="10.0.0.1", @@ -66,12 +59,10 @@ def test_build_setup_config_helper(): assert cfg["printer_ip"] == "10.0.0.1" assert cfg["serial"] == "SN1" - def test_normalize_model_nozzle(): assert common_mod._normalize_model("p1s", "P1P") == "P1S" assert common_mod._normalize_nozzle("0.4") == "0.4" - def test_write_setup_config(tmp_path): cfg_path = tmp_path / "config.json" code_path = tmp_path / "code" @@ -88,7 +79,6 @@ def test_write_setup_config(tmp_path): assert code_path.is_file() assert code_path.read_text(encoding="utf-8").strip() == "SECRET" - def test_guided_setup_manual_path(tmp_path, monkeypatch): """When zeroconf is unavailable, guided setup falls back to manual prompts.""" cfg = tmp_path / "config.json" @@ -158,7 +148,6 @@ def guarded(name, *a, **k): else: assert raised is not None, "guided setup neither wrote config nor raised" - class MockServiceInfo: def __init__(self, ip): self.ip = ip @@ -166,7 +155,6 @@ def __init__(self, ip): def parsed_addresses(self): return [self.ip] - class MockZeroconf: def __init__(self, services): self.services = services @@ -181,11 +169,9 @@ def get_service_info(self, type_, name): def close(self): self.closed = True - def create_mock_zeroconf(services): return lambda: MockZeroconf(services) - def mock_service_browser(services): def init(zc, type_, listener): for name, _ip in services: @@ -194,7 +180,6 @@ def init(zc, type_, listener): return init - def test_guided_setup_mdns_one_printer(tmp_path, monkeypatch): cfg = tmp_path / "config.json" answers = iter( @@ -263,7 +248,6 @@ def guarded(name, *a, **k): assert data["serial"] == "01P00A123" assert data["model"] == "P1P" - def test_guided_setup_mdns_multiple_printers(tmp_path, monkeypatch): cfg = tmp_path / "config.json" answers = iter( @@ -336,7 +320,6 @@ def guarded(name, *a, **k): assert data["serial"] == "03000A111" assert data["model"] == "A1" - def test_guided_setup_mdns_no_printers(tmp_path, monkeypatch): cfg = tmp_path / "config.json" @@ -388,7 +371,6 @@ def guarded(name, *a, **k): assert raised is not None assert raised.exit_code == 2 # EXIT_NETWORK_ERROR - def test_guided_setup_mdns_discovery_error(tmp_path, monkeypatch): cfg = tmp_path / "config.json" answers = iter(