99import pytest
1010from rich .console import Console
1111
12+ from copilot_usage ._formatting import MAX_CONTENT_LEN
1213from copilot_usage .models import (
1314 CodeChanges ,
1415 EventType ,
2223 ToolTelemetry ,
2324)
2425from copilot_usage .render_detail import (
26+ _build_event_details ,
27+ _event_type_label ,
2528 _extract_tool_name ,
29+ _format_relative_time ,
30+ _render_active_period ,
2631 _render_code_changes ,
2732 _render_recent_events ,
2833 _render_shutdown_cycles ,
@@ -508,8 +513,6 @@ class TestBuildEventDetailsToolRequests:
508513 def test_tool_only_turn_shows_tool_names (self ) -> None :
509514 """ASSISTANT_MESSAGE with content='', outputTokens=0, and two
510515 toolRequests must render both tool names."""
511- from copilot_usage .render_detail import _build_event_details
512-
513516 ev = SessionEvent (
514517 type = EventType .ASSISTANT_MESSAGE ,
515518 data = {
@@ -529,8 +532,6 @@ def test_tool_only_turn_shows_tool_names(self) -> None:
529532 def test_mixed_turn_shows_tokens_and_tool (self ) -> None :
530533 """ASSISTANT_MESSAGE with content, outputTokens, and one
531534 toolRequest must render token info and the tool name."""
532- from copilot_usage .render_detail import _build_event_details
533-
534535 ev = SessionEvent (
535536 type = EventType .ASSISTANT_MESSAGE ,
536537 data = {
@@ -548,8 +549,6 @@ def test_mixed_turn_shows_tokens_and_tool(self) -> None:
548549
549550 def test_no_tools_unchanged (self ) -> None :
550551 """ASSISTANT_MESSAGE without toolRequests must behave as before."""
551- from copilot_usage .render_detail import _build_event_details
552-
553552 ev = SessionEvent (
554553 type = EventType .ASSISTANT_MESSAGE ,
555554 data = {
@@ -564,8 +563,6 @@ def test_no_tools_unchanged(self) -> None:
564563
565564 def test_truncation_applied_to_long_tool_list (self ) -> None :
566565 """When the joined tool names exceed 60 chars, truncation applies."""
567- from copilot_usage .render_detail import _build_event_details
568-
569566 long_names = [
570567 {"name" : f"very_long_tool_name_{ i } " , "toolCallId" : f"t{ i } " }
571568 for i in range (10 )
@@ -584,8 +581,6 @@ def test_truncation_applied_to_long_tool_list(self) -> None:
584581
585582 def test_empty_names_show_unknown (self ) -> None :
586583 """toolRequests present but all names empty must show '(unknown)'."""
587- from copilot_usage .render_detail import _build_event_details
588-
589584 ev = SessionEvent (
590585 type = EventType .ASSISTANT_MESSAGE ,
591586 data = {
@@ -602,8 +597,6 @@ def test_empty_names_show_unknown(self) -> None:
602597
603598 def test_singular_label_based_on_displayed_names (self ) -> None :
604599 """When two toolRequests exist but only one has a name, use 'tool'."""
605- from copilot_usage .render_detail import _build_event_details
606-
607600 ev = SessionEvent (
608601 type = EventType .ASSISTANT_MESSAGE ,
609602 data = {
@@ -662,3 +655,154 @@ def test_multi_model_shutdown_via_full_render(self) -> None:
662655 row = next (line for line in output .splitlines () if "2025-01-01 01:00" in line )
663656 assert re .search (r"\b7\b" , row ) # total API requests = 3 + 4
664657 assert re .search (r"\b800\b" , row ) # total output tokens = 500 + 300
658+
659+
660+ # ---------------------------------------------------------------------------
661+ # _format_relative_time — direct unit tests (issue #879)
662+ # ---------------------------------------------------------------------------
663+
664+
665+ class TestFormatRelativeTime :
666+ """Direct unit tests covering all branches of _format_relative_time."""
667+
668+ def test_sub_hour_formats_as_m_ss (self ) -> None :
669+ """timedelta(minutes=4, seconds=7) → '+4:07'."""
670+ assert _format_relative_time (timedelta (minutes = 4 , seconds = 7 )) == "+4:07"
671+
672+ def test_over_hour_formats_as_h_mm_ss (self ) -> None :
673+ """timedelta(hours=1, minutes=2, seconds=3) → '+1:02:03'."""
674+ assert (
675+ _format_relative_time (timedelta (hours = 1 , minutes = 2 , seconds = 3 ))
676+ == "+1:02:03"
677+ )
678+
679+ def test_negative_delta_clamped_to_zero (self ) -> None :
680+ """Negative timedelta must clamp to '+0:00', never a negative string."""
681+ assert _format_relative_time (timedelta (seconds = - 10 )) == "+0:00"
682+
683+ def test_zero_delta (self ) -> None :
684+ """Zero timedelta → '+0:00'."""
685+ assert _format_relative_time (timedelta ()) == "+0:00"
686+
687+ def test_exactly_one_hour (self ) -> None :
688+ """Exactly 1h boundary triggers the hours branch."""
689+ assert _format_relative_time (timedelta (hours = 1 )) == "+1:00:00"
690+
691+
692+ # ---------------------------------------------------------------------------
693+ # _render_active_period — direct unit tests (issue #879)
694+ # ---------------------------------------------------------------------------
695+
696+
697+ class TestRenderActivePeriod :
698+ """Direct unit tests for _render_active_period covering active / inactive."""
699+
700+ def test_active_session_renders_panel (self ) -> None :
701+ """Active session must render an 'Active Period' panel with stats."""
702+ summary = SessionSummary (
703+ session_id = "active-test" ,
704+ is_active = True ,
705+ model_calls = 3 ,
706+ user_messages = 2 ,
707+ active_model_calls = 3 ,
708+ active_user_messages = 2 ,
709+ active_output_tokens = 1000 ,
710+ )
711+ buf , console = _buf_console ()
712+ _render_active_period (summary , target_console = console )
713+ output = _strip_ansi (buf .getvalue ())
714+ assert "Active Period" in output
715+ assert "3 model calls" in output
716+ assert "2 user messages" in output
717+
718+ def test_inactive_session_produces_no_output (self ) -> None :
719+ """Inactive session → returns immediately, no output."""
720+ summary = SessionSummary (session_id = "inactive-test" , is_active = False )
721+ buf , console = _buf_console ()
722+ _render_active_period (summary , target_console = console )
723+ assert buf .getvalue () == ""
724+
725+
726+ class TestRenderSessionDetailActivePeriod :
727+ """Integration test: render_session_detail with is_active=True must
728+ render the Active Period panel (issue #879)."""
729+
730+ def test_active_session_shows_active_period_panel (self ) -> None :
731+ """render_session_detail with is_active=True must include the
732+ Active Period panel in its output."""
733+ summary = SessionSummary (
734+ session_id = "active-e2e" ,
735+ start_time = datetime (2026 , 4 , 1 , 10 , 0 , 0 , tzinfo = UTC ),
736+ is_active = True ,
737+ model_calls = 5 ,
738+ user_messages = 3 ,
739+ active_model_calls = 5 ,
740+ active_user_messages = 3 ,
741+ active_output_tokens = 2000 ,
742+ )
743+ ev = SessionEvent (
744+ type = EventType .USER_MESSAGE ,
745+ timestamp = datetime (2026 , 4 , 1 , 10 , 5 , 0 , tzinfo = UTC ),
746+ data = {"content" : "hello" },
747+ )
748+ buf , console = _buf_console ()
749+ render_session_detail ([ev ], summary , target_console = console )
750+ output = _strip_ansi (buf .getvalue ())
751+ assert "Active Period" in output
752+
753+
754+ # ---------------------------------------------------------------------------
755+ # _event_type_label — parametrized unit tests (issue #879)
756+ # ---------------------------------------------------------------------------
757+
758+
759+ class TestEventTypeLabel :
760+ """Parametrized test for _event_type_label covering every labelled
761+ EventType case and the wildcard branch."""
762+
763+ @pytest .mark .parametrize (
764+ ("event_type" , "expected_text" ),
765+ [
766+ pytest .param (EventType .USER_MESSAGE , "user message" , id = "user-message" ),
767+ pytest .param (EventType .ASSISTANT_MESSAGE , "assistant" , id = "assistant" ),
768+ pytest .param (EventType .TOOL_EXECUTION_COMPLETE , "tool" , id = "tool-complete" ),
769+ pytest .param (EventType .TOOL_EXECUTION_START , "tool start" , id = "tool-start" ),
770+ pytest .param (EventType .ASSISTANT_TURN_START , "turn start" , id = "turn-start" ),
771+ pytest .param (EventType .ASSISTANT_TURN_END , "turn end" , id = "turn-end" ),
772+ pytest .param (EventType .SESSION_START , "session start" , id = "session-start" ),
773+ pytest .param (EventType .SESSION_SHUTDOWN , "session end" , id = "session-end" ),
774+ pytest .param (
775+ "UNKNOWN_FUTURE_TYPE" , "UNKNOWN_FUTURE_TYPE" , id = "wildcard-branch"
776+ ),
777+ ],
778+ )
779+ def test_label_text (self , event_type : str , expected_text : str ) -> None :
780+ """Label plain text must match the expected string."""
781+ label = _event_type_label (event_type )
782+ assert label .plain == expected_text
783+
784+
785+ # ---------------------------------------------------------------------------
786+ # _build_event_details — USER_MESSAGE branch (issue #879)
787+ # ---------------------------------------------------------------------------
788+
789+
790+ class TestBuildEventDetailsUserMessage :
791+ """Tests for the USER_MESSAGE branch of _build_event_details."""
792+
793+ def test_content_returned (self ) -> None :
794+ """Short content is returned as-is."""
795+ ev = SessionEvent (type = EventType .USER_MESSAGE , data = {"content" : "hello" })
796+ assert _build_event_details (ev ) == "hello"
797+
798+ def test_long_content_truncated (self ) -> None :
799+ """Content exceeding MAX_CONTENT_LEN must be truncated with '…'."""
800+ ev = SessionEvent (type = EventType .USER_MESSAGE , data = {"content" : "x" * 300 })
801+ detail = _build_event_details (ev )
802+ assert detail .endswith ("…" )
803+ assert len (detail ) <= MAX_CONTENT_LEN
804+
805+ def test_empty_content_returns_empty_string (self ) -> None :
806+ """Empty content → empty string."""
807+ ev = SessionEvent (type = EventType .USER_MESSAGE , data = {"content" : "" })
808+ assert _build_event_details (ev ) == ""
0 commit comments