ForcePilot/backend/test/unit/scheduler/use_cases/test_mappers.py

503 lines
16 KiB
Python
Raw Normal View History

"""领域模型(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"