From 4272efbcb7dff7e2bbbc76a36dc4258deff000ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 19:41:26 +0000 Subject: [PATCH 1/2] test: cover untested cli.py branches (#59) Add tests for five untested code paths in cli.py: 1. _ensure_aware: parametrized tests for None, aware, and naive datetimes 2. Uppercase interactive commands: Q, C, R produce same outcomes as lowercase 3. _show_session_by_index with events_path=None: asserts error message 4. Group-level --path propagation: all four subcommands inherit group path 5. Auto-refresh branches: change_event triggers re-render in home, cost, and detail views via captured observer event Coverage for cli.py increases from 92% to 96%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 296 ++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 2471ac5..959ed2f 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -4,13 +4,16 @@ import json import threading +from datetime import UTC, datetime, timezone from pathlib import Path from typing import Any +import pytest from click.testing import CliRunner from rich.console import Console from copilot_usage.cli import ( + _ensure_aware, # pyright: ignore[reportPrivateUsage] _show_session_by_index, # pyright: ignore[reportPrivateUsage] _start_observer, # pyright: ignore[reportPrivateUsage] _stop_observer, # pyright: ignore[reportPrivateUsage] @@ -602,3 +605,296 @@ def test_inherits_from_filesystemeventhandler(self) -> None: assert isinstance(handler, FileSystemEventHandler) handler.dispatch(object()) assert event.is_set() + + +# --------------------------------------------------------------------------- +# Issue #59 — untested branches +# --------------------------------------------------------------------------- + + +# 1. _ensure_aware unit tests ------------------------------------------------ + + +@pytest.mark.parametrize( + ("dt_in", "expected"), + [ + pytest.param(None, None, id="none-returns-none"), + pytest.param( + datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC), + datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC), + id="aware-unchanged", + ), + pytest.param( + datetime(2025, 6, 1, 12, 0, 0), + datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC), + id="naive-gets-utc", + ), + ], +) +def test_ensure_aware(dt_in: datetime | None, expected: datetime | None) -> None: + """_ensure_aware handles None, aware, and naive datetimes correctly.""" + result = _ensure_aware(dt_in) + assert result == expected + if result is not None and expected is not None: + assert result.tzinfo is not None + + +def test_ensure_aware_preserves_non_utc_timezone() -> None: + """An already-aware dt with a non-UTC tz is returned unchanged.""" + est = timezone(offset=datetime.min.replace(hour=5) - datetime.min) # noqa: DTZ001 + dt_in = datetime(2025, 1, 1, 12, 0, 0, tzinfo=est) + result = _ensure_aware(dt_in) + assert result is dt_in # exact same object + + +# 2. Uppercase interactive commands ------------------------------------------- + + +def test_interactive_quit_uppercase(tmp_path: Path) -> None: + """Uppercase 'Q' exits the interactive loop.""" + _write_session(tmp_path, "up_q0000-0000-0000-0000-000000000000", name="QuitUpper") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="Q\n") + assert result.exit_code == 0 + + +def test_interactive_cost_view_uppercase(tmp_path: Path) -> None: + """Uppercase 'C' switches to cost view.""" + _write_session(tmp_path, "up_c0000-0000-0000-0000-000000000000", name="CostUpper") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="C\nq\n") + assert result.exit_code == 0 + assert "Cost" in result.output + + +def test_interactive_refresh_uppercase(tmp_path: Path) -> None: + """Uppercase 'R' refreshes data.""" + _write_session( + tmp_path, "up_r0000-0000-0000-0000-000000000000", name="RefreshUpper" + ) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="R\nq\n") + assert result.exit_code == 0 + + +# 3. _show_session_by_index with events_path=None ---------------------------- + + +def test_show_session_by_index_none_events_path() -> None: + """events_path=None produces 'No events path' error message.""" + from copilot_usage.models import SessionSummary + + s = SessionSummary( + session_id="noneevts-0000-0000-0000-000000000000", + events_path=None, + ) + console = Console(file=None, force_terminal=True) + with console.capture() as capture: + _show_session_by_index(console, [s], 1) + assert "no events path" in capture.get().lower() + + +# 4. Group-level --path propagation ------------------------------------------- + + +def test_group_path_propagates_to_summary(tmp_path: Path) -> None: + """Group-level --path is used by 'summary' when subcommand omits --path.""" + _write_session(tmp_path, "grp_sum00-0000-0000-0000-000000000000", name="GrpSum") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path), "summary"]) + assert result.exit_code == 0 + assert "GrpSum" in result.output or "Summary" in result.output + + +def test_group_path_propagates_to_session(tmp_path: Path, monkeypatch: Any) -> None: + """Group-level --path is used by 'session' when subcommand omits --path.""" + _write_session(tmp_path, "grp_ses00-0000-0000-0000-000000000000", name="GrpSes") + + def _fake_discover(_base: Path | None = None) -> list[Path]: + return sorted( + tmp_path.glob("*/events.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _fake_discover) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path), "session", "grp_ses00"]) + assert result.exit_code == 0 + assert "grp_ses00" in result.output + + +def test_group_path_propagates_to_cost(tmp_path: Path) -> None: + """Group-level --path is used by 'cost' when subcommand omits --path.""" + _write_session( + tmp_path, "grp_cst00-0000-0000-0000-000000000000", name="GrpCost", premium=2 + ) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path), "cost"]) + assert result.exit_code == 0 + assert "Cost" in result.output or "Total" in result.output + + +def test_group_path_propagates_to_live(tmp_path: Path) -> None: + """Group-level --path is used by 'live' when subcommand omits --path.""" + _write_session( + tmp_path, + "grp_liv00-0000-0000-0000-000000000000", + name="GrpLive", + active=True, + ) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path), "live"]) + assert result.exit_code == 0 + + +# 5. Auto-refresh branches in _interactive_loop ------------------------------- + + +def test_auto_refresh_home_view(tmp_path: Path, monkeypatch: Any) -> None: + """change_event triggers re-render while on home view.""" + _write_session(tmp_path, "ar_home0-0000-0000-0000-000000000000", name="AutoHome") + + draw_home_calls: list[int] = [] + + import copilot_usage.cli as cli_mod + + orig_draw_home = cli_mod._draw_home # pyright: ignore[reportPrivateUsage] + + def _patched_draw_home(console: Console, sessions: list[Any]) -> None: + draw_home_calls.append(1) + orig_draw_home(console, sessions) + + monkeypatch.setattr(cli_mod, "_draw_home", _patched_draw_home) + + # Capture the change_event via _start_observer + captured_event: list[threading.Event] = [] + orig_start_observer = cli_mod._start_observer # pyright: ignore[reportPrivateUsage] + + def _capturing_start(session_path: Path, change_event: threading.Event) -> object: + captured_event.append(change_event) + return orig_start_observer(session_path, change_event) + + monkeypatch.setattr(cli_mod, "_start_observer", _capturing_start) + + call_count = 0 + + def _fake_read(timeout: float = 0.5) -> str | None: # noqa: ARG001 + nonlocal call_count + call_count += 1 + if call_count == 1: + # Set the change_event so next loop iteration triggers home refresh + if captured_event: + captured_event[0].set() + return None + return "q" + + monkeypatch.setattr(cli_mod, "_read_line_nonblocking", _fake_read) + + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 0 + # _draw_home called at least twice: initial + auto-refresh + assert len(draw_home_calls) >= 2 + + +def test_auto_refresh_cost_view(tmp_path: Path, monkeypatch: Any) -> None: + """change_event triggers re-render while on cost view.""" + _write_session(tmp_path, "ar_cost0-0000-0000-0000-000000000000", name="AutoCost") + + render_cost_calls: list[int] = [] + + import copilot_usage.cli as cli_mod + + orig_render_cost = cli_mod.render_cost_view + + def _patched_render_cost(*args: Any, **kwargs: Any) -> None: + render_cost_calls.append(1) + orig_render_cost(*args, **kwargs) + + monkeypatch.setattr(cli_mod, "render_cost_view", _patched_render_cost) + + # Capture the change_event via _start_observer + captured_event: list[threading.Event] = [] + orig_start_observer = cli_mod._start_observer # pyright: ignore[reportPrivateUsage] + + def _capturing_start(session_path: Path, change_event: threading.Event) -> object: + captured_event.append(change_event) + return orig_start_observer(session_path, change_event) + + monkeypatch.setattr(cli_mod, "_start_observer", _capturing_start) + + call_count = 0 + + def _fake_read(timeout: float = 0.5) -> str | None: # noqa: ARG001 + nonlocal call_count + call_count += 1 + if call_count == 1: + return "c" # navigate to cost view + if call_count == 2: + # Set change_event so next loop iteration triggers cost refresh + if captured_event: + captured_event[0].set() + return None + if call_count == 3: + return "" # go back from cost view + return "q" + + monkeypatch.setattr(cli_mod, "_read_line_nonblocking", _fake_read) + + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 0 + # render_cost_view called at least twice: initial 'c' + auto-refresh + assert len(render_cost_calls) >= 2 + + +def test_auto_refresh_detail_view(tmp_path: Path, monkeypatch: Any) -> None: + """change_event triggers re-render while on detail view with detail_idx set.""" + _write_session(tmp_path, "ar_det00-0000-0000-0000-000000000000", name="AutoDetail") + + show_detail_calls: list[int] = [] + + import copilot_usage.cli as cli_mod + + orig_show = cli_mod._show_session_by_index # pyright: ignore[reportPrivateUsage] + + def _patched_show(*args: Any, **kwargs: Any) -> None: + show_detail_calls.append(1) + orig_show(*args, **kwargs) + + monkeypatch.setattr(cli_mod, "_show_session_by_index", _patched_show) + + # Capture the change_event via _start_observer + captured_event: list[threading.Event] = [] + orig_start_observer = cli_mod._start_observer # pyright: ignore[reportPrivateUsage] + + def _capturing_start(session_path: Path, change_event: threading.Event) -> object: + captured_event.append(change_event) + return orig_start_observer(session_path, change_event) + + monkeypatch.setattr(cli_mod, "_start_observer", _capturing_start) + + call_count = 0 + + def _fake_read(timeout: float = 0.5) -> str | None: # noqa: ARG001 + nonlocal call_count + call_count += 1 + if call_count == 1: + return "1" # navigate to detail view + if call_count == 2: + # Set change_event so auto-refresh fires in detail view + if captured_event: + captured_event[0].set() + return None + if call_count == 3: + return "" # go back to home + return "q" + + monkeypatch.setattr(cli_mod, "_read_line_nonblocking", _fake_read) + + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 0 + # _show_session_by_index called at least twice: initial '1' + auto-refresh + assert len(show_detail_calls) >= 2 From 6cda280db877754fbeb72113caadfd0b87f19290 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 19:48:41 +0000 Subject: [PATCH 2/2] fix: address review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 959ed2f..7227ee3 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -4,7 +4,7 @@ import json import threading -from datetime import UTC, datetime, timezone +from datetime import UTC, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -641,8 +641,8 @@ def test_ensure_aware(dt_in: datetime | None, expected: datetime | None) -> None def test_ensure_aware_preserves_non_utc_timezone() -> None: """An already-aware dt with a non-UTC tz is returned unchanged.""" - est = timezone(offset=datetime.min.replace(hour=5) - datetime.min) # noqa: DTZ001 - dt_in = datetime(2025, 1, 1, 12, 0, 0, tzinfo=est) + non_utc = timezone(offset=timedelta(hours=5)) + dt_in = datetime(2025, 1, 1, 12, 0, 0, tzinfo=non_utc) result = _ensure_aware(dt_in) assert result is dt_in # exact same object