ForcePilot/backend/test/unit/scheduler/use_cases/test_mappers.py
Kris 08617091dc refactor: 整理项目包结构与导入路径
- 新增多个业务域的__init__.py模块文件,规范包导出结构
- 调整多个DTO文件的导入路径,统一模块组织方式
- 移除测试文件中多余的空行与导入语句
- 优化部分业务模块的包层级划分
2026-07-18 02:04:03 +08:00

742 lines
25 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_trigger_task_output``dataclass + run_id → TriggerTaskOutput 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``
- ``to_list_anomalies_output``:异常任务分组映射 → DTO
"""
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.dto.scheduler import SchedulerConfigOutput
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_anomalies_output,
to_list_daily_stats_output,
to_list_handler_summary_output,
to_list_upcoming_output,
to_run_log_output,
to_task_output,
to_trigger_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_trigger_task_output ───────────────────────────────────────────────
@pytest.mark.unit
class TestToTriggerTaskOutput:
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",
)
run_id = "run-abc"
# Act
output = to_trigger_task_output(task, run_id)
# Assert
assert output.run_id == "run-abc"
assert output.task.task_id == "task-001"
assert output.task.handler_name == "my_handler"
def test_task_snapshot_is_full_task_output(self):
# Arrange - task 字段为完整 TaskOutput包含所有映射字段
task = ScheduledTask(
task_id="t1",
handler_name="h",
owner_scope="business",
owner_id="o",
schedule_kind="at",
run_at=datetime(2024, 1, 1, 0, 0, 0),
status="active",
id=42,
)
# Act
output = to_trigger_task_output(task, "run-1")
# Assert
assert output.task.schedule_kind == "at"
assert output.task.run_at == "2024-01-01T00:00:00Z"
assert output.task.id == 42
# ─── 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",
handler_name="email_handler",
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.handler_name == "email_handler"
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
def test_duration_seconds_calculated_from_started_and_finished(self):
# Arrange - finished_at - started_at = 5.0s
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0),
finished_at=datetime(2024, 1, 1, 0, 15, 5),
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds == 5.0
def test_duration_seconds_none_when_finished_at_missing(self):
# Arrange - running 状态 finished_at 为 None
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0),
finished_at=None,
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds is None
def test_duration_seconds_with_milliseconds(self):
# Arrange - 1.5s 时长,验证毫秒精度(保留 3 位小数)
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0, 0),
finished_at=datetime(2024, 1, 1, 0, 15, 1, 500000),
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds == 1.5
# ─── to_handler_summary_output ────────────────────────────────────────────
@pytest.mark.unit
class TestToHandlerSummaryOutput:
def test_basic_mapping(self):
# Arrange
summary = HandlerSummary(
name="my_handler",
task_count=5,
active_task_count=3,
dead_letter_count=1,
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 output.active_task_count == 3
assert output.dead_letter_count == 1
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_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
def test_dead_letter_count_defaults_zero(self):
# Arrange - dead_letter_count 未提供时默认 0
summary = HandlerSummary(name="h", task_count=0)
# Act
output = to_handler_summary_output(summary)
# Assert
assert output.dead_letter_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:
@staticmethod
def _make_config() -> SchedulerConfigOutput:
return SchedulerConfigOutput(
enabled=True,
tick_interval_seconds=60,
task_timeout_seconds=600,
max_consecutive_errors=5,
backoff_schedule=[10, 30, 120, 600, 3600],
stagger_max_seconds=300,
run_log_retention_days=90,
idempotency_retention_hours=24,
min_cron_interval_minutes=10,
)
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,
config=self._make_config(),
)
# 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)
assert output.config.enabled is True
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,
config=self._make_config(),
)
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_included_in_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 + 3 = 11含 dead_letter
assert output.total_count == 11
assert output.success_rate == pytest.approx(5 / 11)
# ─── 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"
# ─── to_list_anomalies_output ─────────────────────────────────────────────
@pytest.mark.unit
class TestToListAnomaliesOutput:
def test_empty_groups_returns_empty_lists(self):
# Arrange & Act - 空分组字典
output = to_list_anomalies_output({})
# Assert - 三个列表都应为空
assert output.dead_letter == []
assert output.consecutive_failure == []
assert output.stale == []
def test_dead_letter_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
status="dead_letter",
)
# Act
output = to_list_anomalies_output({"dead_letter": [task]})
# Assert
assert len(output.dead_letter) == 1
assert output.dead_letter[0].task_id == "t1"
assert output.consecutive_failure == []
assert output.stale == []
def test_consecutive_failure_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t2",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
consecutive_errors=5,
)
# Act
output = to_list_anomalies_output({"consecutive_failure": [task]})
# Assert
assert len(output.consecutive_failure) == 1
assert output.consecutive_failure[0].task_id == "t2"
assert output.dead_letter == []
assert output.stale == []
def test_stale_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t3",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
last_run_at=datetime(2024, 1, 1, 0, 0, 0),
)
# Act
output = to_list_anomalies_output({"stale": [task]})
# Assert
assert len(output.stale) == 1
assert output.stale[0].task_id == "t3"
assert output.dead_letter == []
assert output.consecutive_failure == []
def test_all_groups_mapped(self):
# Arrange - 三类异常任务同时存在
groups = {
"dead_letter": [
ScheduledTask(
task_id="dl-1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
status="dead_letter",
),
],
"consecutive_failure": [
ScheduledTask(
task_id="cf-1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
consecutive_errors=3,
),
],
"stale": [
ScheduledTask(
task_id="st-1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
last_run_at=datetime(2024, 1, 1, 0, 0, 0),
),
],
}
# Act
output = to_list_anomalies_output(groups)
# Assert
assert len(output.dead_letter) == 1
assert output.dead_letter[0].task_id == "dl-1"
assert len(output.consecutive_failure) == 1
assert output.consecutive_failure[0].task_id == "cf-1"
assert len(output.stale) == 1
assert output.stale[0].task_id == "st-1"
def test_missing_group_key_defaults_to_empty_list(self):
# Arrange - 分组字典只含部分 key缺失的 key 应默认空列表
groups = {
"dead_letter": [
ScheduledTask(
task_id="t1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
),
]
}
# Act
output = to_list_anomalies_output(groups)
# Assert - 缺失的 consecutive_failure / stale 默认空列表
assert len(output.dead_letter) == 1
assert output.consecutive_failure == []
assert output.stale == []