Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 20 additions & 33 deletions src/copilot_usage/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -74,36 +89,16 @@ 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``.
Formats using :func:`_format_timedelta` for consistent output.
"""
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"
delta = datetime.now(tz=UTC) - ensure_aware(start)
return _format_timedelta(delta)


def _estimated_output_tokens(session: SessionSummary) -> int:
Expand Down Expand Up @@ -295,15 +290,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:
Expand Down
52 changes: 44 additions & 8 deletions tests/copilot_usage/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
_format_elapsed_since,
_format_relative_time,
_format_session_running_time,
_format_timedelta,
_has_active_period_stats,
_render_model_table,
_render_shutdown_cycles,
Expand Down Expand Up @@ -2895,12 +2896,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"


# ---------------------------------------------------------------------------
Expand All @@ -2910,16 +2911,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."""
Expand All @@ -2930,3 +2929,40 @@ 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 — Unit tests for _format_timedelta core helper
# ---------------------------------------------------------------------------


class TestFormatTimedelta:
def test_zero(self) -> None:
assert _format_timedelta(timedelta(0)) == "0s"

def test_seconds_only(self) -> None:
assert _format_timedelta(timedelta(seconds=5)) == "5s"

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_no_seconds(self) -> None:
assert _format_timedelta(timedelta(hours=2, minutes=30)) == "2h 30m"

def test_negative_clamped_to_zero(self) -> None:
assert _format_timedelta(timedelta(seconds=-10)) == "0s"

def test_large_duration(self) -> None:
assert (
_format_timedelta(timedelta(hours=100, minutes=5, seconds=3))
== "100h 5m 3s"
)
Loading