482 lines
16 KiB
Python
482 lines
16 KiB
Python
"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from agentkit.calendar.db import get_event_tags, init_calendar_db
|
|
from agentkit.calendar.service import CalendarService
|
|
from agentkit.tools.calendar_tool import CalendarTool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def service(tmp_path: Path) -> CalendarService:
|
|
"""Provide a CalendarService backed by a temp DB."""
|
|
db_path = tmp_path / "test.db"
|
|
asyncio.run(init_calendar_db(db_path))
|
|
return CalendarService(db_path=db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def tool(service: CalendarService) -> CalendarTool:
|
|
return CalendarTool(calendar_service=service)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_event
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_event_action_returns_success(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""create_event returns success and persists the event with source='agent'."""
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Sprint Planning",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
description="Bi-weekly sprint planning",
|
|
location="Room A",
|
|
)
|
|
assert result["success"] is True
|
|
event_dict = result["event"]
|
|
assert event_dict["title"] == "Sprint Planning"
|
|
assert event_dict["source"] == "agent"
|
|
assert event_dict["user_id"] == "user-1"
|
|
|
|
# Verify persisted in DB
|
|
fetched = await service.get_event(event_dict["id"])
|
|
assert fetched is not None
|
|
assert fetched.title == "Sprint Planning"
|
|
assert fetched.source == "agent"
|
|
|
|
|
|
async def test_create_event_with_recurrence_sets_rrule(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""rrule param is stored correctly on the event."""
|
|
rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Weekly Standup",
|
|
start_time="2026-07-06T09:00:00+00:00",
|
|
end_time="2026-07-06T09:30:00+00:00",
|
|
rrule=rrule,
|
|
)
|
|
assert result["success"] is True
|
|
assert result["event"]["rrule"] == rrule
|
|
|
|
fetched = await service.get_event(result["event"]["id"])
|
|
assert fetched is not None
|
|
assert fetched.rrule == rrule
|
|
|
|
|
|
async def test_query_events_returns_list(tool: CalendarTool) -> None:
|
|
"""create 2 events, query, verify both returned."""
|
|
await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Event A",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
)
|
|
await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Event B",
|
|
start_time="2026-07-02T14:00:00+00:00",
|
|
end_time="2026-07-02T15:00:00+00:00",
|
|
)
|
|
|
|
result = await tool.execute(
|
|
action="query_events",
|
|
user_id="user-1",
|
|
)
|
|
assert result["success"] is True
|
|
events = result["events"]
|
|
assert len(events) == 2
|
|
titles = {e["title"] for e in events}
|
|
assert titles == {"Event A", "Event B"}
|
|
|
|
|
|
async def test_update_event_action_modifies_fields(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""create then update title; verify the field is modified."""
|
|
create_result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Original Title",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
)
|
|
assert create_result["success"] is True
|
|
event_id = create_result["event"]["id"]
|
|
|
|
update_result = await tool.execute(
|
|
action="update_event",
|
|
user_id="user-1",
|
|
event_id=event_id,
|
|
title="Updated Title",
|
|
)
|
|
assert update_result["success"] is True
|
|
|
|
fetched = await service.get_event(event_id)
|
|
assert fetched is not None
|
|
assert fetched.title == "Updated Title"
|
|
|
|
|
|
async def test_delete_event_action_removes_record(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""create then delete; verify the record is gone."""
|
|
create_result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="To Be Deleted",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
)
|
|
assert create_result["success"] is True
|
|
event_id = create_result["event"]["id"]
|
|
|
|
delete_result = await tool.execute(
|
|
action="delete_event",
|
|
user_id="user-1",
|
|
event_id=event_id,
|
|
)
|
|
assert delete_result["success"] is True
|
|
|
|
fetched = await service.get_event(event_id)
|
|
assert fetched is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# error paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_invalid_action_returns_error(tool: CalendarTool) -> None:
|
|
"""Unknown action returns success=False with error message."""
|
|
result = await tool.execute(
|
|
action="frobnicate",
|
|
user_id="user-1",
|
|
)
|
|
assert result["success"] is False
|
|
assert "Unknown action" in result["error"]
|
|
|
|
|
|
async def test_missing_required_field_returns_error(tool: CalendarTool) -> None:
|
|
"""create_event without title returns success=False."""
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
)
|
|
assert result["success"] is False
|
|
assert "Missing required field" in result["error"]
|
|
assert "title" in result["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# conversation_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_created_event_has_conversation_id(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""conversation_id is set from context when provided."""
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Chat-Initiated Event",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
conversation_id="conv-abc-123",
|
|
)
|
|
assert result["success"] is True
|
|
assert result["event"]["conversation_id"] == "conv-abc-123"
|
|
|
|
fetched = await service.get_event(result["event"]["id"])
|
|
assert fetched is not None
|
|
assert fetched.conversation_id == "conv-abc-123"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# event_type_name resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_event_with_event_type_name(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""create event with event_type_name='Meeting' creates the type and links it."""
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Strategy Sync",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
event_type_name="Meeting",
|
|
)
|
|
assert result["success"] is True
|
|
event_type_id = result["event"]["event_type_id"]
|
|
assert event_type_id is not None
|
|
|
|
# Verify the event type was created
|
|
types = await service.list_event_types("user-1")
|
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
|
assert len(meeting_types) == 1
|
|
assert meeting_types[0].id == event_type_id
|
|
|
|
# Verify the event references the type
|
|
fetched = await service.get_event(result["event"]["id"])
|
|
assert fetched is not None
|
|
assert fetched.event_type_id == event_type_id
|
|
|
|
|
|
async def test_create_event_reuses_existing_event_type(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""If event_type_name matches an existing type, it is reused (not duplicated)."""
|
|
# Pre-create the type
|
|
existing = await service.create_event_type("user-1", "Meeting")
|
|
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Second Meeting",
|
|
start_time="2026-07-02T10:00:00+00:00",
|
|
end_time="2026-07-02T11:00:00+00:00",
|
|
event_type_name="Meeting",
|
|
)
|
|
assert result["success"] is True
|
|
assert result["event"]["event_type_id"] == existing.id
|
|
|
|
# No duplicate type created
|
|
types = await service.list_event_types("user-1")
|
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
|
assert len(meeting_types) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tag_names resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_event_with_tag_names(
|
|
tool: CalendarTool, service: CalendarService, tmp_path: Path
|
|
) -> None:
|
|
"""create event with tag_names=['urgent', 'work'] creates tags and links them."""
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Urgent Work Task",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
tag_names=["urgent", "work"],
|
|
)
|
|
assert result["success"] is True
|
|
event_id = result["event"]["id"]
|
|
|
|
# Verify tags were created
|
|
tags = await service.list_tags("user-1")
|
|
tag_names = {t.name for t in tags}
|
|
assert "urgent" in tag_names
|
|
assert "work" in tag_names
|
|
|
|
# Verify tags are linked to the event
|
|
linked_tags = await get_event_tags(event_id, service.db_path)
|
|
linked_names = {t.name for t in linked_tags}
|
|
assert linked_names == {"urgent", "work"}
|
|
|
|
|
|
async def test_create_event_reuses_existing_tags(
|
|
tool: CalendarTool, service: CalendarService
|
|
) -> None:
|
|
"""If a tag name matches an existing tag, it is reused (not duplicated)."""
|
|
# Pre-create a tag
|
|
existing = await service.create_tag("user-1", "urgent")
|
|
|
|
result = await tool.execute(
|
|
action="create_event",
|
|
user_id="user-1",
|
|
title="Tagged Event",
|
|
start_time="2026-07-01T10:00:00+00:00",
|
|
end_time="2026-07-01T11:00:00+00:00",
|
|
tag_names=["urgent", "new-tag"],
|
|
)
|
|
assert result["success"] is True
|
|
|
|
# 'urgent' should not be duplicated, 'new-tag' should be created
|
|
tags = await service.list_tags("user-1")
|
|
urgent_tags = [t for t in tags if t.name == "urgent"]
|
|
assert len(urgent_tags) == 1
|
|
assert urgent_tags[0].id == existing.id
|
|
new_tags = [t for t in tags if t.name == "new-tag"]
|
|
assert len(new_tags) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tool registration / schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tool_name_and_schema(tool: CalendarTool) -> None:
|
|
"""Tool has correct name and input_schema."""
|
|
assert tool.name == "calendar"
|
|
schema = tool.input_schema
|
|
assert schema["type"] == "object"
|
|
assert "action" in schema["properties"]
|
|
assert "start_time" in schema["properties"]
|
|
assert schema["properties"]["action"]["enum"] == [
|
|
"create_event",
|
|
"query_events",
|
|
"update_event",
|
|
"delete_event",
|
|
]
|
|
assert "action" in schema["required"]
|
|
# user_id is no longer in the schema — it's resolved internally via
|
|
# _resolve_user_id (caller-provided or default_user_id fallback).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chinese relative date/time parsing (_resolve_datetime)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from datetime import datetime as _dt
|
|
|
|
from agentkit.tools.calendar_tool import _resolve_datetime
|
|
|
|
|
|
def _parse_iso(value: str | None) -> _dt | None:
|
|
"""Helper: parse _resolve_datetime output back to datetime."""
|
|
if value is None:
|
|
return None
|
|
return _dt.fromisoformat(value)
|
|
|
|
|
|
def test_resolve_iso8601_passthrough() -> None:
|
|
"""ISO 8601 strings are returned unchanged."""
|
|
iso = "2026-07-08T15:00:00+00:00"
|
|
assert _resolve_datetime(iso) == iso
|
|
|
|
|
|
def test_resolve_none_for_empty() -> None:
|
|
"""None/empty returns None."""
|
|
assert _resolve_datetime(None) is None
|
|
assert _resolve_datetime("") is None
|
|
assert _resolve_datetime(" ") is None
|
|
|
|
|
|
def test_resolve_chinese_next_week_weekday() -> None:
|
|
"""'下周三' resolves to next week's Wednesday."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("下周三"))
|
|
assert result is not None
|
|
# Should be Wednesday (weekday=2)
|
|
assert result.weekday() == 2
|
|
# Should be in the future, specifically next week
|
|
delta = (result.date() - now.date()).days
|
|
assert delta >= 7 # at least 7 days out (next week)
|
|
assert delta <= 13 # at most 13 days out
|
|
|
|
|
|
def test_resolve_chinese_next_week_with_time() -> None:
|
|
"""'下周三下午3点' resolves to next Wednesday at 15:00."""
|
|
result = _parse_iso(_resolve_datetime("下周三下午3点"))
|
|
assert result is not None
|
|
assert result.weekday() == 2
|
|
assert result.hour == 15
|
|
assert result.minute == 0
|
|
|
|
|
|
def test_resolve_chinese_tomorrow() -> None:
|
|
"""'明天' resolves to tomorrow."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("明天"))
|
|
assert result is not None
|
|
delta = (result.date() - now.date()).days
|
|
assert delta == 1
|
|
|
|
|
|
def test_resolve_chinese_day_after_tomorrow() -> None:
|
|
"""'后天' resolves to day after tomorrow."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("后天"))
|
|
assert result is not None
|
|
delta = (result.date() - now.date()).days
|
|
assert delta == 2
|
|
|
|
|
|
def test_resolve_chinese_n_days_later() -> None:
|
|
"""'3天后' resolves to 3 days from now."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("3天后"))
|
|
assert result is not None
|
|
delta = (result.date() - now.date()).days
|
|
assert delta == 3
|
|
|
|
|
|
def test_resolve_chinese_n_hours_later() -> None:
|
|
"""'2小时后' resolves to ~2 hours from now."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("2小时后"))
|
|
assert result is not None
|
|
delta_seconds = (result - now).total_seconds()
|
|
assert 7000 <= delta_seconds <= 7400 # ~2 hours (allow small tolerance)
|
|
|
|
|
|
def test_resolve_chinese_pm_time() -> None:
|
|
"""'下周三 15:00' (Arabic) resolves to 15:00."""
|
|
result = _parse_iso(_resolve_datetime("下周三 15:00"))
|
|
assert result is not None
|
|
assert result.weekday() == 2
|
|
assert result.hour == 15
|
|
|
|
|
|
def test_resolve_chinese_this_week() -> None:
|
|
"""'这周五' resolves to this week's Friday."""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("这周五"))
|
|
assert result is not None
|
|
assert result.weekday() == 4 # Friday
|
|
# Should be this week or next if already passed
|
|
delta = (result.date() - now.date()).days
|
|
assert 0 <= delta <= 7
|
|
|
|
|
|
def test_resolve_chinese_unparseable_returns_none() -> None:
|
|
"""Chinese text with no recognizable date pattern returns None (not a wrong date)."""
|
|
assert _resolve_datetime("某个时候") is None
|
|
assert _resolve_datetime("时间待定") is None
|
|
assert _resolve_datetime("尽快") is None
|
|
|
|
|
|
def test_resolve_chinese_does_not_fall_through_to_dateutil() -> None:
|
|
"""Chinese+number mix must NOT fall through to dateutil fuzzy (would produce wrong date).
|
|
|
|
Before the fix, '下周三 3pm' would be parsed by dateutil fuzzy as today's
|
|
date at 15:00 (silently dropping the Chinese). Now it returns the correct
|
|
next-Wednesday date.
|
|
"""
|
|
now = _dt.now().astimezone()
|
|
result = _parse_iso(_resolve_datetime("下周三 3pm"))
|
|
assert result is not None
|
|
assert result.weekday() == 2 # Wednesday, not today's weekday
|
|
# The date should NOT be today (the old bug produced today's date)
|
|
assert result.date() != now.date()
|