From 11938d1d184fa288450f6e3f6b7f3bbfa1cfa4e7 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:56:08 -0700 Subject: [PATCH 1/2] test: add coverage for render_detail private helpers (#879) Add direct unit tests for _format_relative_time, _render_active_period, _event_type_label, and the USER_MESSAGE branch of _build_event_details. - TestFormatRelativeTime: sub-hour, over-hour, negative-clamped, zero, and exact-one-hour branches - TestRenderActivePeriod: active panel rendered / inactive produces no output - TestRenderSessionDetailActivePeriod: integration test verifying Active Period panel appears in full render_session_detail output - TestEventTypeLabel: parametrized over known event types plus the wildcard branch for unknown types - TestBuildEventDetailsUserMessage: content returned, long content truncated with ellipsis, empty content returns empty string Also removes redundant local imports of _build_event_details in TestBuildEventDetailsToolRequests (now imported at module level). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_render_detail.py | 165 ++++++++++++++++++++-- 1 file changed, 153 insertions(+), 12 deletions(-) diff --git a/tests/copilot_usage/test_render_detail.py b/tests/copilot_usage/test_render_detail.py index 12bff362..831e42fe 100644 --- a/tests/copilot_usage/test_render_detail.py +++ b/tests/copilot_usage/test_render_detail.py @@ -9,6 +9,7 @@ import pytest from rich.console import Console +from copilot_usage._formatting import MAX_CONTENT_LEN from copilot_usage.models import ( CodeChanges, EventType, @@ -22,7 +23,11 @@ ToolTelemetry, ) from copilot_usage.render_detail import ( + _build_event_details, + _event_type_label, _extract_tool_name, + _format_relative_time, + _render_active_period, _render_code_changes, _render_recent_events, _render_shutdown_cycles, @@ -508,8 +513,6 @@ class TestBuildEventDetailsToolRequests: def test_tool_only_turn_shows_tool_names(self) -> None: """ASSISTANT_MESSAGE with content='', outputTokens=0, and two toolRequests must render both tool names.""" - from copilot_usage.render_detail import _build_event_details - ev = SessionEvent( type=EventType.ASSISTANT_MESSAGE, data={ @@ -529,8 +532,6 @@ def test_tool_only_turn_shows_tool_names(self) -> None: def test_mixed_turn_shows_tokens_and_tool(self) -> None: """ASSISTANT_MESSAGE with content, outputTokens, and one toolRequest must render token info and the tool name.""" - from copilot_usage.render_detail import _build_event_details - ev = SessionEvent( type=EventType.ASSISTANT_MESSAGE, data={ @@ -548,8 +549,6 @@ def test_mixed_turn_shows_tokens_and_tool(self) -> None: def test_no_tools_unchanged(self) -> None: """ASSISTANT_MESSAGE without toolRequests must behave as before.""" - from copilot_usage.render_detail import _build_event_details - ev = SessionEvent( type=EventType.ASSISTANT_MESSAGE, data={ @@ -564,8 +563,6 @@ def test_no_tools_unchanged(self) -> None: def test_truncation_applied_to_long_tool_list(self) -> None: """When the joined tool names exceed 60 chars, truncation applies.""" - from copilot_usage.render_detail import _build_event_details - long_names = [ {"name": f"very_long_tool_name_{i}", "toolCallId": f"t{i}"} for i in range(10) @@ -584,8 +581,6 @@ def test_truncation_applied_to_long_tool_list(self) -> None: def test_empty_names_show_unknown(self) -> None: """toolRequests present but all names empty must show '(unknown)'.""" - from copilot_usage.render_detail import _build_event_details - ev = SessionEvent( type=EventType.ASSISTANT_MESSAGE, data={ @@ -602,8 +597,6 @@ def test_empty_names_show_unknown(self) -> None: def test_singular_label_based_on_displayed_names(self) -> None: """When two toolRequests exist but only one has a name, use 'tool'.""" - from copilot_usage.render_detail import _build_event_details - ev = SessionEvent( type=EventType.ASSISTANT_MESSAGE, data={ @@ -662,3 +655,151 @@ def test_multi_model_shutdown_via_full_render(self) -> None: row = next(line for line in output.splitlines() if "2025-01-01 01:00" in line) assert re.search(r"\b7\b", row) # total API requests = 3 + 4 assert re.search(r"\b800\b", row) # total output tokens = 500 + 300 + + +# --------------------------------------------------------------------------- +# _format_relative_time — direct unit tests (issue #879) +# --------------------------------------------------------------------------- + + +class TestFormatRelativeTime: + """Direct unit tests covering all branches of _format_relative_time.""" + + def test_sub_hour_formats_as_m_ss(self) -> None: + """timedelta(minutes=4, seconds=7) → '+4:07'.""" + assert _format_relative_time(timedelta(minutes=4, seconds=7)) == "+4:07" + + def test_over_hour_formats_as_h_mm_ss(self) -> None: + """timedelta(hours=1, minutes=2, seconds=3) → '+1:02:03'.""" + assert ( + _format_relative_time(timedelta(hours=1, minutes=2, seconds=3)) + == "+1:02:03" + ) + + def test_negative_delta_clamped_to_zero(self) -> None: + """Negative timedelta must clamp to '+0:00', never a negative string.""" + assert _format_relative_time(timedelta(seconds=-10)) == "+0:00" + + def test_zero_delta(self) -> None: + """Zero timedelta → '+0:00'.""" + assert _format_relative_time(timedelta()) == "+0:00" + + def test_exactly_one_hour(self) -> None: + """Exactly 1h boundary triggers the hours branch.""" + assert _format_relative_time(timedelta(hours=1)) == "+1:00:00" + + +# --------------------------------------------------------------------------- +# _render_active_period — direct unit tests (issue #879) +# --------------------------------------------------------------------------- + + +class TestRenderActivePeriod: + """Direct unit tests for _render_active_period covering active / inactive.""" + + def test_active_session_renders_panel(self) -> None: + """Active session must render an 'Active Period' panel with stats.""" + summary = SessionSummary( + session_id="active-test", + is_active=True, + model_calls=3, + user_messages=2, + active_model_calls=3, + active_user_messages=2, + active_output_tokens=1000, + ) + buf, console = _buf_console() + _render_active_period(summary, target_console=console) + output = buf.getvalue() + assert "Active Period" in output + assert "3" in output # model calls + assert "2" in output # user messages + assert "1.0K" in output # output tokens (format_tokens renders 1000 as "1.0K") + + def test_inactive_session_produces_no_output(self) -> None: + """Inactive session → returns immediately, no output.""" + summary = SessionSummary(session_id="inactive-test", is_active=False) + buf, console = _buf_console() + _render_active_period(summary, target_console=console) + assert buf.getvalue() == "" + + +class TestRenderSessionDetailActivePeriod: + """Integration test: render_session_detail with is_active=True must + render the Active Period panel (issue #879).""" + + def test_active_session_shows_active_period_panel(self) -> None: + """render_session_detail with is_active=True must include the + Active Period panel in its output.""" + summary = SessionSummary( + session_id="active-e2e", + start_time=datetime(2026, 4, 1, 10, 0, 0, tzinfo=UTC), + is_active=True, + model_calls=5, + user_messages=3, + active_model_calls=5, + active_user_messages=3, + active_output_tokens=2000, + ) + ev = SessionEvent( + type=EventType.USER_MESSAGE, + timestamp=datetime(2026, 4, 1, 10, 5, 0, tzinfo=UTC), + data={"content": "hello"}, + ) + buf, console = _buf_console() + render_session_detail([ev], summary, target_console=console) + output = buf.getvalue() + assert "Active Period" in output + + +# --------------------------------------------------------------------------- +# _event_type_label — parametrized unit tests (issue #879) +# --------------------------------------------------------------------------- + + +class TestEventTypeLabel: + """Parametrized test for _event_type_label covering all known types + and the wildcard branch.""" + + @pytest.mark.parametrize( + ("event_type", "expected_text"), + [ + pytest.param(EventType.USER_MESSAGE, "user message", id="user-message"), + pytest.param(EventType.ASSISTANT_MESSAGE, "assistant", id="assistant"), + pytest.param(EventType.TOOL_EXECUTION_COMPLETE, "tool", id="tool-complete"), + pytest.param(EventType.SESSION_SHUTDOWN, "session end", id="session-end"), + pytest.param( + "UNKNOWN_FUTURE_TYPE", "UNKNOWN_FUTURE_TYPE", id="wildcard-branch" + ), + ], + ) + def test_label_text(self, event_type: str, expected_text: str) -> None: + """Label plain text must match the expected string.""" + label = _event_type_label(event_type) + assert label.plain == expected_text + + +# --------------------------------------------------------------------------- +# _build_event_details — USER_MESSAGE branch (issue #879) +# --------------------------------------------------------------------------- + + +class TestBuildEventDetailsUserMessage: + """Tests for the USER_MESSAGE branch of _build_event_details.""" + + def test_content_returned(self) -> None: + """Short content is returned as-is.""" + ev = SessionEvent(type=EventType.USER_MESSAGE, data={"content": "hello"}) + assert _build_event_details(ev) == "hello" + + def test_long_content_truncated(self) -> None: + """Content exceeding MAX_CONTENT_LEN must be truncated with '…'.""" + ev = SessionEvent(type=EventType.USER_MESSAGE, data={"content": "x" * 300}) + detail = _build_event_details(ev) + assert detail.endswith("…") + assert len(detail) <= MAX_CONTENT_LEN + + def test_empty_content_returns_empty_string(self) -> None: + """Empty content → empty string.""" + ev = SessionEvent(type=EventType.USER_MESSAGE, data={"content": ""}) + assert _build_event_details(ev) == "" From e8614a4eace5f179deea4031f92330743ed1e40d Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:04:38 -0700 Subject: [PATCH 2/2] fix: address review comments - Strip ANSI from Rich output before asserting specific substrings ("3 model calls", "2 user messages") instead of bare digits - Extend _event_type_label parametrization to cover all match cases (added TOOL_EXECUTION_START, ASSISTANT_TURN_START/END, SESSION_START) - Fix-forward: apply _strip_ansi consistently in integration test too Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_render_detail.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/copilot_usage/test_render_detail.py b/tests/copilot_usage/test_render_detail.py index 831e42fe..723f825c 100644 --- a/tests/copilot_usage/test_render_detail.py +++ b/tests/copilot_usage/test_render_detail.py @@ -710,11 +710,10 @@ def test_active_session_renders_panel(self) -> None: ) buf, console = _buf_console() _render_active_period(summary, target_console=console) - output = buf.getvalue() + output = _strip_ansi(buf.getvalue()) assert "Active Period" in output - assert "3" in output # model calls - assert "2" in output # user messages - assert "1.0K" in output # output tokens (format_tokens renders 1000 as "1.0K") + assert "3 model calls" in output + assert "2 user messages" in output def test_inactive_session_produces_no_output(self) -> None: """Inactive session → returns immediately, no output.""" @@ -748,7 +747,7 @@ def test_active_session_shows_active_period_panel(self) -> None: ) buf, console = _buf_console() render_session_detail([ev], summary, target_console=console) - output = buf.getvalue() + output = _strip_ansi(buf.getvalue()) assert "Active Period" in output @@ -758,8 +757,8 @@ def test_active_session_shows_active_period_panel(self) -> None: class TestEventTypeLabel: - """Parametrized test for _event_type_label covering all known types - and the wildcard branch.""" + """Parametrized test for _event_type_label covering every labelled + EventType case and the wildcard branch.""" @pytest.mark.parametrize( ("event_type", "expected_text"), @@ -767,6 +766,10 @@ class TestEventTypeLabel: pytest.param(EventType.USER_MESSAGE, "user message", id="user-message"), pytest.param(EventType.ASSISTANT_MESSAGE, "assistant", id="assistant"), pytest.param(EventType.TOOL_EXECUTION_COMPLETE, "tool", id="tool-complete"), + pytest.param(EventType.TOOL_EXECUTION_START, "tool start", id="tool-start"), + pytest.param(EventType.ASSISTANT_TURN_START, "turn start", id="turn-start"), + pytest.param(EventType.ASSISTANT_TURN_END, "turn end", id="turn-end"), + pytest.param(EventType.SESSION_START, "session start", id="session-start"), pytest.param(EventType.SESSION_SHUTDOWN, "session end", id="session-end"), pytest.param( "UNKNOWN_FUTURE_TYPE", "UNKNOWN_FUTURE_TYPE", id="wildcard-branch"