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
68 changes: 68 additions & 0 deletions tests/copilot_usage/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
33 changes: 33 additions & 0 deletions tests/copilot_usage/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions tests/copilot_usage/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading