"""领域模型(dataclass) ↔ Output DTO(Pydantic) 转换单元测试。 覆盖 ``yuxi.scheduler.use_cases.mappers`` 模块的全部公开函数: - ``to_task_output`` / ``to_run_log_output``:dataclass → Output DTO - ``to_handler_summary_output`` / ``to_list_handler_summary_output`` - ``to_count_by_status_output``:状态分布字典 → DTO - ``to_get_health_output``:健康检查参数 → DTO - ``daily_stat_to_dict`` / ``to_daily_stat_output`` / ``to_list_daily_stats_output`` - ``to_list_upcoming_output`` """ from __future__ import annotations from datetime import datetime import pytest from yuxi.scheduler.core.models import ( DailyStat, HandlerSummary, ScheduledTask, ScheduledTaskRunLog, ) from yuxi.scheduler.use_cases.mappers import ( daily_stat_to_dict, to_count_by_status_output, to_daily_stat_output, to_get_health_output, to_handler_summary_output, to_list_daily_stats_output, to_list_handler_summary_output, to_list_upcoming_output, to_run_log_output, to_task_output, ) pytestmark = pytest.mark.unit # ─── to_task_output ─────────────────────────────────────────────────────── @pytest.mark.unit class TestToTaskOutput: def test_basic_mapping(self): # Arrange task = ScheduledTask( task_id="task-001", handler_name="my_handler", owner_scope="system", owner_id="test:1", schedule_kind="cron", cron_expression="*/15 * * * *", tz="Asia/Shanghai", payload={"k": "v"}, enabled=True, stagger_seconds=30, status="active", next_run_at=datetime(2024, 1, 1, 0, 15, 0), id=1, ) # Act output = to_task_output(task) # Assert assert output.task_id == "task-001" assert output.handler_name == "my_handler" assert output.owner_scope == "system" assert output.schedule_kind == "cron" assert output.cron_expression == "*/15 * * * *" assert output.payload == {"k": "v"} assert output.stagger_seconds == 30 assert output.status == "active" assert output.id == 1 def test_datetime_fields_formatted_as_iso(self): # Arrange - naive datetime 按 UTC 处理(与 DB 存储约定一致) next_run = datetime(2024, 1, 1, 0, 15, 0) task = ScheduledTask( task_id="t1", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron", next_run_at=next_run, ) # Act output = to_task_output(task) # Assert - DateTime 字段格式化为 ISO 8601 字符串,naive UTC 不偏移 assert isinstance(output.next_run_at, str) assert output.next_run_at == "2024-01-01T00:15:00Z" def test_none_datetime_fields_return_none(self): # Arrange task = ScheduledTask( task_id="t1", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron", ) # Act output = to_task_output(task) # Assert assert output.next_run_at is None assert output.last_run_at is None assert output.run_at is None assert output.created_at is None assert output.updated_at is None assert output.deleted_at is None def test_none_payload_returns_empty_dict(self): # Arrange - dataclass payload 为 None 时 mapper 兜底为空 dict task = ScheduledTask( task_id="t1", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron", payload=None, # type: ignore[arg-type] ) # Act output = to_task_output(task) # Assert assert output.payload == {} # ─── to_run_log_output ──────────────────────────────────────────────────── @pytest.mark.unit class TestToRunLogOutput: def test_basic_mapping(self): # Arrange log = ScheduledTaskRunLog( task_id="task-001", run_id="run-001", triggered_by="auto", status="success", output={"rows": 10}, started_at=datetime(2024, 1, 1, 0, 15, 0), finished_at=datetime(2024, 1, 1, 0, 15, 5), id=1, ) # Act output = to_run_log_output(log) # Assert assert output.task_id == "task-001" assert output.run_id == "run-001" assert output.triggered_by == "auto" assert output.status == "success" assert output.output == {"rows": 10} assert output.id == 1 def test_datetime_fields_formatted_as_iso(self): # Arrange - naive datetime 按 UTC 处理(与 DB 存储约定一致) started = datetime(2024, 1, 1, 0, 15, 0) log = ScheduledTaskRunLog( task_id="t", run_id="r", started_at=started, ) # Act output = to_run_log_output(log) # Assert - naive UTC 不偏移 assert isinstance(output.started_at, str) assert output.started_at == "2024-01-01T00:15:00Z" def test_none_datetime_fields_return_none(self): log = ScheduledTaskRunLog(task_id="t", run_id="r") output = to_run_log_output(log) assert output.started_at is None assert output.finished_at is None def test_none_output_returns_none(self): # Arrange - output 为 None 时 mapper 透传 None log = ScheduledTaskRunLog(task_id="t", run_id="r", output=None) # Act output = to_run_log_output(log) # Assert assert output.output is None def test_none_error_message_returns_none(self): # Arrange - error_message 为 None 时 mapper 透传 None log = ScheduledTaskRunLog(task_id="t", run_id="r", error_message=None) # Act output = to_run_log_output(log) # Assert assert output.error_message is None def test_all_fields_mapped(self): # Arrange - 验证全字段映射 log = ScheduledTaskRunLog( task_id="t1", run_id="r1", triggered_by="manual", status="failure", error_message="boom", output={"error": True}, started_at=datetime(2024, 1, 1, 0, 15, 0), finished_at=datetime(2024, 1, 1, 0, 15, 5), created_by="admin", updated_by="operator", id=42, created_at=datetime(2024, 1, 1, 0, 0, 0), updated_at=datetime(2024, 1, 1, 0, 0, 1), is_deleted=0, deleted_at=None, ) # Act output = to_run_log_output(log) # Assert assert output.task_id == "t1" assert output.run_id == "r1" assert output.triggered_by == "manual" assert output.status == "failure" assert output.error_message == "boom" assert output.output == {"error": True} assert output.created_by == "admin" assert output.updated_by == "operator" assert output.id == 42 assert output.is_deleted == 0 assert output.deleted_at is None # ─── to_handler_summary_output ──────────────────────────────────────────── @pytest.mark.unit class TestToHandlerSummaryOutput: def test_basic_mapping(self): # Arrange summary = HandlerSummary( name="my_handler", task_count=5, last_active_at=datetime(2024, 1, 1, 0, 0, 0), ) # Act output = to_handler_summary_output(summary) # Assert assert output.handler_name == "my_handler" assert output.task_count == 5 assert isinstance(output.last_active_at, str) def test_none_last_active_at(self): summary = HandlerSummary(name="h", task_count=0, last_active_at=None) output = to_handler_summary_output(summary) assert output.last_active_at is None def test_description_defaults_empty(self): # Arrange - description 由调用方按需补充,mapper 不聚合 summary = HandlerSummary(name="h", task_count=0) # Act output = to_handler_summary_output(summary) # Assert assert output.description == "" def test_active_task_count_defaults_zero(self): summary = HandlerSummary(name="h", task_count=0) output = to_handler_summary_output(summary) assert output.active_task_count == 0 # ─── to_list_handler_summary_output ─────────────────────────────────────── @pytest.mark.unit class TestToListHandlerSummaryOutput: def test_empty_list(self): output = to_list_handler_summary_output([]) assert output.items == [] def test_multiple_items(self): summaries = [ HandlerSummary(name="h1", task_count=1), HandlerSummary(name="h2", task_count=2), ] output = to_list_handler_summary_output(summaries) assert len(output.items) == 2 assert output.items[0].handler_name == "h1" assert output.items[1].handler_name == "h2" # ─── to_count_by_status_output ──────────────────────────────────────────── @pytest.mark.unit class TestToCountByStatusOutput: def test_all_statuses_present(self): # Arrange dist = {"active": 10, "paused": 5, "dead_letter": 2} # Act output = to_count_by_status_output(dist) # Assert assert output.active == 10 assert output.paused == 5 assert output.dead_letter == 2 def test_missing_statuses_default_zero(self): # Arrange - 仓储层未出现的状态不在字典中 dist = {"active": 10} # Act output = to_count_by_status_output(dist) # Assert assert output.active == 10 assert output.paused == 0 assert output.dead_letter == 0 def test_empty_dict(self): output = to_count_by_status_output({}) assert output.active == 0 assert output.paused == 0 assert output.dead_letter == 0 # ─── to_get_health_output ───────────────────────────────────────────────── @pytest.mark.unit class TestToGetHealthOutput: def test_healthy_status(self): # Arrange last_tick = datetime(2024, 1, 1, 0, 0, 0) # Act output = to_get_health_output( last_tick_at=last_tick, active_task_count=10, dead_letter_count=2, running_count=3, healthy=True, ) # Assert assert output.status == "healthy" assert output.active_task_count == 10 assert output.dead_letter_count == 2 assert output.running_count == 3 assert isinstance(output.last_tick_at, str) def test_unhealthy_status(self): output = to_get_health_output( last_tick_at=None, active_task_count=0, dead_letter_count=0, running_count=0, healthy=False, ) assert output.status == "unhealthy" assert output.last_tick_at is None # ─── daily_stat_to_dict ─────────────────────────────────────────────────── @pytest.mark.unit class TestDailyStatToDict: def test_basic_mapping(self): # Arrange stat = DailyStat( stat_date="2024-01-01", handler_name="h", success_count=10, failure_count=2, timeout_count=1, dead_letter_count=0, ) # Act result = daily_stat_to_dict(stat) # Assert assert result["stat_date"] == "2024-01-01" assert result["handler_name"] == "h" assert result["success_count"] == 10 assert result["failure_count"] == 2 assert result["timeout_count"] == 1 assert result["dead_letter_count"] == 0 # ─── to_daily_stat_output ───────────────────────────────────────────────── @pytest.mark.unit class TestToDailyStatOutput: def test_basic_mapping_with_derived_fields(self): # Arrange stat = DailyStat( stat_date=datetime(2024, 1, 1).date(), handler_name="h", success_count=10, failure_count=2, timeout_count=1, dead_letter_count=0, ) # Act output = to_daily_stat_output(stat) # Assert assert output.stat_date == "2024-01-01" assert output.handler_name == "h" assert output.success_count == 10 assert output.failure_count == 2 assert output.timeout_count == 1 assert output.dead_letter_count == 0 # 派生字段:total = 10 + 2 + 1 = 13 assert output.total_count == 13 # success_rate = 10 / 13 assert output.success_rate == pytest.approx(10 / 13) def test_zero_total_count_success_rate_zero(self): # Arrange - 所有计数为 0 stat = DailyStat( stat_date=datetime(2024, 1, 1).date(), handler_name="h", success_count=0, failure_count=0, timeout_count=0, dead_letter_count=0, ) # Act output = to_daily_stat_output(stat) # Assert assert output.total_count == 0 assert output.success_rate == 0.0 def test_dead_letter_count_excluded_from_total(self): # Arrange - dead_letter_count 不计入 total_count stat = DailyStat( stat_date=datetime(2024, 1, 1).date(), handler_name="h", success_count=5, failure_count=2, timeout_count=1, dead_letter_count=3, ) # Act output = to_daily_stat_output(stat) # Assert - total = 5 + 2 + 1 = 8(不含 dead_letter) assert output.total_count == 8 assert output.success_rate == pytest.approx(5 / 8) # ─── to_list_daily_stats_output ─────────────────────────────────────────── @pytest.mark.unit class TestToListDailyStatsOutput: def test_empty_list(self): output = to_list_daily_stats_output([]) assert output.items == [] def test_multiple_items(self): stats = [ DailyStat( stat_date=datetime(2024, 1, 1).date(), handler_name="h1", success_count=1, failure_count=0, timeout_count=0, dead_letter_count=0, ), DailyStat( stat_date=datetime(2024, 1, 2).date(), handler_name="h2", success_count=2, failure_count=0, timeout_count=0, dead_letter_count=0, ), ] output = to_list_daily_stats_output(stats) assert len(output.items) == 2 assert output.items[0].handler_name == "h1" assert output.items[1].handler_name == "h2" # ─── to_list_upcoming_output ────────────────────────────────────────────── @pytest.mark.unit class TestToListUpcomingOutput: def test_empty_list(self): output = to_list_upcoming_output([]) assert output.items == [] def test_multiple_tasks(self): tasks = [ ScheduledTask( task_id="t1", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron", ), ScheduledTask( task_id="t2", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron", ), ] output = to_list_upcoming_output(tasks) assert len(output.items) == 2 assert output.items[0].task_id == "t1" assert output.items[1].task_id == "t2"