Files
mcm-26-b/tests/test_protocol_client.py
T
2026-09-13 18:43:25 +08:00

282 lines
14 KiB
Python

"""P1 验收测试:幂等重试、故障恢复、现实预算与真实演练日志回放。
对应验收标准(docs/B题建模分析与实施计划.md P1 行):
- accepted=false 和超时重试不污染状态;
- 失败重试复用原 request_id 与原内容,服务器端动作恰好生效一次;
- 用 /enter 的 remaining_real_duration_s 建立本地截止时钟;
- 官方演练日志可完整回放,本地重建状态与服务器记录逐步吻合。
"""
import json
import math
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from mcm26b.config import ProblemConfig
from mcm26b.evaluation import recompute_virtual_time, summarize
from mcm26b.protocol.client import (DeadlineExceeded, ProtocolError,
SimulatorClient)
from mcm26b.protocol.log import SessionLog
from mcm26b.protocol.transport import TransportError
from mcm26b.harness import (LocalSimTransport, LocalSimulator,
PlaybackTransport, ScriptedFaultTransport)
from referee import Source
ROOT = Path(__file__).resolve().parents[1]
class FaultRecoveryTests(unittest.TestCase):
"""故障注入:失败重试不污染状态、不重复生效。"""
def _client(self, script, sources=None):
simulator = LocalSimulator(sources or [], robot_id="T001")
inner = LocalSimTransport(simulator)
faults = ScriptedFaultTransport(inner, script)
client = SimulatorClient("T001", transport=faults)
return client, faults, simulator
def test_timeout_after_apply_replays_cached_response(self):
"""读超时(服务器已生效)后重试:同一响应回放,动作不二次生效。"""
# 脚本按请求顺序消耗:enter 放行,measure 第一次注入"已生效但超时"。
client, faults, simulator = self._client([None, "timeout_after", None, None])
client.enter()
outcome = client.measure((0, 0), 1)
self.assertEqual(outcome.virtual_time_s, 5.0)
# measure 只被服务器执行了一次:请求发出两次,但虚拟时刻只推进一次。
measure_bodies = list(faults.sent_requests[1:])
self.assertEqual(len(measure_bodies), 2, "应发出原始请求+一次重试")
first, retry = (json.loads(b) for b in measure_bodies)
self.assertEqual(first["request_id"], retry["request_id"],
"重试必须复用原 request_id")
self.assertEqual(first, retry, "重试请求体必须逐字段一致")
self.assertEqual(simulator.virtual_time_s, 5.0, "动作只生效一次")
self.assertEqual(client.state.virtual_time_s, 5.0)
def test_drop_before_apply_is_safe_to_retry(self):
"""连接失败(未送达)后重试成功,无残留效果。"""
client, _, simulator = self._client(["drop_before", None])
client.enter()
client.measure((100, 0), 1)
self.assertEqual(simulator.virtual_time_s, 25.0) # 100/5 移动 + 5 检测
self.assertEqual(client.state.virtual_time_s, 25.0)
def test_server_500_retried_with_same_id(self):
client, _, _ = self._client([None, ("status", 500), None, None])
client.enter()
client.measure((0, 0), 1)
self.assertEqual(client.state.virtual_time_s, 5.0)
def test_exhausted_retries_raise_and_leave_state_clean(self):
# enter 放行后,measure 连续注入断连直至超过 max_attempts=3。
client, _, simulator = self._client([None, "drop_before", "drop_before",
"drop_before"])
client.enter()
with self.assertRaises(TransportError):
client.measure((0, 0), 1)
self.assertEqual(client.state.virtual_time_s, 0.0,
"失败动作不得污染本地状态")
self.assertTrue(simulator.entered) # enter 已成功,会话仍然有效
# 后续新动作用新 request_id 正常执行(脚本已耗尽,全部放行)。
outcome = client.measure((0, 0), 1)
self.assertEqual(outcome.virtual_time_s, 5.0)
def test_accepted_false_raises_and_state_untouched(self):
client, _, _ = self._client([None])
with self.assertRaises(ProtocolError) as ctx:
client.measure((0, 0), 1) # 未 enter -> accepted=false
self.assertEqual(ctx.exception.status, 200)
self.assertEqual(client.state.virtual_time_s, 0.0)
class DeadlineBudgetTests(unittest.TestCase):
"""现实预算:本地单调时钟、预留余量、/exit 豁免。"""
def test_deadline_from_enter_remaining(self):
now = [1000.0]
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator),
clock=lambda: now[0])
client.enter()
self.assertAlmostEqual(client.real_remaining_s, 1200.0)
now[0] += 300.0
self.assertAlmostEqual(client.real_remaining_s, 900.0)
def test_action_blocked_when_reserve_margin_low(self):
now = [0.0]
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator),
clock=lambda: now[0])
client.enter() # remaining = 1200s
now[0] += 1145.0 # 只剩 55s < 60s 预留
with self.assertRaises(DeadlineExceeded):
client.measure((0, 0), 1)
info = client.exit() # /exit 不受预算拦截
self.assertEqual(info.reason, "user_exit")
def test_custom_reserve_margin(self):
now = [0.0]
simulator = LocalSimulator([], robot_id="T001")
config = replace(ProblemConfig(),
session=replace(ProblemConfig().session,
reserve_exit_s=10.0))
client = SimulatorClient("T001", config=config,
transport=LocalSimTransport(simulator),
clock=lambda: now[0])
client.enter()
now[0] += 1195.0 # 剩 5s < 新预留 10s
with self.assertRaises(DeadlineExceeded):
client.measure((0, 0), 1)
class PracticeReplayTests(unittest.TestCase):
"""官方演练日志经 PlaybackTransport 驱动完整客户端。"""
def _events(self):
files = sorted((ROOT / "output" / "q1" / "practice").glob("*_actions.json"))
if not files:
self.skipTest("缺少 output/q1/practice 下的官方演练采集证据(可选夹具)")
events = []
for path in files:
events.extend(json.loads(path.read_text(encoding="utf-8")))
return events
def test_client_state_matches_recorded_server_clock(self):
events = self._events()
client = SimulatorClient("任意队号", transport=PlaybackTransport(events))
for event in events:
path, request = event["path"], event["request"]
if path == "/enter":
client.enter()
elif path == "/measure":
client.measure((request["position"]["x"], request["position"]["y"]),
request["channel"])
elif path == "/exit":
client.exit()
self.assertAlmostEqual(
client.state.virtual_time_s,
event["response"]["virtual_time_s"], places=5,
msg=f"{path} 后本地虚拟时刻应与服务器记录一致")
self.assertTrue(client.state.exited)
def test_evaluation_recompute_agrees_with_recording(self):
events = [dict(path=e["path"], request=e["request"], response=e["response"])
for e in self._events()
if e["response"].get("accepted") is True]
report = recompute_virtual_time(events)
self.assertEqual(report["mismatches"], [])
self.assertAlmostEqual(report["final_virtual_time_s"],
events[-1]["response"]["virtual_time_s"], places=5)
def test_summarize_counts(self):
events = [dict(path=e["path"], request=e["request"], response=e["response"])
for e in self._events()]
stats = summarize(events)
_directions = [e for e in events
if e["response"].get("measure_result") == "direction"]
self.assertGreater(stats["measures"], 0)
self.assertEqual(stats["clears"], 0, "该次演练只采集观测,未调用 /clear")
class LocalSimObservationTests(unittest.TestCase):
"""观测语义:no_signal / near / direction 与定向覆盖角。"""
def test_distance_and_coverage_gate(self):
source = Source(channel=1, x=500, y=0, r_eff_m=1000)
simulator = LocalSimulator([source], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
self.assertEqual(client.measure((1505, 0), 1).result, "no_signal") # 距源1005>1000
self.assertEqual(client.measure((500, 998), 1).result, "direction") # 1000内
self.assertEqual(client.measure((500, 0), 1).result, "near") # <=5m
def test_directional_source_backside_invisible(self):
source = Source(channel=1, x=500, y=0, r_eff_m=1500,
directional=True, heading_deg=90.0) # 朝北
simulator = LocalSimulator([source], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
self.assertEqual(client.measure((500, 600), 1).result, "direction") # 正北
self.assertEqual(client.measure((500, -300), 1).result, "no_signal") # 正南背面
self.assertEqual(client.measure((500, 998), 1).result, "direction") # 边界含90度
def test_clear_ignores_directional_facing(self):
"""清除只看距离:定向源背面同样可清除 [题面]。"""
source = Source(channel=7, x=0, y=0, r_eff_m=1500,
directional=True, heading_deg=180.0)
simulator = LocalSimulator([source], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
self.assertEqual(client.state.channel, 1)
outcome = client.clear((15, 0), 7) # 距离15m<=20,位于背面
self.assertEqual(outcome.result, "success")
outcome = client.clear((0, 0), 7) # 同源只能清除一次
self.assertEqual(outcome.result, "no_target_in_range")
# 两次 /clear 后测向机频道仍是初始 1:/clear 不切换频道 [题面]。
self.assertEqual(client.state.channel, 1)
# 清除后同频道无信号;/measure 会把频道更新为 7。
self.assertEqual(client.measure((100, 0), 7).result, "no_signal")
self.assertEqual(client.state.channel, 7)
def test_bearing_error_within_bound(self):
source = Source(channel=1, x=1000, y=300, r_eff_m=1500)
simulator = LocalSimulator([source], error_half_deg=1.0, robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
outcome = client.measure((0, 0), 1)
true_bearing = math.degrees(math.atan2(300, 1000))
# 误差在 ±1° 内均匀分布,返回值再保留两位小数。
self.assertLessEqual(abs(outcome.svd_deg - true_bearing), 1.005)
def test_same_position_error_is_fixed(self):
"""同一(源,位置)重复测量读数不变 [题面/官方演练实测]。"""
source = Source(channel=1, x=1000, y=300, r_eff_m=1500)
simulator = LocalSimulator([source], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
first = client.measure((0, 0), 1)
second = client.measure((0, 0), 1) # 新 request_id,同位置
self.assertEqual(first.result, "direction")
self.assertEqual(first.svd_deg, second.svd_deg,
"同位置重复测量的示向度必须相同")
def test_error_field_deterministic_per_seed(self):
"""同种子误差场逐点一致;不同种子得到不同误差场(逐点白噪声)。"""
source = Source(channel=1, x=1000, y=0, r_eff_m=1500)
readings = {}
for label, seed in (("a", 1), ("b", 1), ("c", 2)):
simulator = LocalSimulator([source], error_seed=seed, robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
readings[label] = [client.measure((float(x), 0.0), 1).svd_deg
for x in (0, 100, 200, 300, 400)]
self.assertEqual(readings["a"], readings["b"])
self.assertNotEqual(readings["a"], readings["c"],
"不同种子应在至少一个测点产生不同读数")
class SessionLogTests(unittest.TestCase):
def test_log_records_attempts_and_accepted(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "session.jsonl"
simulator = LocalSimulator([], robot_id="T001")
faults = ScriptedFaultTransport(LocalSimTransport(simulator),
[None, "drop_before", None, None])
client = SimulatorClient("T001", transport=faults,
log=SessionLog(path=path))
client.enter()
client.measure((0, 0), 1)
client.exit()
rows = [json.loads(line) for line in
path.read_text(encoding="utf-8").splitlines()]
accepted = [row for row in rows if row["event"] == "accepted"]
self.assertEqual(len(accepted), 3)
self.assertEqual(accepted[1]["attempts"], 2, "重试后成功应记录尝试次数")
self.assertEqual(len({row["request_id"] for row in accepted}), 3,
"每个被接受动作各占一个 request_id")
if __name__ == "__main__":
unittest.main()