diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 1103f07..b16543b 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -518,3 +518,71 @@ def test_start_observer_returns_running_observer(tmp_path: Path) -> None: assert observer.is_alive() # type: ignore[union-attr] finally: _stop_observer(observer) # pyright: ignore[reportUnknownArgumentType] + + +# --------------------------------------------------------------------------- +# _stop_observer(None) guard +# --------------------------------------------------------------------------- + + +def test_stop_observer_none_is_noop() -> None: + """_stop_observer(None) returns silently without raising.""" + _stop_observer(None) # should not raise + + +# --------------------------------------------------------------------------- +# _FileChangeHandler tests +# --------------------------------------------------------------------------- + + +class TestFileChangeHandler: + """Tests for _FileChangeHandler debounce logic.""" + + def test_dispatch_sets_event_on_first_call(self) -> None: + """First dispatch call within a cold window sets the change_event.""" + from copilot_usage.cli import ( + _FileChangeHandler, # pyright: ignore[reportPrivateUsage] + ) + + event = threading.Event() + handler = _FileChangeHandler(event) + handler.dispatch(object()) + assert event.is_set() + + def test_dispatch_suppresses_within_debounce_window(self) -> None: + """Second dispatch call within 2 s is suppressed (debounce).""" + import time as _time + + from copilot_usage.cli import ( + _FileChangeHandler, # pyright: ignore[reportPrivateUsage] + ) + + event = threading.Event() + handler = _FileChangeHandler(event) + handler.dispatch(object()) + assert event.is_set() + + # Clear and force _last_trigger to now so second call is within debounce + event.clear() + handler._last_trigger = _time.monotonic() # pyright: ignore[reportPrivateUsage] + handler.dispatch(object()) + assert not event.is_set() + + def test_dispatch_fires_again_after_debounce_gap(self) -> None: + """Dispatch fires again after > 2 s gap.""" + import time as _time + + from copilot_usage.cli import ( + _FileChangeHandler, # pyright: ignore[reportPrivateUsage] + ) + + event = threading.Event() + handler = _FileChangeHandler(event) + handler.dispatch(object()) + assert event.is_set() + + event.clear() + # Simulate passage of time by manipulating _last_trigger + handler._last_trigger = _time.monotonic() - 3.0 # pyright: ignore[reportPrivateUsage] + handler.dispatch(object()) + assert event.is_set() diff --git a/tests/copilot_usage/test_parser.py b/tests/copilot_usage/test_parser.py index a276b67..4578718 100644 --- a/tests/copilot_usage/test_parser.py +++ b/tests/copilot_usage/test_parser.py @@ -1826,6 +1826,39 @@ def test_config_without_model_key(self, tmp_path: Path) -> None: summary = build_session_summary(events, config_path=config) assert summary.model is None + def test_config_model_integer_returns_none(self, tmp_path: Path) -> None: + """config.json with {"model": 42} → model is None.""" + config = tmp_path / "config.json" + config.write_text('{"model": 42}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + + def test_config_model_null_returns_none(self, tmp_path: Path) -> None: + """config.json with {"model": null} → model is None.""" + config = tmp_path / "config.json" + config.write_text('{"model": null}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + + def test_config_model_list_returns_none(self, tmp_path: Path) -> None: + """config.json with {"model": []} → model is None.""" + config = tmp_path / "config.json" + config.write_text('{"model": []}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + # --------------------------------------------------------------------------- # build_session_summary — empty session (only session.start) diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py index a838f9c..a3d2f4e 100644 --- a/tests/copilot_usage/test_report.py +++ b/tests/copilot_usage/test_report.py @@ -208,6 +208,27 @@ def test_table_title_contains_active_indicator(self) -> None: output = _capture_output([session]) assert "Active Copilot Sessions" in output + def test_last_resume_time_used_over_start_time(self) -> None: + """When last_resume_time is set, running time is measured from it.""" + now = datetime.now(tz=UTC) + session = SessionSummary( + session_id="resume__12345678", + name="Resumed", + model="claude-sonnet-4", + is_active=True, + start_time=now - timedelta(days=2), + last_resume_time=now - timedelta(minutes=3), + user_messages=1, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + usage=TokenUsage(outputTokens=100), + ) + }, + ) + output = _capture_output([session]) + # Should show minutes (from last_resume_time), NOT days (from start_time) + assert "2d" not in output and "48h" not in output + # --------------------------------------------------------------------------- # Helpers for session detail tests @@ -1137,6 +1158,26 @@ def test_no_historical_data(self) -> None: output = _capture_full_summary([session]) assert "No historical shutdown data" in output + def test_active_section_uses_last_resume_time(self) -> None: + """Active section shows running time from last_resume_time, not start_time.""" + now = datetime.now(tz=UTC) + session = SessionSummary( + session_id="resu-5678-abcdef", + name="Resumed Session", + model="claude-sonnet-4", + start_time=now - timedelta(days=3), + last_resume_time=now - timedelta(minutes=2), + is_active=True, + user_messages=1, + model_calls=1, + active_model_calls=1, + active_user_messages=1, + active_output_tokens=200, + ) + output = _capture_full_summary([session]) + # Should show minutes (from last_resume_time), NOT days (from start_time) + assert "3d" not in output and "72h" not in output + # --------------------------------------------------------------------------- # render_cost_view capture helper