From 7c22264741b15cd3e68662974b1b6074aedc384c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:37:53 +0000 Subject: [PATCH 1/6] add utils coverage tests Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- tests/test_utils.py | 107 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..d07fcf0 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import numpy as np +import pandas as pd + +from backend import utils +from backend.config import settings + + +def test_ts_fmt_normalizes_to_utc() -> None: + naive = datetime(2026, 1, 2, 3, 4, 5) + aware = datetime(2026, 1, 2, 11, 4, 5, tzinfo=ZoneInfo("Asia/Shanghai")) + + assert utils.ts_fmt(naive) == "2026-01-02T03:04:05" + assert utils.ts_fmt(aware) == "2026-01-02T03:04:05" + + +def test_parse_db_iso_utc_handles_valid_invalid_and_empty() -> None: + assert utils.parse_db_iso_utc(None) is None + assert utils.parse_db_iso_utc("") is None + assert utils.parse_db_iso_utc("not-a-timestamp") is None + + parsed_naive = utils.parse_db_iso_utc("2026-01-02T03:04:05") + assert parsed_naive == datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + parsed_aware = utils.parse_db_iso_utc("2026-01-02T11:04:05+08:00") + assert parsed_aware == datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + +def test_app_timezone_uses_config_and_falls_back_to_utc(monkeypatch) -> None: + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Shanghai") + assert utils.app_timezone().key == "Asia/Shanghai" + + monkeypatch.setattr(settings, "TIMEZONE", "Invalid/Timezone") + assert utils.app_timezone().key == "UTC" + + +def test_now_helpers_are_timezone_aware(monkeypatch) -> None: + monkeypatch.setattr(settings, "TIMEZONE", "Europe/London") + + assert utils.now_utc().tzinfo == timezone.utc + assert utils.now_local().tzinfo is not None + + +def test_dataframe_to_json_safe_cleans_non_serializable_values() -> None: + df = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-02T03:04:05", None], utc=True), + "value": [1.5, np.inf], + "raw": [np.nan, "ok"], + "object_dt": [pd.Timestamp("2026-01-03T04:05:06Z"), pd.NA], + } + ) + + result = utils.dataframe_to_json_safe(df) + + assert result == [ + { + "timestamp": "2026-01-02T03:04:05", + "value": 1.5, + "raw": None, + "object_dt": "2026-01-03T04:05:06", + }, + { + "timestamp": None, + "value": None, + "raw": "ok", + "object_dt": None, + }, + ] + + +def test_dataframe_to_json_safe_empty_dataframe() -> None: + assert utils.dataframe_to_json_safe(pd.DataFrame()) == [] + + +def test_serialize_value_handles_numpy_datetime_scalars_and_arrays() -> None: + assert utils.serialize_value(np.int64(7)) == 7 + assert utils.serialize_value(np.float64(np.inf)) is None + assert utils.serialize_value(np.array([1, 2, 3])) == [1, 2, 3] + assert utils.serialize_value(pd.Timestamp("2026-01-02T03:04:05Z")) == "2026-01-02T03:04:05" + assert utils.serialize_value(np.datetime64("2026-01-02T03:04:05")) == "2026-01-02T03:04:05" + + +def test_clean_dict_for_json_recursively_serializes_nested_data() -> None: + data = { + "top": np.float64(1.25), + "nested": { + "when": pd.Timestamp("2026-01-02T03:04:05Z"), + "missing": np.nan, + }, + "items": [np.int64(2), np.float64(np.nan), np.array([3, 4])], + } + + cleaned = utils.clean_dict_for_json(data) + + assert cleaned == { + "top": 1.25, + "nested": { + "when": "2026-01-02T03:04:05", + "missing": None, + }, + "items": [2, None, [3, 4]], + } From 79a2a34c29e9ca5899d422e85c53e8d34b267320 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:38:07 +0000 Subject: [PATCH 2/6] add inbox queue coverage tests Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- tests/test_inbox_queue.py | 99 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_inbox_queue.py diff --git a/tests/test_inbox_queue.py b/tests/test_inbox_queue.py new file mode 100644 index 0000000..71427a0 --- /dev/null +++ b/tests/test_inbox_queue.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.messaging.base import MessageChannel, MessageEnvelope +from backend.messaging.inbox import InboxQueue, _debounce + + +def _msg( + message_id: str, + content: str, + *, + sender_id: str = "user-1", + channel: MessageChannel = MessageChannel.TELEGRAM, + seconds: int = 0, +) -> MessageEnvelope: + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + return MessageEnvelope( + message_id=message_id, + channel=channel, + sender_id=sender_id, + content=content, + timestamp=base + timedelta(seconds=seconds), + chat_id="chat-1", + ) + + +@pytest.mark.asyncio +async def test_push_and_pop_all_roundtrip() -> None: + queue = InboxQueue() + assert not queue.has_messages() + + await queue.push(_msg("1", "hello")) + assert queue.has_messages() + + drained = await queue.pop_all() + assert len(drained) == 1 + assert drained[0].content == "hello" + assert not queue.has_messages() + + +@pytest.mark.asyncio +async def test_push_drops_oldest_when_full() -> None: + queue = InboxQueue(maxsize=2) + + await queue.push(_msg("1", "first", sender_id="a")) + await queue.push(_msg("2", "second", sender_id="b")) + await queue.push(_msg("3", "third", sender_id="c")) + + drained = await queue.pop_all() + assert [m.message_id for m in drained] == ["2", "3"] + + +@pytest.mark.asyncio +async def test_pop_all_debounces_burst_from_same_sender() -> None: + queue = InboxQueue() + + await queue.push(_msg("1", "part-1", seconds=0)) + await queue.push(_msg("2", "part-2", seconds=3)) + await queue.push(_msg("3", "separate", seconds=9)) + + drained = await queue.pop_all() + assert len(drained) == 2 + assert drained[0].content == "part-1\npart-2" + assert drained[1].content == "separate" + + +@pytest.mark.asyncio +async def test_pop_all_does_not_merge_across_sender_or_channel() -> None: + queue = InboxQueue() + + await queue.push(_msg("1", "telegram-a", sender_id="same", channel=MessageChannel.TELEGRAM, seconds=0)) + await queue.push(_msg("2", "feishu-a", sender_id="same", channel=MessageChannel.FEISHU, seconds=1)) + await queue.push(_msg("3", "telegram-b", sender_id="other", channel=MessageChannel.TELEGRAM, seconds=2)) + + drained = await queue.pop_all() + assert [m.message_id for m in drained] == ["1", "2", "3"] + + +@pytest.mark.asyncio +async def test_wait_and_pop_all_drains_followup_messages() -> None: + queue = InboxQueue() + await queue.push(_msg("1", "first", sender_id="a")) + await queue.push(_msg("2", "second", sender_id="b")) + + with patch("backend.messaging.inbox.asyncio.sleep", new_callable=AsyncMock) as sleep_mock: + drained = await queue.wait_and_pop_all() + + sleep_mock.assert_awaited_once_with(0.3) + assert [m.message_id for m in drained] == ["1", "2"] + + +def test_debounce_returns_original_for_zero_or_one_message() -> None: + one = [_msg("1", "only")] + assert _debounce([]) == [] + assert _debounce(one) == one From fec4c69caef1511a0e8c246756cb2ef56cdc0798 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:39:33 +0000 Subject: [PATCH 3/6] expand coverage for utils and inbox queue Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- backend/utils.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index b5abca0..68f2732 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -144,16 +144,19 @@ def serialize_value(value: Any) -> Any: Returns: JSON-serializable value """ - if pd.isna(value): - return None + if isinstance(value, np.ndarray): + return value.tolist() elif isinstance(value, (pd.Timestamp, np.datetime64)): return ts_fmt(value.to_pydatetime()) if hasattr(value, 'to_pydatetime') else str(value) elif isinstance(value, (np.integer, np.floating)): if np.isnan(value) or np.isinf(value): return None return value.item() - elif isinstance(value, np.ndarray): - return value.tolist() + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass else: return value From 1a83465adeb5d2864d519b0a41f502ab536b8aec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:40:40 +0000 Subject: [PATCH 4/6] simplify serialize value fallback flow Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- backend/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index 68f2732..784832d 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -157,8 +157,7 @@ def serialize_value(value: Any) -> Any: return None except (TypeError, ValueError): pass - else: - return value + return value def clean_dict_for_json(data: dict) -> dict: From 2fc5480ce15f420b216665d7868391d4a730a37e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:41:48 +0000 Subject: [PATCH 5/6] address review feedback on coverage tests Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- backend/utils.py | 1 + tests/test_inbox_queue.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index 784832d..8c44c5c 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -153,6 +153,7 @@ def serialize_value(value: Any) -> Any: return None return value.item() try: + # Scalars work with pd.isna, but arrays/other objects can raise. if pd.isna(value): return None except (TypeError, ValueError): diff --git a/tests/test_inbox_queue.py b/tests/test_inbox_queue.py index 71427a0..8f42866 100644 --- a/tests/test_inbox_queue.py +++ b/tests/test_inbox_queue.py @@ -6,7 +6,7 @@ import pytest from backend.messaging.base import MessageChannel, MessageEnvelope -from backend.messaging.inbox import InboxQueue, _debounce +from backend.messaging.inbox import InboxQueue def _msg( @@ -93,7 +93,13 @@ async def test_wait_and_pop_all_drains_followup_messages() -> None: assert [m.message_id for m in drained] == ["1", "2"] -def test_debounce_returns_original_for_zero_or_one_message() -> None: - one = [_msg("1", "only")] - assert _debounce([]) == [] - assert _debounce(one) == one +@pytest.mark.asyncio +async def test_pop_all_handles_empty_and_single_message() -> None: + queue = InboxQueue() + assert await queue.pop_all() == [] + + msg = _msg("1", "only") + await queue.push(msg) + drained = await queue.pop_all() + assert len(drained) == 1 + assert drained[0] == msg From 5610c7662cb23e5d2783839288116c5662296e64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:42:56 +0000 Subject: [PATCH 6/6] refactor serialize value control flow Agent-Logs-Url: https://github.com/thinkwee/HiMe/sessions/12ef4c67-b97d-49c8-99de-b4afe7136ddc Co-authored-by: thinkwee <11889052+thinkwee@users.noreply.github.com> --- backend/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index 8c44c5c..10b9c4f 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -146,9 +146,9 @@ def serialize_value(value: Any) -> Any: """ if isinstance(value, np.ndarray): return value.tolist() - elif isinstance(value, (pd.Timestamp, np.datetime64)): + if isinstance(value, (pd.Timestamp, np.datetime64)): return ts_fmt(value.to_pydatetime()) if hasattr(value, 'to_pydatetime') else str(value) - elif isinstance(value, (np.integer, np.floating)): + if isinstance(value, (np.integer, np.floating)): if np.isnan(value) or np.isinf(value): return None return value.item()