feat: 改用Metrix API写入干扰数据
This commit is contained in:
+68
-109
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user