ForcePilot/backend/test/unit/scheduler/use_cases/test_mappers.py
Kris 9e68234c20 test: 新增 scheduler 限界上下文全量单元测试
新增覆盖以下模块的单元测试:
1. 核心契约与校验函数
2. 持久化层工作单元与ORM映射
3. 运行时处理器注册表
4. 业务用例工具类(退避、抖动、Cron计算)
5. 基础设施容器装配
6. 框架内置清理处理器(运行日志、幂等记录)
7. 测试共享Fixture与桩实现
2026-06-22 21:16:16 +08:00

503 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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