from __future__ import annotations import csv from datetime import datetime import io import json from pathlib import Path import tempfile import unittest import zipfile from openpyxl import Workbook import main COMPLETE_WINDOW = "2026073110001100" INCOMPLETE_WINDOW = "2026073111001200" class PipelineTest(unittest.TestCase): def test_latest_complete_window_is_converted_and_merged(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) / "source" output = Path(temp) / "output" for source_type in main.EXPECTED_TYPES: create_archive(root, source_type, COMPLETE_WINDOW) create_archive(root, main.EXPECTED_TYPES[0], INCOMPLETE_WINDOW) store = RecordingStore() result = main.process(main.LocalSource(root), output, lookback_days=3, store=store) self.assertEqual(result.name, COMPLETE_WINDOW) converted = sorted((result / "converted").glob("*.csv")) self.assertEqual(len(converted), 7) with (result / f"interference_summary_{COMPLETE_WINDOW}.csv").open(encoding="utf-8-sig", newline="") as file: rows = list(csv.DictReader(file)) self.assertEqual(len(rows), 7) self.assertEqual({row["source_type"] for row in rows}, set(main.EXPECTED_TYPES)) self.assertIn("460-00-100-1", {row["cgi"] for row in rows}) self.assertIn("46000-100-1", {row["cgi"] for row in rows}) self.assertTrue(all(row["interference_dbm"] == "-100.5" for row in rows)) manifest = json.loads((result / "manifest.json").read_text(encoding="utf-8")) self.assertEqual(manifest["source_file_count"], 7) self.assertEqual(manifest["summary_rows"], 7) self.assertEqual(len(manifest["warnings"]), 1) self.assertIn(INCOMPLETE_WINDOW, manifest["warnings"][0]) self.assertEqual(manifest["database"]["metric_time"], "2026-07-31 10:00:00") self.assertEqual(len(store.rows), 7) def test_requested_incomplete_window_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) / "source" create_archive(root, main.EXPECTED_TYPES[0], INCOMPLETE_WINDOW) with self.assertRaisesRegex(main.ProcessingError, "Requested window is incomplete"): main.process(main.LocalSource(root), Path(temp) / "output", 3, INCOMPLETE_WINDOW) def test_schema_change_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) / "source" for source_type in main.EXPECTED_TYPES: create_archive(root, source_type, COMPLETE_WINDOW, bad_header=source_type == main.EXPECTED_TYPES[0]) with self.assertRaisesRegex(main.ProcessingError, "Unexpected Sheet0 header"): main.process(main.LocalSource(root), Path(temp) / "output", 3) def test_cross_midnight_window(self) -> None: self.assertEqual( main.window_bounds("2026073023000000"), ("2026-07-30 23:00:00", "2026-07-31 00:00:00"), ) def test_mysql_store_replaces_same_hour_and_deletes_other_hours(self) -> None: connection = FakeConnection(datetime(2026, 7, 31, 10), same_hour_rows=7, old_rows=20) store = main.MySQLSummaryStore( host="", port=3306, user="", password="", database="", connect_factory=lambda: connection, ) result = store.replace_latest([database_row()]) self.assertEqual(result["metric_time"], "2026-07-31 10:00:00") self.assertEqual(result["inserted_rows"], 1) self.assertEqual(result["refreshed_rows"], 7) self.assertEqual(result["old_rows_deleted"], 20) self.assertEqual(connection.cursor_instance.inserted[0][0], datetime(2026, 7, 31, 10)) self.assertTrue(connection.committed) self.assertFalse(connection.rolled_back) self.assertTrue(connection.closed) def test_mysql_store_refuses_to_replace_a_newer_hour(self) -> None: connection = FakeConnection(datetime(2026, 7, 31, 11)) store = main.MySQLSummaryStore( host="", port=3306, user="", password="", database="", connect_factory=lambda: connection, ) with self.assertRaisesRegex(main.ProcessingError, "already contains newer metric time"): store.replace_latest([database_row()]) self.assertFalse(connection.committed) self.assertTrue(connection.rolled_back) self.assertEqual(connection.cursor_instance.inserted, []) self.assertTrue(connection.closed) class RecordingStore: def __init__(self) -> None: self.rows: list[dict[str, str]] = [] def replace_latest(self, rows: list[dict[str, str]]) -> dict[str, object]: self.rows = list(rows) return { "enabled": True, "table": main.DEFAULT_DB_TABLE, "metric_time": rows[0]["hour_start"], "inserted_rows": len(rows), "refreshed_rows": 0, "old_rows_deleted": 0, } class FakeCursor: def __init__(self, latest_time: datetime | None, same_hour_rows: int, old_rows: int) -> None: self.latest_time = latest_time self.same_hour_rows = same_hour_rows self.old_rows = old_rows self.rowcount = 0 self.inserted: list[tuple[object, ...]] = [] self.closed = False def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: normalized = " ".join(query.split()) if normalized.startswith("DELETE") and "metric_time = %s" in normalized: self.rowcount = self.same_hour_rows elif normalized.startswith("DELETE") and "metric_time <> %s" in normalized: self.rowcount = self.old_rows def executemany(self, query: str, values: list[tuple[object, ...]]) -> None: del query self.inserted = list(values) self.rowcount = len(values) def fetchone(self) -> tuple[datetime | None]: return (self.latest_time,) def close(self) -> None: self.closed = True class FakeConnection: def __init__(self, latest_time: datetime | None, same_hour_rows: int = 0, old_rows: int = 0) -> None: self.cursor_instance = FakeCursor(latest_time, same_hour_rows, old_rows) self.committed = False self.rolled_back = False self.closed = False def cursor(self) -> FakeCursor: return self.cursor_instance def commit(self) -> None: self.committed = True def rollback(self) -> None: self.rolled_back = True def close(self) -> None: self.closed = True def database_row() -> dict[str, str]: return { "hour_start": "2026-07-31 10:00:00", "hour_end": "2026-07-31 11:00:00", "source_type": "5G干扰监控", "cgi": "46000-100-1", "cell_name": "测试小区", "interference_dbm": "-100.5", "source_path": "/source.zip", } def create_archive(root: Path, source_type: str, window: str, bad_header: bool = False) -> None: date_dir = root / f"{window[:4]}-{window[4:6]}-{window[6:8]}" date_dir.mkdir(parents=True, exist_ok=True) filename = f"{source_type}_LWP_每小时_过滤110_{window}" workbook = Workbook() sheet = workbook.active sheet.title = "Sheet0" header = list(main.EXPECTED_HEADERS[source_type]) if bad_header: header[-1] = "unexpected" sheet.append(header) sheet.append(mock_row(source_type, window)) metadata = workbook.create_sheet("指标(计数器)") metadata.append(["指标或计数器", "指标或计数器描述", "指标公式", "指标或计数器状态"]) content = io.BytesIO() workbook.save(content) workbook.close() with zipfile.ZipFile(date_dir / f"{filename}.zip", "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr(f"{filename}.xlsx", content.getvalue()) def mock_row(source_type: str, window: str) -> list[object]: header = main.EXPECTED_HEADERS[source_type] values: dict[str, object] = {column: "mock" for column in header} values.update( { "开始时间": datetime.strptime(window[:12], "%Y%m%d%H%M"), "粒度": "1 小时", "eNodeBId": 100, "eNodeBID": 100, "cellId": 1, "小区ID": 1, "masterOperatorId": "46000-100-1", "E-UTRAN FDD小区名称": f"{source_type}-小区", "E-UTRAN TDD小区名称": f"{source_type}-小区", "CU小区配置名称": f"{source_type}-小区", "小区名称": f"{source_type}-小区", "载波平均噪声干扰(dBm)": -100.5, "小区上行平均干扰电平(dBm)": -100.5, } ) return [values[column] for column in header] if __name__ == "__main__": unittest.main()