perf:优化webui交互体验,优化统计逻辑,优化log展示
This commit is contained in:
332
pytests/webui/test_statistics_service.py
Normal file
332
pytests/webui/test_statistics_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services import statistics_service
|
||||
from src.webui.schemas.statistics import DashboardData, StatisticsSummary, TimeSeriesData
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, *, first_value: Any = None, all_values: list[Any] | None = None) -> None:
|
||||
self._first_value = first_value
|
||||
self._all_values = all_values or []
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._first_value
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return self._all_values
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, results: list[_Result]) -> None:
|
||||
self._results = results
|
||||
|
||||
def exec(self, statement: Any) -> _Result:
|
||||
del statement
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
class _MemoryStore:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, Any] = {}
|
||||
|
||||
def __getitem__(self, item: str) -> Any:
|
||||
return self.store.get(item)
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self.store[key] = value
|
||||
|
||||
|
||||
def _patch_session_results(monkeypatch: pytest.MonkeyPatch, results: list[_Result]) -> list[bool]:
|
||||
auto_commit_calls: list[bool] = []
|
||||
|
||||
@contextmanager
|
||||
def _fake_get_db_session(auto_commit: bool = True) -> Iterator[_Session]:
|
||||
auto_commit_calls.append(auto_commit)
|
||||
yield _Session([results.pop(0)])
|
||||
|
||||
monkeypatch.setattr(statistics_service, "get_db_session", _fake_get_db_session)
|
||||
return auto_commit_calls
|
||||
|
||||
|
||||
def _patch_session_result_group(monkeypatch: pytest.MonkeyPatch, results: list[_Result]) -> list[bool]:
|
||||
auto_commit_calls: list[bool] = []
|
||||
|
||||
@contextmanager
|
||||
def _fake_get_db_session(auto_commit: bool = True) -> Iterator[_Session]:
|
||||
auto_commit_calls.append(auto_commit)
|
||||
yield _Session(results)
|
||||
|
||||
monkeypatch.setattr(statistics_service, "get_db_session", _fake_get_db_session)
|
||||
return auto_commit_calls
|
||||
|
||||
|
||||
def _build_dashboard_data(total_requests: int = 1) -> DashboardData:
|
||||
return DashboardData(
|
||||
summary=StatisticsSummary(total_requests=total_requests),
|
||||
model_stats=[],
|
||||
hourly_data=[],
|
||||
daily_data=[],
|
||||
recent_activity=[],
|
||||
)
|
||||
|
||||
|
||||
def _build_dashboard_data_with_time_series() -> DashboardData:
|
||||
return DashboardData(
|
||||
summary=StatisticsSummary(total_requests=1),
|
||||
model_stats=[],
|
||||
hourly_data=[
|
||||
TimeSeriesData(timestamp="2026-05-06T10:00:00", requests=0, cost=0.0, tokens=0),
|
||||
TimeSeriesData(timestamp="2026-05-06T11:00:00", requests=2, cost=0.5, tokens=50),
|
||||
TimeSeriesData(timestamp="2026-05-06T12:00:00", requests=0, cost=0.0, tokens=0),
|
||||
],
|
||||
daily_data=[
|
||||
TimeSeriesData(timestamp="2026-05-05T00:00:00", requests=0, cost=0.0, tokens=0),
|
||||
TimeSeriesData(timestamp="2026-05-06T00:00:00", requests=3, cost=0.7, tokens=70),
|
||||
],
|
||||
recent_activity=[],
|
||||
)
|
||||
|
||||
|
||||
def test_shared_fetch_queries_disable_auto_commit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
now = datetime(2026, 5, 6, 12, 0, 0)
|
||||
online_record = SimpleNamespace(start_timestamp=now - timedelta(minutes=5), end_timestamp=now)
|
||||
usage_record = SimpleNamespace(
|
||||
timestamp=now,
|
||||
request_type="chat.reply",
|
||||
model_api_provider_name="provider",
|
||||
model_assign_name="chat-main",
|
||||
model_name="gpt-a",
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
cost=0.01,
|
||||
time_cost=1.2,
|
||||
)
|
||||
message_record = SimpleNamespace(timestamp=now, message_id="msg-1")
|
||||
tool_record = SimpleNamespace(timestamp=now, tool_name="reply")
|
||||
auto_commit_calls = _patch_session_results(
|
||||
monkeypatch,
|
||||
[
|
||||
_Result(all_values=[online_record]),
|
||||
_Result(all_values=[usage_record]),
|
||||
_Result(all_values=[message_record]),
|
||||
_Result(all_values=[tool_record]),
|
||||
],
|
||||
)
|
||||
|
||||
online_ranges = statistics_service.fetch_online_time_since(now - timedelta(hours=1))
|
||||
usage_records = statistics_service.fetch_model_usage_since(now - timedelta(hours=1))
|
||||
messages = statistics_service.fetch_messages_since(now - timedelta(hours=1))
|
||||
tool_records = statistics_service.fetch_tool_records_since(now - timedelta(hours=1))
|
||||
|
||||
assert online_ranges == [(online_record.start_timestamp, online_record.end_timestamp)]
|
||||
assert usage_records == [
|
||||
{
|
||||
"timestamp": now,
|
||||
"request_type": "chat.reply",
|
||||
"model_api_provider_name": "provider",
|
||||
"model_assign_name": "chat-main",
|
||||
"model_name": "gpt-a",
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"cost": 0.01,
|
||||
"time_cost": 1.2,
|
||||
}
|
||||
]
|
||||
assert messages == [message_record]
|
||||
assert tool_records == [tool_record]
|
||||
assert auto_commit_calls == [False, False, False, False]
|
||||
|
||||
|
||||
def test_get_earliest_statistics_time_uses_min_valid_timestamp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fallback_time = datetime(2026, 5, 6, 12, 0, 0)
|
||||
earliest_time = datetime(2026, 5, 1, 8, 30, 0)
|
||||
auto_commit_calls = _patch_session_result_group(
|
||||
monkeypatch,
|
||||
[
|
||||
_Result(first_value=datetime(2026, 5, 3, 9, 0, 0)),
|
||||
_Result(first_value=earliest_time),
|
||||
_Result(first_value=None),
|
||||
_Result(first_value=datetime(2026, 5, 2, 9, 0, 0)),
|
||||
],
|
||||
)
|
||||
|
||||
result = statistics_service.get_earliest_statistics_time(fallback_time)
|
||||
|
||||
assert result == earliest_time
|
||||
assert auto_commit_calls == [False]
|
||||
|
||||
|
||||
def test_get_earliest_statistics_time_falls_back_when_query_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fallback_time = datetime(2026, 5, 6, 12, 0, 0)
|
||||
|
||||
@contextmanager
|
||||
def _fake_get_db_session(auto_commit: bool = True) -> Iterator[_Session]:
|
||||
del auto_commit
|
||||
raise RuntimeError("database unavailable")
|
||||
yield _Session([])
|
||||
|
||||
monkeypatch.setattr(statistics_service, "get_db_session", _fake_get_db_session)
|
||||
|
||||
assert statistics_service.get_earliest_statistics_time(fallback_time) == fallback_time
|
||||
|
||||
|
||||
def test_dashboard_statistics_cache_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
memory_store = _MemoryStore()
|
||||
now = datetime.now()
|
||||
dashboard_data = _build_dashboard_data(total_requests=7)
|
||||
monkeypatch.setattr(statistics_service, "local_storage", memory_store)
|
||||
|
||||
statistics_service.store_dashboard_statistics_cache({24: dashboard_data}, generated_at=now)
|
||||
cached_data = statistics_service.get_cached_dashboard_statistics(24)
|
||||
|
||||
assert cached_data is not None
|
||||
assert cached_data.summary.total_requests == 7
|
||||
|
||||
|
||||
def test_dashboard_statistics_cache_stores_sparse_time_series(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
memory_store = _MemoryStore()
|
||||
generated_at = datetime(2026, 5, 6, 12, 0, 0)
|
||||
dashboard_data = _build_dashboard_data_with_time_series()
|
||||
monkeypatch.setattr(statistics_service, "local_storage", memory_store)
|
||||
|
||||
statistics_service.store_dashboard_statistics_cache({2: dashboard_data}, generated_at=generated_at)
|
||||
|
||||
raw_cache = memory_store[statistics_service.DASHBOARD_STATISTICS_CACHE_KEY]
|
||||
raw_entry = raw_cache["entries"]["2"]
|
||||
assert raw_entry["sparse"] is True
|
||||
assert raw_entry["hourly_data"] == [
|
||||
{"timestamp": "2026-05-06T11:00:00", "requests": 2, "cost": 0.5, "tokens": 50}
|
||||
]
|
||||
assert raw_entry["daily_data"] == [
|
||||
{"timestamp": "2026-05-06T00:00:00", "requests": 3, "cost": 0.7, "tokens": 70}
|
||||
]
|
||||
|
||||
cached_data = statistics_service.get_cached_dashboard_statistics(2, max_age_seconds=10**9)
|
||||
assert cached_data is not None
|
||||
assert [item.timestamp for item in cached_data.hourly_data] == [
|
||||
"2026-05-06T10:00:00",
|
||||
"2026-05-06T11:00:00",
|
||||
"2026-05-06T12:00:00",
|
||||
]
|
||||
assert cached_data.hourly_data[0].requests == 0
|
||||
assert cached_data.hourly_data[1].requests == 2
|
||||
assert cached_data.hourly_data[2].requests == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_statistics_prefers_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
memory_store = _MemoryStore()
|
||||
dashboard_data = _build_dashboard_data(total_requests=9)
|
||||
monkeypatch.setattr(statistics_service, "local_storage", memory_store)
|
||||
statistics_service.store_dashboard_statistics_cache({24: dashboard_data}, generated_at=datetime.now())
|
||||
|
||||
async def _fail_compute_dashboard_statistics(hours: int = 24) -> DashboardData:
|
||||
del hours
|
||||
raise AssertionError("cache should be used")
|
||||
|
||||
monkeypatch.setattr(statistics_service, "compute_dashboard_statistics", _fail_compute_dashboard_statistics)
|
||||
|
||||
result = await statistics_service.get_dashboard_statistics(24)
|
||||
|
||||
assert result.summary.total_requests == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_statistics_returns_empty_when_cache_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
memory_store = _MemoryStore()
|
||||
monkeypatch.setattr(statistics_service, "local_storage", memory_store)
|
||||
|
||||
async def _fail_compute_dashboard_statistics(hours: int = 24) -> DashboardData:
|
||||
del hours
|
||||
raise AssertionError("dashboard API should not compute fallback data")
|
||||
|
||||
monkeypatch.setattr(statistics_service, "compute_dashboard_statistics", _fail_compute_dashboard_statistics)
|
||||
|
||||
result = await statistics_service.get_dashboard_statistics(24)
|
||||
|
||||
assert result.summary.total_requests == 0
|
||||
assert result.model_stats == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary_statistics_aggregates_database_and_message_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
start_time = datetime(2026, 5, 6, 10, 0, 0)
|
||||
end_time = datetime(2026, 5, 6, 12, 0, 0)
|
||||
online_records = [
|
||||
SimpleNamespace(
|
||||
start_timestamp=start_time - timedelta(minutes=30),
|
||||
end_timestamp=start_time + timedelta(minutes=30),
|
||||
),
|
||||
SimpleNamespace(
|
||||
start_timestamp=start_time + timedelta(hours=1),
|
||||
end_timestamp=end_time + timedelta(minutes=30),
|
||||
),
|
||||
]
|
||||
auto_commit_calls = _patch_session_results(
|
||||
monkeypatch,
|
||||
[
|
||||
_Result(first_value=(3, 1.5, 900, 2.5)),
|
||||
_Result(all_values=online_records),
|
||||
],
|
||||
)
|
||||
|
||||
def _fake_count_messages(**kwargs: Any) -> int:
|
||||
return 5 if kwargs.get("has_reply_to") is None else 2
|
||||
|
||||
monkeypatch.setattr(statistics_service, "count_messages", _fake_count_messages)
|
||||
|
||||
summary = await statistics_service.get_summary_statistics(start_time, end_time)
|
||||
|
||||
assert summary.total_requests == 3
|
||||
assert summary.total_cost == 1.5
|
||||
assert summary.total_tokens == 900
|
||||
assert summary.avg_response_time == 2.5
|
||||
assert summary.online_time == 5400
|
||||
assert summary.total_messages == 5
|
||||
assert summary.total_replies == 2
|
||||
assert summary.cost_per_hour == pytest.approx(1.0)
|
||||
assert summary.tokens_per_hour == pytest.approx(600.0)
|
||||
assert auto_commit_calls == [False, False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_model_statistics_groups_by_display_model_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
now = datetime(2026, 5, 6, 12, 0, 0)
|
||||
records = [
|
||||
SimpleNamespace(
|
||||
model_assign_name="chat-main",
|
||||
model_name="gpt-a",
|
||||
cost=0.4,
|
||||
total_tokens=100,
|
||||
time_cost=2.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
model_assign_name="chat-main",
|
||||
model_name="gpt-a",
|
||||
cost=0.6,
|
||||
total_tokens=200,
|
||||
time_cost=4.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
model_assign_name=None,
|
||||
model_name="gpt-b",
|
||||
cost=0.2,
|
||||
total_tokens=50,
|
||||
time_cost=0.0,
|
||||
),
|
||||
]
|
||||
_patch_session_results(monkeypatch, [_Result(all_values=records)])
|
||||
|
||||
stats = await statistics_service.get_model_statistics(now - timedelta(hours=24))
|
||||
|
||||
assert [item.model_name for item in stats] == ["chat-main", "gpt-b"]
|
||||
assert stats[0].request_count == 2
|
||||
assert stats[0].total_cost == pytest.approx(1.0)
|
||||
assert stats[0].total_tokens == 300
|
||||
assert stats[0].avg_response_time == pytest.approx(3.0)
|
||||
assert stats[1].avg_response_time == 0.0
|
||||
13
pytests/webui/test_system_routes.py
Normal file
13
pytests/webui/test_system_routes.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from src.webui.routers import system
|
||||
|
||||
|
||||
def test_is_newer_version_detects_patch_update() -> None:
|
||||
assert system._is_newer_version("1.0.7", "1.0.6") is True
|
||||
|
||||
|
||||
def test_is_newer_version_ignores_same_version_with_shorter_parts() -> None:
|
||||
assert system._is_newer_version("1.0.0", "1.0") is False
|
||||
|
||||
|
||||
def test_is_newer_version_handles_unknown_current_version() -> None:
|
||||
assert system._is_newer_version("1.0.7", "unknown") is False
|
||||
Reference in New Issue
Block a user