Files
iAOP/tests/e2e/test_data_pipeline.py
T
bot_dev1 33e64fe18b feat(#86): 端到端联调用例编写(数据流 + 问答流 + 跨链路闭环)
新增 tests/e2e/ 端到端联调测试套件,覆盖四个 iAOP-Core 内核模块的全链路协作:

数据流(PRD 5.1→5.2):
- edge-gateway 只读采集(模拟驱动)→ spool 断点续传 → data-bus 批量写入
- 验证不丢不重(幂等去重)、样本字段完整、健康度满足 SLA

问答流(PRD 5.4):
- rag-kb 模板化知识库检索(命中片段+来源)→ llm-gateway 混合网关
- 验证敏感度路由、DLP 拦截、幻觉溯源校验、审计可追溯

跨链路:采集→落库→知识沉淀→安全问答业务闭环

共 14 个用例,全部基于可注入接口运行,零外部依赖(CI 可直接执行)。
运行:python -m unittest discover -s tests/e2e -v
2026-08-04 18:26:00 +08:00

184 lines
7.7 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.
# -*- coding: utf-8 -*-
"""端到端联调用例(Issue #86)—— 数据流链路。
验证 PRD 5.1(边缘采集网关)→ PRD 5.2(数据总线 + 时序库)的端到端协作:
edge-gateway 只读采集(模拟驱动)→ spool 缓存 → BatchWriter 批量写入
(MemorySink 幂等去重,「不丢不重」)。
不依赖 Kafka / TDengine / 真实设备:全部走内存桩,可在 CI 直接执行。
"""
from __future__ import annotations
# 引导加载四个内核模块(必须在测试导入前执行)
import tests.e2e._bootstrap # noqa: F401
import os
import tempfile
import unittest
from collector.engine import CollectorEngine
from collector.metrics import HealthMetrics
from collector.spool import SpoolStore
from drivers import SimulatorDriver
from point_dict.loader import Point, PointDict
from data_bus import BatchWriter, MemorySink
def _make_point_dict() -> PointDict:
"""模拟氯化车间(Template-Ti)点位集:2 台设备 × 3 测点。"""
points = [
Point(device_id="CLF-01", point_id="CLF-01.TEMP", name="1#炉温",
unit="℃", data_type="float", sample_rate=1000,
quality_code=True, row_number=2),
Point(device_id="CLF-01", point_id="CLF-01.PRES", name="1#炉压",
unit="kPa", data_type="float", sample_rate=1000,
quality_code=True, row_number=3),
Point(device_id="CLF-01", point_id="CLF-01.FLOW", name="1#氯气流量",
unit="m³/h", data_type="float", sample_rate=1000,
quality_code=True, row_number=4),
Point(device_id="CLF-02", point_id="CLF-02.TEMP", name="2#炉温",
unit="℃", data_type="float", sample_rate=1000,
quality_code=True, row_number=5),
Point(device_id="CLF-02", point_id="CLF-02.PRES", name="2#炉压",
unit="kPa", data_type="float", sample_rate=1000,
quality_code=True, row_number=6),
Point(device_id="CLF-02", point_id="CLF-02.FLOW", name="2#氯气流量",
unit="m³/h", data_type="float", sample_rate=1000,
quality_code=True, row_number=7),
]
return PointDict(points)
def _make_engine(tmpdir: str, point_dict: PointDict):
"""组装一个最小可运行的采集引擎(模拟驱动,spool 落临时目录)。"""
spool = SpoolStore(spool_dir=tmpdir, cache_limit_bytes=16 * 1024 * 1024)
metrics = HealthMetrics()
engine = CollectorEngine(
point_dict=point_dict,
driver_slots=[("simulator", SimulatorDriver(), [])],
spool=spool,
metrics=metrics,
interval_ms=1000,
max_pending=100_000,
)
return engine, spool, metrics
class DataPipelineE2ETest(unittest.TestCase):
"""采集 → spool → 批量写入 全链路联调。"""
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="iaop_e2e_")
self.point_dict = _make_point_dict()
self.engine, self.spool, self.metrics = _make_engine(
self._tmp, self.point_dict
)
# -- 采集 → spool ------------------------------------------------------
def test_collect_once_all_points_read_into_spool(self) -> None:
"""一轮采集:6 点位全部读出并落入 spool(expected == got)。"""
got = self.engine.collect_once()
self.assertEqual(got, len(self.point_dict))
# spool 待上行记录数应等于本轮样本数
self.assertEqual(self.spool.total_pending(), got)
# 健康度:一轮全成功,失败计 0
self.assertEqual(self.metrics.total_rounds, 1)
self.assertEqual(self.metrics.failed_rounds, 0)
def test_collect_multiple_rounds_accumulate(self) -> None:
"""多轮采集:spool 累计样本数 = 轮数 × 点位数(未上行前不丢)。"""
rounds = 5
total = 0
for _ in range(rounds):
total += self.engine.collect_once()
self.assertEqual(total, rounds * len(self.point_dict))
self.assertEqual(self.spool.total_pending(), total)
self.assertEqual(self.metrics.total_rounds, rounds)
# -- spool → BatchWriter(幂等去重,不丢不重)-------------------------
def test_spool_drain_into_batchwriter_no_loss_no_dup(self) -> None:
"""采集 → spool → 批量写入:样本不丢、重复不重。"""
rounds = 3
for _ in range(rounds):
self.engine.collect_once()
sink = MemorySink()
writer = BatchWriter(sink=sink, batch_size=len(self.point_dict),
flush_interval=0.0)
accepted = 0
# 从 spool 取出待上行样本(pending_records 读取,ack 确认删除)
for sample in self.spool.pending_records():
if writer.push(sample):
accepted += 1
self.spool.ack(sample) # 上行确认
writer.flush()
expected = rounds * len(self.point_dict)
self.assertEqual(accepted, expected) # 全部接受(无重复)
self.assertEqual(len(sink.rows), expected) # 全部落库(不丢)
self.assertEqual(sink.write_count, expected)
# spool 已全部确认,待上行归零
self.assertEqual(self.spool.total_pending(), 0)
def test_batchwriter_idempotent_on_replay(self) -> None:
"""断点续传重发同一批样本:落库不重复(幂等去重)。"""
sink = MemorySink()
writer = BatchWriter(sink=sink, batch_size=10, flush_interval=0.0)
self.engine.collect_once()
samples = self.spool.pending_records()
# 第一次写入
for s in samples:
writer.push(s)
writer.flush()
first_count = len(sink.rows)
# 模拟断点续传:重发同一批样本
for s in samples:
writer.push(s)
writer.flush()
self.assertEqual(len(sink.rows), first_count) # 重发不产生重复
# BatchWriter 统计:duplicates 应等于重发条数
stats = writer.stats()
self.assertEqual(stats["duplicates"], len(samples))
# -- 全链路数据完整性 --------------------------------------------------
def test_sample_fields_preserved_end_to_end(self) -> None:
"""端到端:样本字段(device/point/value/unit/ts)完整落库。"""
self.engine.collect_once()
sink = MemorySink()
writer = BatchWriter(sink=sink, batch_size=10, flush_interval=0.0)
for s in self.spool.pending_records():
writer.push(s)
writer.flush()
point_ids = {p.point_id for p in self.point_dict.points}
written_ids = {r["point_id"] for r in sink.rows}
self.assertEqual(written_ids, point_ids) # 所有点位都落库
for row in sink.rows:
# 字段完整性
for key in ("device_id", "point_id", "value", "ts"):
self.assertIn(key, row)
# 设备与点位字典一致
src = next(p for p in self.point_dict.points
if p.point_id == row["point_id"])
self.assertEqual(row["device_id"], src.device_id)
# -- 健康度上报贯穿链路 ------------------------------------------------
def test_health_metrics_reflect_collection(self) -> None:
"""采集健康度(成功率/丢失率)随采集轮次正确累计。"""
for _ in range(10):
self.engine.collect_once()
self.assertEqual(self.metrics.total_rounds, 10)
self.assertEqual(self.metrics.failed_rounds, 0)
# 可用性 = 1 - 失败轮次/总轮次,目标 ≥ 99.8%(此处 100%)
availability = self.metrics.availability
self.assertGreaterEqual(availability, 0.998)
self.assertTrue(self.metrics.meets_sla())
if __name__ == "__main__":
unittest.main()