diff --git a/tests/test_qa_progress.py b/tests/test_qa_progress.py new file mode 100644 index 0000000..1d5fde2 --- /dev/null +++ b/tests/test_qa_progress.py @@ -0,0 +1,226 @@ +"""Tests for ``arborist.qa.progress`` — stage-level state-machine +emitter for ``arborist query``. + +Module was zero-coverage. 85 LOC of pure stdlib emitter + +env/TTY/cli-override resolution. Surface: + + - Progress.emit (formatting, key=value tail, stream redirect) + - disabled() singleton (true no-op, no formatting cost) + - from_env(cli_override=...) precedence resolution + - ARBORIST_PROGRESS env var (multiple truthy/falsy spellings) + - TTY auto-detect fallback +""" + +from __future__ import annotations + +import io +import time + +import pytest + +from arborist.qa.progress import Progress, disabled, from_env + + +# --- Progress.emit -------------------------------------------------- + + +def test_progress_emit_writes_to_stream(): + buf = io.StringIO() + p = Progress(enabled=True, t_start=time.monotonic(), stream=buf) + p.emit("stage.name", k="v") + out = buf.getvalue() + assert "stage.name" in out + assert "k=v" in out + assert "arborist" in out # prefix + + +def test_progress_emit_disabled_writes_nothing(): + """`enabled=False` is a true no-op — no string formatting, + no stream write.""" + buf = io.StringIO() + p = Progress(enabled=False, t_start=time.monotonic(), stream=buf) + p.emit("stage", a=1) + assert buf.getvalue() == "" + + +def test_progress_emit_no_kv_writes_bare_prefix(): + """emit('stage') with no kwargs → just the timestamp + stage.""" + buf = io.StringIO() + p = Progress(enabled=True, t_start=time.monotonic(), stream=buf) + p.emit("stage.bare") + out = buf.getvalue() + assert "stage.bare" in out + assert "=" not in out # no key=value tail + + +def test_progress_emit_includes_elapsed_seconds(): + """Elapsed is wall-clock since t_start. Format `NN.NNs`.""" + t0 = time.monotonic() - 1.5 # pretend started 1.5s ago + buf = io.StringIO() + p = Progress(enabled=True, t_start=t0, stream=buf) + p.emit("now") + out = buf.getvalue() + # Format includes "1.5" or higher; exact value drifts but the + # decimal format is fixed at 6.2f. + assert "s]" in out # closing bracket on elapsed + # Token before "s]" should parse as a positive float. + head = out.split("s]")[0] + elapsed_str = head.split()[-1] + assert float(elapsed_str) >= 1.5 + + +def test_progress_emit_multiple_kv_pairs_space_separated(): + buf = io.StringIO() + p = Progress(enabled=True, t_start=time.monotonic(), stream=buf) + p.emit("multi", a=1, b="two", c=3.14) + out = buf.getvalue() + assert "a=1" in out + assert "b=two" in out + assert "c=3.14" in out + + +def test_progress_emit_default_stream_is_stderr(capsys): + """When stream kwarg is None, default is sys.stderr.""" + p = Progress(enabled=True, t_start=time.monotonic(), stream=None) + p.emit("default-stream") + captured = capsys.readouterr() + assert captured.out == "" + assert "default-stream" in captured.err + + +def test_progress_emit_default_t_start_is_now(): + """If t_start kwarg is None, defaults to monotonic() at + construction. Elapsed at first emit() should be ~0.""" + buf = io.StringIO() + p = Progress(enabled=True, t_start=None, stream=buf) + p.emit("right-after-init") + out = buf.getvalue() + elapsed_str = out.split("s]")[0].split()[-1] + assert float(elapsed_str) < 0.1 # virtually no time elapsed + + +# --- disabled() singleton ------------------------------------------- + + +def test_disabled_returns_singleton(): + """disabled() returns the same Progress instance on every call. + Suitable as a default kwarg value with no allocation.""" + a = disabled() + b = disabled() + assert a is b + assert a.enabled is False + + +def test_disabled_emits_nothing(): + p = disabled() + # disabled() singleton has no test-injectable stream; emit must + # still no-op without raising. + p.emit("stage", k="v") + # No assertion — just confirming no exception path. + + +# --- from_env precedence -------------------------------------------- + + +def test_from_env_cli_override_on(monkeypatch): + """cli_override='on' wins over env + TTY.""" + monkeypatch.setenv("ARBORIST_PROGRESS", "0") + p = from_env(cli_override="on") + assert p.enabled is True + + +def test_from_env_cli_override_off(monkeypatch): + """cli_override='off' wins over env.""" + monkeypatch.setenv("ARBORIST_PROGRESS", "1") + p = from_env(cli_override="off") + assert p.enabled is False + + +@pytest.mark.parametrize("env_value", ["1", "true", "on", "yes", + "TRUE", "On", "YES"]) +def test_from_env_truthy_env_enables(monkeypatch, env_value): + """ARBORIST_PROGRESS in {1, true, on, yes} (case-insensitive) + → enabled=True.""" + monkeypatch.setenv("ARBORIST_PROGRESS", env_value) + p = from_env() + assert p.enabled is True + + +@pytest.mark.parametrize("env_value", ["0", "false", "off", "no", + "FALSE", "Off", "NO"]) +def test_from_env_falsy_env_disables(monkeypatch, env_value): + monkeypatch.setenv("ARBORIST_PROGRESS", env_value) + p = from_env() + assert p.enabled is False + + +def test_from_env_unknown_env_falls_through_to_tty(monkeypatch): + """ARBORIST_PROGRESS=garbage → fall through to TTY auto-detect.""" + monkeypatch.setenv("ARBORIST_PROGRESS", "maybe") + # Force stderr to be non-TTY (we're in a test, so usually it is). + class _NonTTY: + def isatty(self): + return False + monkeypatch.setattr("arborist.qa.progress.sys.stderr", _NonTTY()) + p = from_env() + assert p.enabled is False + + +def test_from_env_unset_env_falls_through_to_tty(monkeypatch): + """No ARBORIST_PROGRESS in env → TTY detect.""" + monkeypatch.delenv("ARBORIST_PROGRESS", raising=False) + class _IsATTY: + def isatty(self): + return True + monkeypatch.setattr("arborist.qa.progress.sys.stderr", _IsATTY()) + p = from_env() + assert p.enabled is True + + +def test_from_env_isatty_attribute_error_disables(monkeypatch): + """If sys.stderr lacks .isatty (replaced with a buffer), the + fallback is enabled=False (fail-closed).""" + monkeypatch.delenv("ARBORIST_PROGRESS", raising=False) + + class _NoIsATTY: + pass + + monkeypatch.setattr("arborist.qa.progress.sys.stderr", _NoIsATTY()) + p = from_env() + assert p.enabled is False + + +def test_from_env_returns_fresh_instance(monkeypatch): + """Each from_env() call returns a new Progress with a fresh + t_start; never shares state with prior calls.""" + monkeypatch.setenv("ARBORIST_PROGRESS", "1") + a = from_env() + time.sleep(0.001) + b = from_env() + assert a is not b + assert b.t_start >= a.t_start + + +# --- empty-string env ----------------------------------------------- + + +def test_from_env_empty_string_falls_through(monkeypatch): + """ARBORIST_PROGRESS='' (empty) → fall through to TTY.""" + monkeypatch.setenv("ARBORIST_PROGRESS", "") + class _NonTTY: + def isatty(self): + return False + monkeypatch.setattr("arborist.qa.progress.sys.stderr", _NonTTY()) + p = from_env() + assert p.enabled is False + + +def test_from_env_whitespace_env_falls_through(monkeypatch): + """ARBORIST_PROGRESS=' ' (whitespace) → strip → empty → TTY.""" + monkeypatch.setenv("ARBORIST_PROGRESS", " ") + class _IsATTY: + def isatty(self): + return True + monkeypatch.setattr("arborist.qa.progress.sys.stderr", _IsATTY()) + p = from_env() + assert p.enabled is True