feat: 改用Metrix API写入干扰数据

This commit is contained in:
2026-08-03 12:05:20 +08:00
parent 470763a818
commit 9776391ad1
8 changed files with 294 additions and 267 deletions
+68 -109
View File
@@ -6,9 +6,7 @@ import io
import json
from pathlib import Path
import tempfile
import types
import unittest
from unittest.mock import patch
import zipfile
import main
@@ -112,9 +110,9 @@ class PipelineTest(unittest.TestCase):
with self.assertRaisesRegex(main.ProcessingError, "Conflicting CellData coordinates"):
main.load_cell_coordinates([("first.xlsx", first), ("second.xlsx", second)])
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(connect_factory=lambda: connection)
def test_api_store_replaces_same_hour_and_deletes_other_hours(self) -> None:
client = FakeApiClient("2026-07-31T10:00:00")
store = main.ApiSummaryStore(client, "db_share_mysql")
result = store.replace_latest([database_row()])
@@ -122,47 +120,42 @@ class PipelineTest(unittest.TestCase):
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.assertIsNone(connection.cursor_instance.inserted[0][4])
self.assertIsNone(connection.cursor_instance.inserted[0][5])
self.assertTrue(connection.committed)
self.assertFalse(connection.rolled_back)
self.assertTrue(connection.closed)
self.assertNotIn("source_type", " ".join(connection.cursor_instance.queries))
self.assertNotIn("source_path", " ".join(connection.cursor_instance.queries))
self.assertTrue(any("ADD COLUMN `longitude`" in query for query in connection.cursor_instance.queries))
self.assertTrue(any("ADD COLUMN `latitude`" in query for query in connection.cursor_instance.queries))
queries = [payload["sql"] for endpoint, payload in client.posts if endpoint.endswith("/query")]
self.assertTrue(any("CREATE DATABASE IF NOT EXISTS `interference_etl`" in query for query in queries))
self.assertTrue(any("ADD COLUMN `longitude`" in query for query in queries))
self.assertTrue(any("ADD COLUMN `latitude`" in query for query in queries))
script = next(payload for endpoint, payload in client.posts if endpoint.endswith("/run-script"))
self.assertTrue(script["single_session"])
self.assertIn("START TRANSACTION", script["content"])
self.assertIn("NULL, NULL)", script["content"])
self.assertIn("CONVERT(0x", script["content"])
self.assertNotIn("source_type", script["content"])
self.assertNotIn("source_path", script["content"])
def test_mysql_store_refuses_to_replace_a_newer_hour(self) -> None:
connection = FakeConnection(datetime(2026, 7, 31, 11))
store = main.MySQLSummaryStore(connect_factory=lambda: connection)
def test_api_store_refuses_to_replace_a_newer_hour(self) -> None:
client = FakeApiClient("2026-07-31T11:00:00")
store = main.ApiSummaryStore(client, "db_share_mysql")
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)
self.assertFalse(any(endpoint.endswith("/run-script") for endpoint, _ in client.posts))
def test_mysql_store_creates_and_connects_to_own_database(self) -> None:
bootstrap = BootstrapConnection()
target = object()
calls: list[dict[str, object]] = []
def test_api_store_propagates_script_failure(self) -> None:
client = FakeApiClient(None, fail_script=True)
store = main.ApiSummaryStore(client, "db_share_mysql")
def connect(**kwargs: object) -> object:
calls.append(kwargs)
return bootstrap if len(calls) == 1 else target
with self.assertRaisesRegex(main.ProcessingError, "statement 3: insert failed"):
store.replace_latest([database_row()])
with patch.dict("sys.modules", {"pymysql": types.SimpleNamespace(connect=connect)}):
connection = main.MySQLSummaryStore()._connect()
def test_api_store_rejects_invalid_decimal(self) -> None:
client = FakeApiClient(None)
store = main.ApiSummaryStore(client, "db_share_mysql")
row = database_row()
row["interference_dbm"] = "not-a-number"
self.assertIs(connection, target)
self.assertNotIn("database", calls[0])
self.assertEqual(calls[1]["database"], "interference_etl")
self.assertIn("CREATE DATABASE IF NOT EXISTS `interference_etl`", bootstrap.cursor_instance.executed[0])
self.assertTrue(bootstrap.cursor_instance.closed)
self.assertTrue(bootstrap.closed)
with self.assertRaisesRegex(main.ProcessingError, "Invalid decimal value for interference_dbm"):
store.replace_latest([row])
class RecordingStore:
@@ -181,81 +174,47 @@ class RecordingStore:
}
class FakeCursor:
def __init__(self, latest_time: datetime | None, same_hour_rows: int, old_rows: int) -> None:
class FakeApiClient:
def __init__(self, latest_time: str | None, fail_script: bool = False) -> 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.queries: list[str] = []
self.closed = False
self.fail_script = fail_script
self.posts: list[tuple[str, dict[str, object]]] = []
def execute(self, query: str, params: tuple[object, ...] | None = None) -> None:
normalized = " ".join(query.split())
self.queries.append(normalized)
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 get_json(self, endpoint: str, query: dict[str, object] | None = None, timeout: int = 30) -> object:
del endpoint, query, timeout
return [
{"name": "metric_time"},
{"name": "cgi"},
{"name": "cell_name"},
{"name": "interference_dbm"},
]
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 fetchall(self) -> list[tuple[str]]:
return [(name,) for name in ("metric_time", "cgi", "cell_name", "interference_dbm")]
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
class BootstrapCursor:
def __init__(self) -> None:
self.executed: list[str] = []
self.closed = False
def execute(self, query: str) -> None:
self.executed.append(" ".join(query.split()))
def close(self) -> None:
self.closed = True
class BootstrapConnection:
def __init__(self) -> None:
self.cursor_instance = BootstrapCursor()
self.closed = False
def cursor(self) -> BootstrapCursor:
return self.cursor_instance
def close(self) -> None:
self.closed = True
def post_json(self, endpoint: str, payload: dict[str, object], timeout: int = 30) -> object:
del timeout
self.posts.append((endpoint, payload))
if endpoint.endswith("/query"):
if str(payload["sql"]).lstrip().startswith("SELECT MAX"):
return {"rows": [{"latest_time": self.latest_time}]}
return {"affected_rows": 0}
if self.fail_script:
return {
"results": [
{"index": 1, "ok": True, "affected_rows": 0},
{"index": 2, "ok": True, "affected_rows": 7},
{"index": 3, "ok": False, "message": "insert failed", "affected_rows": 0},
],
"stopped": True,
}
return {
"results": [
{"index": 1, "ok": True, "affected_rows": 0},
{"index": 2, "ok": True, "affected_rows": 7},
{"index": 3, "ok": True, "affected_rows": 1},
{"index": 4, "ok": True, "affected_rows": 20},
{"index": 5, "ok": True, "affected_rows": 0},
],
"stopped": False,
}
def database_row() -> dict[str, str]: