"""Tests for v0.34.0 Part A — smart logging tiers.""" from __future__ import annotations import logging import re import pytest from typer.testing import CliRunner from soup_cli.cli import app from soup_cli.utils.log_level import ( LOG_LEVELS, LogLevel, parse_log_level, resolve_python_log_level, setup_logging, ) # Rich help renderer can split a flag like --log-level with ANSI colour # escapes between `--`, `-log`, `-level` when the terminal is narrow (CI # runners have a narrower default width than local shells). Strip ANSI so # substring assertions are robust across all CI matrix jobs. _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[mK]") def _strip_ansi(text: str) -> str: return _ANSI_ESCAPE.sub("", text) class TestLogLevelEnum: def test_four_tiers_defined(self): assert set(LOG_LEVELS) == {"quiet", "normal", "verbose", "debug"} def test_parse_valid(self): assert parse_log_level("quiet") == LogLevel.QUIET assert parse_log_level("normal") == LogLevel.NORMAL assert parse_log_level("verbose") == LogLevel.VERBOSE assert parse_log_level("debug") == LogLevel.DEBUG def test_parse_case_insensitive(self): assert parse_log_level("QUIET") == LogLevel.QUIET assert parse_log_level("Verbose") == LogLevel.VERBOSE def test_parse_invalid(self): with pytest.raises(ValueError, match="invalid log level"): parse_log_level("loud") def test_parse_null_byte_rejected(self): with pytest.raises(ValueError): parse_log_level("quiet\x00") def test_resolve_python_level(self): assert resolve_python_log_level(LogLevel.QUIET) == logging.ERROR assert resolve_python_log_level(LogLevel.NORMAL) == logging.WARNING assert resolve_python_log_level(LogLevel.VERBOSE) == logging.INFO assert resolve_python_log_level(LogLevel.DEBUG) == logging.DEBUG class TestSetupLogging: def test_returns_root_logger(self): logger = setup_logging(LogLevel.NORMAL) assert logger.level == logging.WARNING def test_quiet_suppresses_info(self): logger = setup_logging(LogLevel.QUIET) assert not logger.isEnabledFor(logging.INFO) def test_debug_enables_debug(self): logger = setup_logging(LogLevel.DEBUG) assert logger.isEnabledFor(logging.DEBUG) def test_idempotent(self): # Calling twice should not duplicate handlers setup_logging(LogLevel.NORMAL) n_handlers = len(logging.getLogger("soup").handlers) setup_logging(LogLevel.NORMAL) assert len(logging.getLogger("soup").handlers) == n_handlers def test_tier_change_replaces_handler(self): setup_logging(LogLevel.NORMAL) setup_logging(LogLevel.DEBUG) # Exactly one tier-tagged handler should remain — the DEBUG one. tagged = [ handler for handler in logging.getLogger("soup").handlers if getattr(handler, "_soup_log_tier", None) is not None ] assert len(tagged) == 1 assert tagged[0]._soup_log_tier == LogLevel.DEBUG # type: ignore[attr-defined] def test_parse_non_string_rejected(self): with pytest.raises(ValueError, match="must be a string"): parse_log_level(42) # type: ignore[arg-type] class TestCliFlag: def test_log_level_help_visible(self): runner = CliRunner() result = runner.invoke(app, ["--help"]) assert "--log-level" in _strip_ansi(result.output), result.output def test_log_level_invalid_rejected(self): runner = CliRunner() result = runner.invoke(app, ["--log-level", "loud", "version"]) assert result.exit_code != 0 def test_log_level_quiet_accepted(self): runner = CliRunner() result = runner.invoke(app, ["--log-level", "quiet", "version"]) assert result.exit_code == 0, (result.output, repr(result.exception))