diff --git a/src/copilot_usage/report.py b/src/copilot_usage/report.py index 0678901..f5e8a64 100644 --- a/src/copilot_usage/report.py +++ b/src/copilot_usage/report.py @@ -61,6 +61,21 @@ def format_tokens(n: int) -> str: return str(n) +def _format_timedelta(td: timedelta) -> str: + """Format a timedelta to human-readable duration (e.g. '1h 5m 30s').""" + total_seconds = max(int(td.total_seconds()), 0) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + parts: list[str] = [] + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if seconds or not parts: + parts.append(f"{seconds}s") + return " ".join(parts) + + def format_duration(ms: int) -> str: """Format milliseconds to human-readable duration. @@ -74,36 +89,12 @@ def format_duration(ms: int) -> str: >>> format_duration(3661000) '1h 1m 1s' """ - if ms <= 0: - return "0s" - total_seconds = ms // 1000 - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - seconds = total_seconds % 60 - - parts: list[str] = [] - if hours: - parts.append(f"{hours}h") - if minutes: - parts.append(f"{minutes}m") - if seconds or not parts: - parts.append(f"{seconds}s") - return " ".join(parts) + return _format_timedelta(timedelta(milliseconds=ms)) def _format_elapsed_since(start: datetime) -> str: - """Return a human-readable elapsed time from *start* to now. - - Formats as ``Xh Ym`` when >= 1 hour, otherwise ``Ym Zs``. - """ - now = datetime.now(tz=UTC) - delta = now - ensure_aware(start) - total_seconds = max(int(delta.total_seconds()), 0) - hours, remainder = divmod(total_seconds, 3600) - minutes, seconds = divmod(remainder, 60) - if hours > 0: - return f"{hours}h {minutes}m" - return f"{minutes}m {seconds}s" + """Return a human-readable elapsed time from *start* to now.""" + return _format_timedelta(datetime.now(tz=UTC) - ensure_aware(start)) def _estimated_output_tokens(session: SessionSummary) -> int: @@ -295,15 +286,7 @@ def _format_detail_duration( """Return a human-readable duration string between two timestamps.""" if start is None or end is None: return "—" - delta = end - start - total_seconds = max(int(delta.total_seconds()), 0) - if total_seconds < 60: - return f"{total_seconds}s" - minutes, seconds = divmod(total_seconds, 60) - if minutes < 60: - return f"{minutes}m {seconds}s" - hours, minutes = divmod(minutes, 60) - return f"{hours}h {minutes}m" + return _format_timedelta(end - start) def _event_type_label(event_type: str) -> Text: diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py index a8efdc1..07ce25f 100644 --- a/tests/copilot_usage/test_report.py +++ b/tests/copilot_usage/test_report.py @@ -32,6 +32,7 @@ _format_elapsed_since, _format_relative_time, _format_session_running_time, + _format_timedelta, _has_active_period_stats, _render_model_table, _render_shutdown_cycles, @@ -2846,12 +2847,12 @@ def test_minutes_seconds_branch(self) -> None: assert result == "5m 30s" def test_zero_elapsed(self) -> None: - """When start == now, format is '0m 0s'.""" + """When start == now, format is '0s'.""" now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC) with patch("copilot_usage.report.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now result = _format_elapsed_since(now) - assert result == "0m 0s" + assert result == "0s" # --------------------------------------------------------------------------- @@ -2861,16 +2862,14 @@ def test_zero_elapsed(self) -> None: class TestFormatDetailDurationBoundaries: def test_exactly_60_seconds(self) -> None: - """60s sits on the < 60 boundary — should produce '1m 0s'.""" + """60s sits on the < 60 boundary — should produce '1m'.""" start = datetime(2025, 1, 1, tzinfo=UTC) - assert _format_detail_duration(start, start + timedelta(seconds=60)) == "1m 0s" + assert _format_detail_duration(start, start + timedelta(seconds=60)) == "1m" def test_exactly_3600_seconds(self) -> None: - """3600s sits on the minutes < 60 boundary — should produce '1h 0m'.""" + """3600s sits on the minutes < 60 boundary — should produce '1h'.""" start = datetime(2025, 1, 1, tzinfo=UTC) - assert ( - _format_detail_duration(start, start + timedelta(seconds=3600)) == "1h 0m" - ) + assert _format_detail_duration(start, start + timedelta(seconds=3600)) == "1h" def test_start_none(self) -> None: """None start should return em-dash.""" @@ -2881,3 +2880,39 @@ def test_end_none(self) -> None: """None end should return em-dash.""" start = datetime(2025, 1, 1, tzinfo=UTC) assert _format_detail_duration(start, None) == "—" + + +# --------------------------------------------------------------------------- +# Issue #243 — _format_timedelta core helper tests +# --------------------------------------------------------------------------- + + +class TestFormatTimedelta: + """Direct unit tests for the core _format_timedelta helper.""" + + def test_zero(self) -> None: + assert _format_timedelta(timedelta(0)) == "0s" + + def test_negative(self) -> None: + assert _format_timedelta(timedelta(seconds=-5)) == "0s" + + def test_seconds_only(self) -> None: + assert _format_timedelta(timedelta(seconds=45)) == "45s" + + def test_minutes_and_seconds(self) -> None: + assert _format_timedelta(timedelta(minutes=6, seconds=29)) == "6m 29s" + + def test_exact_minute(self) -> None: + assert _format_timedelta(timedelta(minutes=1)) == "1m" + + def test_exact_hour(self) -> None: + assert _format_timedelta(timedelta(hours=1)) == "1h" + + def test_hours_minutes_seconds(self) -> None: + assert _format_timedelta(timedelta(hours=1, minutes=1, seconds=1)) == "1h 1m 1s" + + def test_hours_and_minutes(self) -> None: + assert _format_timedelta(timedelta(hours=2, minutes=30)) == "2h 30m" + + def test_hours_and_seconds(self) -> None: + assert _format_timedelta(timedelta(hours=1, seconds=5)) == "1h 5s"