# -*- coding: utf-8 -*- """EtlTimer 单元测试""" from __future__ import annotations import os import time from pathlib import Path from unittest.mock import patch from zoneinfo import ZoneInfo import pytest from utils.timer import EtlTimer, StepRecord, _fmt_ms # ── _fmt_ms 格式化 ─────────────────────────────────────────── class TestFmtMs: def test_sub_second(self): assert _fmt_ms(123.4) == "123.4ms" def test_seconds(self): assert _fmt_ms(2500) == "2.50s" def test_minutes(self): result = _fmt_ms(90_000) # 90 秒 assert result.startswith("1m") def test_hours(self): result = _fmt_ms(3_700_000) # ~61.7 分钟 assert result.startswith("1h") # ── StepRecord ─────────────────────────────────────────────── class TestStepRecord: def test_elapsed_seconds(self): rec = StepRecord(name="test", start_time=None, elapsed_ms=1500.0) assert rec.elapsed_seconds == 1.5 def test_to_dict_without_end(self): from datetime import datetime now = datetime.now(ZoneInfo("Asia/Shanghai")) rec = StepRecord(name="s1", start_time=now, elapsed_ms=100.0) d = rec.to_dict() assert d["name"] == "s1" assert d["end_time"] is None assert d["elapsed_ms"] == 100.0 assert d["children"] == [] def test_to_dict_with_children(self): from datetime import datetime tz = ZoneInfo("Asia/Shanghai") now = datetime.now(tz) parent = StepRecord(name="p", start_time=now, elapsed_ms=200.0) child = StepRecord(name="c", start_time=now, end_time=now, elapsed_ms=50.0) parent.children.append(child) d = parent.to_dict() assert len(d["children"]) == 1 assert d["children"][0]["name"] == "c" # ── EtlTimer 核心流程 ──────────────────────────────────────── class TestEtlTimer: def test_start_stop_step(self): timer = EtlTimer() timer.start() timer.start_step("STEP_A") time.sleep(0.01) # 确保有可测量的耗时 rec = timer.stop_step("STEP_A") assert rec.name == "STEP_A" assert rec.end_time is not None assert rec.elapsed_ms > 0 def test_sub_steps(self): timer = EtlTimer() timer.start() timer.start_step("PARENT") timer.start_sub_step("PARENT", "child_1") time.sleep(0.01) timer.stop_sub_step("PARENT", "child_1") timer.start_sub_step("PARENT", "child_2") timer.stop_sub_step("PARENT", "child_2") timer.stop_step("PARENT") parent = timer.get_step("PARENT") assert parent is not None assert len(parent.children) == 2 assert parent.children[0].name == "child_1" assert parent.children[0].elapsed_ms > 0 def test_stop_unknown_step_raises(self): timer = EtlTimer() with pytest.raises(KeyError, match="未找到步骤"): timer.stop_step("NONEXISTENT") def test_start_sub_step_unknown_parent_raises(self): timer = EtlTimer() with pytest.raises(KeyError, match="未找到父步骤"): timer.start_sub_step("NONEXISTENT", "child") def test_stop_sub_step_unknown_raises(self): timer = EtlTimer() timer.start() timer.start_step("P") with pytest.raises(KeyError, match="未找到子步骤"): timer.stop_sub_step("P", "no_such_child") def test_multiple_steps(self): timer = EtlTimer() timer.start() for name in ["ODS_LOAD", "DWD_LOAD", "DWS_AGG"]: timer.start_step(name) timer.stop_step(name) assert len(timer.steps) == 3 assert timer.steps[0].name == "ODS_LOAD" assert timer.steps[2].name == "DWS_AGG" def test_to_dict(self): timer = EtlTimer() timer.start() timer.start_step("S1") timer.stop_step("S1") report = timer.finish(write_report=False) d = timer.to_dict() assert d["overall_start"] is not None assert d["overall_end"] is not None assert d["overall_elapsed_ms"] >= 0 assert len(d["steps"]) == 1 def test_overall_elapsed(self): timer = EtlTimer() timer.start() time.sleep(0.02) timer.finish(write_report=False) assert timer.overall_elapsed_ms >= 15 # 至少 15ms(留余量) def test_finish_returns_markdown(self): timer = EtlTimer() timer.start() timer.start_step("TEST_STEP") timer.stop_step("TEST_STEP") md = timer.finish(write_report=False) assert "# ETL 执行计时报告" in md assert "TEST_STEP" in md assert "步骤汇总" in md def test_markdown_contains_sub_steps(self): timer = EtlTimer() timer.start() timer.start_step("MAIN") timer.start_sub_step("MAIN", "sub_a") timer.stop_sub_step("MAIN", "sub_a") timer.stop_step("MAIN") md = timer.finish(write_report=False) assert "步骤详情" in md assert "sub_a" in md def test_write_report_requires_env(self): """ETL_REPORT_ROOT 未设置时应抛出 KeyError""" timer = EtlTimer() timer.start() timer.start_step("X") timer.stop_step("X") with patch.dict(os.environ, {}, clear=True): with pytest.raises(KeyError, match="ETL_REPORT_ROOT"): timer.finish(write_report=True) def test_write_report_creates_file(self, tmp_path: Path): """设置 ETL_REPORT_ROOT 后应生成 .md 文件""" timer = EtlTimer() timer.start() timer.start_step("Y") timer.stop_step("Y") with patch.dict(os.environ, {"ETL_REPORT_ROOT": str(tmp_path)}): timer.finish(write_report=True) md_files = list(tmp_path.glob("etl_timing_*.md")) assert len(md_files) == 1 content = md_files[0].read_text(encoding="utf-8") assert "# ETL 执行计时报告" in content assert "Y" in content def test_elapsed_equals_end_minus_start(self): """Property 7 核心验证:耗时 ≈ 结束时间 - 开始时间""" timer = EtlTimer() timer.start() timer.start_step("VERIFY") time.sleep(0.05) rec = timer.stop_step("VERIFY") # 用 datetime 差值计算的毫秒数 dt_diff_ms = (rec.end_time - rec.start_time).total_seconds() * 1000 # perf_counter 计算的毫秒数 # 两者应在合理误差范围内(±50ms,考虑系统调度抖动) assert abs(rec.elapsed_ms - dt_diff_ms) < 50