diff --git a/docs/mutation-baseline.md b/docs/mutation-baseline.md index 5e2eaaf..be961ad 100644 --- a/docs/mutation-baseline.md +++ b/docs/mutation-baseline.md @@ -73,7 +73,7 @@ Per-module (approx. killed / accounted on a clean run): | `download/naming.py` | ~55–56% | Injection + sanitize properties; CD/header edges survive | | `job/predict.py` | ~33% | Dry-run heuristics under-specified; many equivalent branches | | `download/validation.py` | ~30–31% | Message/emit paths + normalize edge strings | -| `slicer/output.py` | ~21% | **Low:** `_finalize_slice` I/O/logging mutates with little unit signal; `_is_valid_sliced_3mf` itself is much better covered | +| `slicer/output.py` | ~21% baseline; **not yet re-measured** since the C.4 hermetic Orca stub landed | **Was low:** `_finalize_slice` I/O/logging mutated with little unit signal. `tests/test_slice_stub_integration.py` (roadmap C.4) now drives its benign-GL / empty / corrupt / missing-output / real-error branches through the real slicer subprocess (line coverage 79.8%→~93%), so a re-run of `mutmut` on this module should score materially higher — update this row after the next mutation run. | **Honest reading:** the overall score **dropped vs Phase 1** because scope **widened** into harder modules (especially `output._finalize_slice` and `predict`/`validation` emit paths). That is intentional. Do **not** restore a high score by shrinking back to only well-covered files. @@ -92,7 +92,7 @@ Enforced by `./scripts/run_mutation_baseline.sh` after `mutmut export-cicd-stats Categories (not an exhaustive dump of 861 IDs): 1. **Equivalent / cosmetic** — error-message string literals, log format, `getattr` default when tests always set the attribute, `ZipFile(..., "r")` vs default mode. -2. **`_finalize_slice` (output.py)** — subprocess exit interpretation, JSON emit, path display. Deferred: needs hermetic fake-Orca fixtures; not pure safety. Dominates the low output.py score. +2. **`_finalize_slice` (output.py)** — subprocess exit interpretation, JSON emit, path display. **Addressed (C.4):** `tests/fakes/orca_stub` + `tests/test_slice_stub_integration.py` now run these branches against a real fake-slicer subprocess. Residual survivors here should be cosmetic (log strings / path display); re-measure before treating any as "accepted". 3. **DNS cache / hop bookkeeping (netsafety)** — TTL, cache size clear, attribute names on redirect requests. Core `is_global` refuse path is well killed. 4. **URL normalize / Content-Disposition edges (validation/naming)** — ambiguous scheme-less inputs and RFC2231 header tuples; behavior partially covered; full combinatorial matrix deferred. 5. **Dry-run prediction (predict.py)** — Printables/archive/extension branches that return `None` early; many mutants are observationally equivalent under the focused suite. @@ -121,4 +121,4 @@ Artifacts (`mutants/`, `.mutmut-cache`, `.hypothesis/`) are gitignored. - mutmut 3.x needs Python ≥ 3.10 (CI mutation job uses 3.12). - Hypothesis property tests live in `tests/test_properties_safety.py` and are part of the focused mutmut suite. -- Raising the score further: add hermetic tests for `_finalize_slice` decision branches, or move pure 3mf validation to a tiny module so mutmut does not spend budget on I/O. +- Raising the score further: the hermetic `_finalize_slice` tests now exist (C.4, `tests/test_slice_stub_integration.py`); re-run `mutmut` on `slicer/output.py` and update the per-module row. Optionally still move pure 3mf validation to a tiny module so mutmut does not spend budget on I/O. diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md index a5b7fb6..2e0dccb 100644 --- a/docs/quality-roadmap.md +++ b/docs/quality-roadmap.md @@ -56,7 +56,7 @@ security is not yet **A+**. | Correctness / bugs | **A** | dead flags fixed (global `--json` before subcommand); structured errors; purity greps; version single-sourced | | Typing | **A** | `uvx mypy -p bambu_cli` full package with `check_untyped_defs = true`; no residual excludes | | Error model | **A** | `sys.exit` only in `cli.py` (errors.py hits are docstrings); domain uses `abort` / `BambuError` | -| Tests | **A−** | **1041** non-live tests collected / **1041** passing (2026-07-29; the latest additions cover the shared `verify_cert_fingerprint` TLS pin checker, incl. malformed/non-ASCII pin fail-closed cases); **84.6%** coverage measured 2026-07-29 on Linux; CI floor **83**; per-module floors not enforced | +| Tests | **A−** | **1065** non-live tests collected / **1064** passing (2026-07-29; latest additions are the C.4 hermetic fake-OrcaSlicer slice tests exercising the real slicer subprocess, and the shared `verify_cert_fingerprint` TLS pin checker incl. malformed/non-ASCII fail-closed cases); **84.9%** coverage measured 2026-07-29 on Linux; CI floor **83**; per-module floors not enforced | | CI / release | **A−** | single pytest path; purity greps; bandit/audit/mypy blocking; **`--cov-fail-under=83`** (A+ target remains 92) | | Docs / governance | **A−** | roadmap + backlog + SECURITY + AGENTS aligned (2026-07-24); prior AGENTS mypy-blocklist / backlog ≥98% claims corrected | | Product polish | **B+** | quality gates in place; still pre-1.0 Beta (version is single-sourced from `pyproject.toml`); coverage ratchet + camera defaults remain for 1.0 A+ | @@ -428,7 +428,7 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85 | C.1 | **T2** remaining wizard/mDNS cases | | C.2 | **T3** full download/extract matrix | | C.3 | **T4** slicer + doctor + print safety | -| C.4 | Hermetic **fake OrcaSlicer** script in `tests/fakes/orca_stub` (exit codes, stdout, profile paths) | +| C.4 | **Done.** Hermetic **fake OrcaSlicer** script in `tests/fakes/orca_stub` (exit codes, stdout, profile paths); `tests/test_slice_stub_integration.py` runs `cmd_slice` end-to-end through the real `_run_orcaslicer`/`_finalize_slice` instead of mocking `subprocess.Popen` | | C.5 | Coverage total ≥**92%**; module floors per scorecard A+ column for transport/setup/download | #### Typing @@ -459,7 +459,7 @@ bandit + pip-audit blocking #### DoD - [ ] Scorecard **A** column green for Tests and Typing (Typing A− OK if strict not yet full-package). -- [ ] Fake Orca used by default in slice unit tests. +- [x] Fake Orca available (`tests/fakes/orca_stub`) and used by the hermetic slice suite (`tests/test_slice_stub_integration.py`); remaining `test_slice_cmd.py` unit tests keep their mocks where they assert argv assembly / profile-resolution branches. - [ ] `docs/test-backlog.md` reduced to “nice-to-have” only (or empty P1–P5). #### Score impact diff --git a/docs/test-backlog.md b/docs/test-backlog.md index 1a88c8c..389ac73 100644 --- a/docs/test-backlog.md +++ b/docs/test-backlog.md @@ -10,13 +10,13 @@ Do not treat historical “≥98% coverage” claims as current — see the snap | Metric | Current (honest) | A+ / 1.0 target | |--------|------------------|-----------------| -| Non-live tests collected | **1041** collected / **1041** passing (measured 2026-07-29) | ≥550 with zero known flakes ✅ size | -| Line/branch coverage (CI) | **84.32%** Linux / ~**83.9%** Windows measured 2026-07-26; **floor 83** (Windows is the binding leg) | **≥92%** total; optional module floors | +| Non-live tests collected | **1065** collected / **1064** passing (measured 2026-07-29; incl. the C.4 hermetic Orca slice tests and the shared TLS-pin checker tests) | ≥550 with zero known flakes ✅ size | +| Line/branch coverage (CI) | **84.9%** Linux measured 2026-07-29 (C.4 hermetic Orca tests lift `slicer/output.py` 79.8%→92.7%); ~**83.9%** Windows measured 2026-07-26; **floor 83** (Windows is the binding leg) | **≥92%** total; optional module floors | | Typing | Full package mypy + `check_untyped_defs` | keep; optional full `strict` later | | Error model | `sys.exit` only in `cli.py` | keep | | `@mockable` / test-awareness | **0** (CI greps) | keep | | JSON schemas | **19** files under `docs/schemas/` | every `--json` command + monitor goldens | -| Mutation baseline | Pure safety modules; floor **40%** | optional raise after hermetic Orca stub | +| Mutation baseline | Pure safety modules; floor **40%** | hermetic Orca stub landed (C.4); re-run `mutmut` on `slicer/output.py` to raise its row | | Live printer | Documented opt-in harness | manual pre-release (optional scheduled lab) | | Product version | pre-1.0 Beta (single-sourced from `pyproject.toml`) | **v1.0.0** when roadmap §5 is complete | @@ -50,7 +50,7 @@ Tracked in [SECURITY.md](../SECURITY.md) known limitations: |-----|-------| | Raise CI floor 83 → 85 → 88 → **92** | Residual: mqtt/ftps pin paths, pool recovery, wizard TTY, Orca process | | Per-module floors (optional) | mqtt / ftps / netsafety / download / camera | -| Hermetic fake Orca binary | Raises mutation kill rate on `slicer/output._finalize_slice`; slice unit tests less mock-heavy | +| ~~Hermetic fake Orca binary~~ | **Done (C.4).** `tests/fakes/orca_stub` + `tests/test_slice_stub_integration.py` run `cmd_slice` end-to-end through the real slicer subprocess (`_run_orcaslicer`/`_finalize_slice`); `slicer/output.py` line coverage 79.8%→~93%. Mutation re-run on that module still pending. | ### P2 — Contracts & agent surface diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 0000000..94386e8 --- /dev/null +++ b/tests/fakes/__init__.py @@ -0,0 +1 @@ +"""Hermetic test fakes (fake OrcaSlicer, etc.) — see roadmap A.3 / C.4.""" diff --git a/tests/fakes/orca_stub/__init__.py b/tests/fakes/orca_stub/__init__.py new file mode 100644 index 0000000..454cc12 --- /dev/null +++ b/tests/fakes/orca_stub/__init__.py @@ -0,0 +1,133 @@ +"""Hermetic fake OrcaSlicer harness (roadmap C.4). + +Provides: + +* ``orca_stub.py`` — a standalone fake-slicer script (invoked as the binary). +* ``make_orca_launcher`` — write a cross-platform launcher for that script so + ``settings.orca_slicer`` can point at a single directly-executable path that + ``subprocess.Popen`` can run on Linux, macOS, and Windows (no shebang/exec-bit + reliance on Windows). +* ``build_profiles_dir`` — materialise a real OrcaSlicer-style ``profiles_dir`` + (machine/process/filament JSONs) under a tmp dir so ``cmd_slice`` finds real + files instead of mocking ``os.path.exists``/``os.listdir``. +* ``write_stl`` — write a tiny real ASCII STL model input. +""" + +from __future__ import annotations + +import json +import os +import stat +import sys + +STUB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "orca_stub.py") + + +def make_orca_launcher(dest_dir: str, name: str = "orca-slicer") -> str: + """Write a launcher for ``orca_stub.py`` and return its path. + + The launcher is what ``settings.orca_slicer`` points at. ``cmd_slice`` runs + it via ``subprocess.Popen([launcher, ...args])`` and + ``_slicer_executable_problem`` checks it exists + is X_OK on POSIX, so the + launcher must be a single directly-executable file on every OS: + + * POSIX: a ``sh`` wrapper that ``exec``s `` orca_stub.py "$@"``, + chmod +x. (The X_OK check itself requires this on POSIX.) + * Windows: a ``.cmd`` batch file ``@ orca_stub.py %*`` — Popen can + launch a ``.cmd`` directly; there is no exec bit to set. + + Both embed the current interpreter (``sys.executable``) so no shebang or + ``PATH`` python lookup is needed. + """ + python = sys.executable + if os.name == "nt": + launcher = os.path.join(dest_dir, name + ".cmd") + # %* forwards all args; quote python + script for spaced paths. + content = f'@"{python}" "{STUB_PATH}" %*\r\n' + with open(launcher, "w", encoding="utf-8", newline="") as fh: + fh.write(content) + return launcher + + launcher = os.path.join(dest_dir, name) + content = f'#!/bin/sh\nexec "{python}" "{STUB_PATH}" "$@"\n' + with open(launcher, "w", encoding="utf-8") as fh: + fh.write(content) + mode = os.stat(launcher).st_mode + os.chmod(launcher, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return launcher + + +# ---- Minimal but realistic profile JSONs ------------------------------------ +# cmd_slice looks for, in ``profiles_dir``: +# machine/ nozzle.json (default P1P 0.4) +# process/ Standard @BBL P1P.json (quality-derived name) +# filament/**@base*.json (fuzzy match, default PLA Basic) +# The names below match the default P1P / 0.4 / standard / PLA Basic path. + +_MACHINE_JSON = { + "type": "machine", + "name": "Bambu Lab P1P 0.4 nozzle", + "nozzle_diameter": ["0.4"], + "printer_model": "Bambu Lab P1P", +} + +_PROCESS_JSON = { + "type": "process", + "name": "0.20mm Standard @BBL P1P", + "layer_height": "0.2", + "compatible_printers": ["Bambu Lab P1P 0.4 nozzle"], +} + +_FILAMENT_JSON = { + "type": "filament", + "name": "Bambu PLA Basic @base", + "filament_type": ["PLA"], +} + + +def build_profiles_dir(root: str) -> str: + """Create a real profiles_dir tree under *root* and return its path. + + Matches the default slice path (P1P, 0.4 nozzle, standard quality, PLA + Basic) so ``cmd_slice`` resolves every profile from real files on disk. + """ + profiles_dir = os.path.join(root, "profiles") + for sub, name, payload in ( + ("machine", "Bambu Lab P1P 0.4 nozzle.json", _MACHINE_JSON), + ("process", "0.20mm Standard @BBL P1P.json", _PROCESS_JSON), + ("filament", "Bambu PLA Basic @base.json", _FILAMENT_JSON), + ): + d = os.path.join(profiles_dir, sub) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, name), "w", encoding="utf-8") as fh: + json.dump(payload, fh) + return profiles_dir + + +# A minimal valid ASCII STL (single degenerate triangle) — enough for the stub +# to receive a real model input path; the stub never actually parses it. +_STL = """solid cube +facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop +endfacet +endsolid cube +""" + + +def write_stl(path: str) -> str: + """Write a tiny real ASCII STL to *path* and return it.""" + with open(path, "w", encoding="utf-8") as fh: + fh.write(_STL) + return path + + +__all__ = [ + "STUB_PATH", + "make_orca_launcher", + "build_profiles_dir", + "write_stl", +] diff --git a/tests/fakes/orca_stub/orca_stub.py b/tests/fakes/orca_stub/orca_stub.py new file mode 100644 index 0000000..6f4d6a3 --- /dev/null +++ b/tests/fakes/orca_stub/orca_stub.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Hermetic fake OrcaSlicer CLI (roadmap C.4). + +This script stands in for the real ``orca-slicer`` binary so slice tests can +exercise the REAL subprocess-invocation, stdout-pump, and output-parsing code in +``bambu_cli/slicer/`` (``orca._run_orcaslicer`` and ``output._finalize_slice``) +instead of mocking ``subprocess.Popen`` and ``os.path.exists``. + +It reproduces only the narrow slice of the OrcaSlicer CLI surface our code drives +(see ``bambu_cli/slicer/orca._build_orcaslicer_cmd``):: + + orca --load-settings ";" \ + --load-filaments \ + --slice 0 --export-3mf --outputdir \ + [--arrange 1] [--threads N] [ ...] + +Behaviour is selected via the ``ORCA_STUB_SCENARIO`` environment variable so a +test can point ``settings.orca_slicer`` at a generated launcher for this script +and choose a scenario without patching internals: + + success write a plausible, valid sliced .3mf; exit 0 (default) + benign_gl write a valid .3mf but emit GL/thumbnail noise on stderr and + exit non-zero (the "headless GL failed but slice is fine" + path ``_finalize_slice`` treats as benign) + empty_output write a zero-byte output file; exit 0 + corrupt_output write non-zip garbage to the output path; exit 0 + missing_output write nothing; exit 0 (parser sees no output file) + fail emit an ``[error]`` line and exit non-zero; write nothing + fail_real_gl emit GL noise AND a real "slicing error" line, exit non-zero, + write a valid .3mf (must NOT be treated as benign) + garbage_stdout print unparseable progress-ish noise, still succeed + hang sleep longer than the test's timeout (write nothing) + progress print several ``NN%`` / "slicing" lines then succeed + (drives the stdout progress-parsing branch) + +Extra knobs (all optional): + ORCA_STUB_SLEEP float seconds to sleep before finishing (default 0) + ORCA_STUB_EXIT override the exit code + ORCA_STUB_STDOUT extra literal text to print to stdout + ORCA_STUB_STDERR extra literal text to print to stderr + ORCA_STUB_MARKER if set, a file at this path is appended to when the stub + runs (lets a test assert the real binary was invoked) + +The stub never touches anything outside the ``--outputdir`` it is told to use +(plus the optional marker file), so tests stay confined to ``tmp_path``. +""" + +from __future__ import annotations + +import os +import sys +import time +import zipfile + +# ---- Minimal valid sliced-3MF payload --------------------------------------- +# ``bambu_cli.slicer.output._is_valid_sliced_3mf`` requires a non-corrupt OPC zip +# containing ``[Content_Types].xml`` plus either ``3D/3dmodel.model`` or a +# ``Metadata/plate_*.gcode`` plate. We ship both so the fixture resembles a real +# Bambu/Orca export. + +_CONTENT_TYPES = ( + '\n' + '' + '' + '' + "" +) + +_MODEL_XML = ( + '\n' + '' + "" +) + +_PLATE_GCODE = "; sliced by orca_stub (fake OrcaSlicer)\nG28\nG1 Z0.2 F600\n" + + +def _write_valid_3mf(path: str) -> None: + """Write a small but structurally valid sliced .3mf zip package.""" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", _CONTENT_TYPES) + zf.writestr("3D/3dmodel.model", _MODEL_XML) + zf.writestr("Metadata/plate_1.gcode", _PLATE_GCODE) + + +def _resolve_output_path(argv: list[str]) -> str | None: + """Recover the sliced-output path the way the real CLI would use it. + + Mirrors ``_build_orcaslicer_cmd``: ``--export-3mf `` (a bare + filename) resolved against ``--outputdir ``. + """ + outfile = None + outdir = None + for i, tok in enumerate(argv): + if tok == "--export-3mf" and i + 1 < len(argv): + outfile = argv[i + 1] + elif tok == "--outputdir" and i + 1 < len(argv): + outdir = argv[i + 1] + if outfile is None: + return None + if outdir: + return os.path.join(outdir, outfile) + return outfile + + +def main(argv: list[str]) -> int: + scenario = os.environ.get("ORCA_STUB_SCENARIO", "success") + + marker = os.environ.get("ORCA_STUB_MARKER") + if marker: + # Record that the real binary path was invoked (used by tests to assert + # the launcher/DI seam actually reached this script). + with open(marker, "a", encoding="utf-8") as fh: + fh.write(scenario + "\n") + + outpath = _resolve_output_path(argv) + + # A hang must block regardless of the sleep knob so timeout handling in + # ``cmd_slice`` (subprocess.TimeoutExpired -> EXIT_TIMEOUT) is exercised. + if scenario == "hang": + time.sleep(float(os.environ.get("ORCA_STUB_SLEEP", "3600"))) + return 0 + + sleep_s = float(os.environ.get("ORCA_STUB_SLEEP", "0")) + if sleep_s > 0: + time.sleep(sleep_s) + + stdout = sys.stdout + stderr = sys.stderr + + if scenario == "success": + print("Loading configuration...", file=stdout) + print("slicing plate 1", file=stdout) + print("100%", file=stdout) + if outpath: + _write_valid_3mf(outpath) + rc = 0 + + elif scenario == "progress": + for pct in (0, 17, 42, 73, 99): + print(f"exporting {pct}% ...", file=stdout) + print("slicing complete", file=stdout) + if outpath: + _write_valid_3mf(outpath) + rc = 0 + + elif scenario == "garbage_stdout": + # Non-progress noise that must not crash the stdout line handler. + print("\x00\x01 not-a-percentage ~~~ \r\r partial", file=stdout) + print("gibberish line without newline", file=stdout, end="") + if outpath: + _write_valid_3mf(outpath) + rc = 0 + + elif scenario == "benign_gl": + # OrcaSlicer wrote a valid .3mf but the headless GL/thumbnail step failed + # and it exited non-zero. ``_finalize_slice`` should treat this as success. + if outpath: + _write_valid_3mf(outpath) + print("slicing plate 1", file=stdout) + print("[GLFW] init OpenGL failed; skip thumbnail generation", file=stderr) + print("glew: no usable GL context", file=stderr) + rc = 1 + + elif scenario == "fail_real_gl": + # GL noise present but so is a real slicing error: must NOT be benign. + if outpath: + _write_valid_3mf(outpath) + print("[GLFW] init opengl failed", file=stderr) + print("[error] slicing error: model exceeds build volume", file=stderr) + rc = 1 + + elif scenario == "empty_output": + if outpath: + open(outpath, "w").close() # zero bytes + print("done", file=stdout) + rc = 0 + + elif scenario == "corrupt_output": + if outpath: + with open(outpath, "wb") as fh: + fh.write(b"this is not a zip file, just garbage bytes" * 8) + print("done", file=stdout) + rc = 0 + + elif scenario == "missing_output": + # Report success but never write the file. + print("slicing plate 1", file=stdout) + print("100%", file=stdout) + rc = 0 + + elif scenario == "fail": + print("Loading configuration...", file=stdout) + print("[error] nothing to be sliced, please check your model", file=stderr) + rc = 1 + + else: + print(f"orca_stub: unknown scenario {scenario!r}", file=stderr) + rc = 2 + + extra_out = os.environ.get("ORCA_STUB_STDOUT") + if extra_out: + print(extra_out, file=stdout) + extra_err = os.environ.get("ORCA_STUB_STDERR") + if extra_err: + print(extra_err, file=stderr) + + override = os.environ.get("ORCA_STUB_EXIT") + if override is not None: + rc = int(override) + + stdout.flush() + stderr.flush() + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_slice_stub_integration.py b/tests/test_slice_stub_integration.py new file mode 100644 index 0000000..bb4fc36 --- /dev/null +++ b/tests/test_slice_stub_integration.py @@ -0,0 +1,234 @@ +"""Hermetic end-to-end slice tests against the fake OrcaSlicer (roadmap C.4). + +These tests point ``settings.orca_slicer`` at a real launcher for +``tests/fakes/orca_stub/orca_stub.py`` and give ``cmd_slice`` a real +``profiles_dir`` and a real STL under ``tmp_path``. Nothing is mocked at the +``subprocess`` / ``os.path.exists`` layer, so the whole pipeline runs for real: + + _build_orcaslicer_cmd -> _run_orcaslicer (Popen + stdout pump) + -> _finalize_slice (returncode + 3MF validation + JSON/error emit) + +The point is to kill mutants in ``bambu_cli/slicer/output.py`` (the benign-GL +branch, empty/corrupt/missing output handling, error-line extraction) that the +mock-heavy unit tests could not reach, and to actually execute the real +``_run_orcaslicer`` process loop that unit tests skip via ``# pragma: no cover``. +""" + +from __future__ import annotations + +import argparse +import json +import os + +import pytest + +from bambu_cli.errors import BambuError +from bambu_cli.slicer import cmd_slice +from tests.bambu_test_base import settings_ctx +from tests.fakes.orca_stub import build_profiles_dir, make_orca_launcher, write_stl + + +def _last_json_object(text: str) -> dict: + """Return the last top-level JSON object in *text*. + + ``emit_json`` / ``emit_json_error`` pretty-print with ``indent=2``, so the + envelope spans multiple lines; scan for the last balanced ``{...}`` block. + """ + decoder = json.JSONDecoder() + last = None + idx = 0 + while idx < len(text): + brace = text.find("{", idx) + if brace == -1: + break + try: + obj, end = decoder.raw_decode(text, brace) + except json.JSONDecodeError: + idx = brace + 1 + continue + last = obj + idx = end + assert last is not None, f"no JSON object found in output: {text!r}" + return last + + +def _slice_args(model_path: str, outdir: str, **overrides) -> argparse.Namespace: + """A minimal real Namespace for cmd_slice (no MagicMock truthy-attr traps).""" + ns = argparse.Namespace( + file=model_path, + output=outdir, + quality="standard", + copies=1, + infill=15, + pattern="3dhoneycomb", + supports=False, + nozzle_temp=220, + bed_temp=60, + filament="PLA Basic", + json=False, + threads=None, + list_settings=False, + ) + for k, v in overrides.items(): + setattr(ns, k, v) + return ns + + +@pytest.fixture +def orca_env(tmp_path, monkeypatch): + """Real profiles + STL + fake-slicer launcher wired via settings_ctx. + + Returns a callable ``run(scenario, **stub_env)`` that installs the launcher + as ``settings.orca_slicer`` and invokes ``cmd_slice`` with the given stub + scenario, plus the resolved output path and model path. + """ + # A DISPLAY makes _build_orcaslicer_cmd skip the xvfb-run prefix on Linux so + # the launcher runs directly. Harmless on macOS/Windows. + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.delenv("ORCA_STUB_SCENARIO", raising=False) + + launcher = make_orca_launcher(str(tmp_path)) + profiles = build_profiles_dir(str(tmp_path)) + model = write_stl(str(tmp_path / "model.stl")) + outdir = tmp_path / "out" + outdir.mkdir() + # cmd_slice derives outfile from the source basename: model.stl -> model_sliced.3mf + outpath = str(outdir / "model_sliced.3mf") + + def run(scenario: str, args=None, **stub_env): + monkeypatch.setenv("ORCA_STUB_SCENARIO", scenario) + for k, v in stub_env.items(): + monkeypatch.setenv(k, str(v)) + ns = args if args is not None else _slice_args(model, str(outdir)) + with settings_ctx(orca_slicer=launcher, profiles_dir=profiles): + return cmd_slice(ns) + + run.model = model + run.outdir = str(outdir) + run.outpath = outpath + run.profiles = profiles + run.launcher = launcher + return run + + +# --- Success paths ----------------------------------------------------------- + + +def test_success_writes_valid_3mf(orca_env): + result = orca_env("success") + assert result == orca_env.outpath + assert os.path.exists(result) + import zipfile + + assert zipfile.is_zipfile(result) + names = set(zipfile.ZipFile(result).namelist()) + assert "[Content_Types].xml" in names + assert "Metadata/plate_1.gcode" in names + + +def test_success_emits_json_envelope(orca_env, capsys): + args = _slice_args(orca_env.model, orca_env.outdir, json=True) + result = orca_env("success", args=args) + assert result == orca_env.outpath + payload = _last_json_object(capsys.readouterr().out) + assert payload["status"] == "sliced" + assert payload["command"] == "slice" + # emit_json compacts $HOME to ~ in every string value; on Windows CI the + # pytest tmpdir lives under the user profile, so expect the display form. + from bambu_cli.utils import _display_path + + assert payload["path"] == _display_path(orca_env.outpath) + assert payload["filename"] == "model_sliced.3mf" + assert payload["bytes"] > 0 + assert payload["step_converted"] is False + + +def test_progress_scenario_still_succeeds(orca_env): + # Drives the stdout progress-line parsing in _run_orcaslicer with a + # human-facing (non-json) run so the Rich/progress branch executes. + result = orca_env("progress") + assert os.path.exists(result) + + +def test_garbage_stdout_does_not_break_pump(orca_env): + # Null bytes / stray \r / no-newline tail must not crash the stdout handler. + result = orca_env("garbage_stdout") + assert os.path.exists(result) + + +# --- The benign non-zero (headless GL) branch -------------------------------- + + +def test_benign_gl_nonzero_exit_is_treated_as_success(orca_env): + # returncode != 0 + valid .3mf + GL/thumbnail noise + no real error text + # => _finalize_slice continues (the hardest-to-mock branch). + result = orca_env("benign_gl") + assert result == orca_env.outpath + assert os.path.exists(result) + + +def test_real_error_with_gl_noise_is_not_benign(orca_env): + # GL noise present BUT a real "slicing error" line => must still fail even + # though a valid .3mf was written. Guards the `not _real_err` condition. + with pytest.raises(BambuError): + orca_env("fail_real_gl") + + +# --- Failure / bad-output paths ---------------------------------------------- + + +def test_nonzero_exit_failure_aborts(orca_env): + with pytest.raises(BambuError): + orca_env("fail") + + +def test_nonzero_exit_failure_json_envelope(orca_env, capsys): + args = _slice_args(orca_env.model, orca_env.outdir, json=True) + with pytest.raises(BambuError): + orca_env("fail", args=args) + payload = _last_json_object(capsys.readouterr().out) + assert payload["status"] == "error" + assert payload["command"] == "slice" + assert payload["failed_step"] == "slicer" + assert payload["returncode"] == 1 + + +def test_empty_output_file_aborts_and_is_removed(orca_env): + with pytest.raises(BambuError): + orca_env("empty_output") + # _finalize_slice removes the partial/empty file. + assert not os.path.exists(orca_env.outpath) + + +def test_corrupt_output_file_aborts_and_is_removed(orca_env): + with pytest.raises(BambuError): + orca_env("corrupt_output") + assert not os.path.exists(orca_env.outpath) + + +def test_missing_output_file_aborts(orca_env): + with pytest.raises(BambuError): + orca_env("missing_output") + assert not os.path.exists(orca_env.outpath) + + +# --- Timeout ----------------------------------------------------------------- + + +def test_hang_hits_slice_timeout(orca_env, monkeypatch): + # get_slicer_timeout reads args.slicer_timeout; pin it tiny so the stub's + # sleep trips the real subprocess.TimeoutExpired -> EXIT_TIMEOUT path. + args = _slice_args(orca_env.model, orca_env.outdir, slicer_timeout=1) + with pytest.raises(BambuError): + orca_env("hang", args=args, ORCA_STUB_SLEEP=30) + + +# --- The DI/launcher seam actually reached the fake binary ------------------- + + +def test_stub_binary_was_actually_invoked(orca_env, tmp_path): + marker = tmp_path / "invoked.log" + result = orca_env("success", ORCA_STUB_MARKER=str(marker)) + assert os.path.exists(result) + assert marker.exists() + assert marker.read_text().strip() == "success"