Files
mcm-26-b/tests/test_protocol_timing.py
2026-09-11 11:36:08 +08:00

208 lines
9.6 KiB
Python

"""P0 验收测试:计时示例、状态机规则与字段表语义。
对应验收标准(docs/B题建模分析与实施计划.md P0 行):
- 附件计时示例能逐步得到 105、111、194、199 秒;
- /clear 不切换测向频道;
- accepted=false 不更新状态,其 virtual_time_s=0 不当作当前虚拟时刻。
"""
import unittest
from mcm26b.config import CostConfig
from mcm26b.protocol.rules import CLEAR_NO_TARGET
from mcm26b.protocol.state import RobotState, StateError
from mcm26b.protocol.timing import clear_cost, measure_cost
from mcm26b.harness import LocalSimTransport, LocalSimulator
from mcm26b.protocol.client import ActionRejected, SimulatorClient
class TimingModelTests(unittest.TestCase):
"""附件1表2 / 附件2第10节示例逐步分解。"""
def setUp(self):
self.cost = CostConfig()
def test_measure_step2_move_500_no_switch(self):
breakdown = measure_cost((0, 0), (300, 400), 1, 1, self.cost)
self.assertAlmostEqual(breakdown.move_s, 100) # 500m / 5(m/s)
self.assertAlmostEqual(breakdown.switch_s, 0) # 初始频道即1
self.assertAlmostEqual(breakdown.action_s, 5)
self.assertAlmostEqual(breakdown.total_s, 105)
def test_measure_step3_in_place_switch_channel(self):
breakdown = measure_cost((300, 400), (300, 400), 1, 2, self.cost)
self.assertAlmostEqual(breakdown.move_s, 0)
self.assertAlmostEqual(breakdown.switch_s, 1)
self.assertAlmostEqual(breakdown.total_s, 6)
def test_clear_step4_move_400_no_target(self):
breakdown = clear_cost((300, 400), (300, 0), success=False, cost=self.cost)
self.assertAlmostEqual(breakdown.move_s, 80) # 400m / 5
self.assertAlmostEqual(breakdown.switch_s, 0) # /clear 不产生换频道耗时
self.assertAlmostEqual(breakdown.action_s, 3) # 未发现=光学定位3s
self.assertAlmostEqual(breakdown.total_s, 83)
def test_measure_step5_same_channel_after_clear(self):
# 第4步 /clear 后测向机仍是频道2,原地再测频道2无切换耗时。
breakdown = measure_cost((300, 0), (300, 0), 2, 2, self.cost)
self.assertAlmostEqual(breakdown.total_s, 5)
def test_virtual_clock_walk(self):
"""累积虚拟时刻逐步得到附件给定值 0/105/111/194/199/199。"""
virtual = 0.0
expected = [0, 105, 111, 194, 199, 199]
sequence = [
measure_cost((0, 0), (300, 400), 1, 1, self.cost),
measure_cost((300, 400), (300, 400), 1, 2, self.cost),
clear_cost((300, 400), (300, 0), False, self.cost),
measure_cost((300, 0), (300, 0), 2, 2, self.cost),
None, # /exit
]
results = [virtual]
for breakdown in sequence:
virtual += 0 if breakdown is None else breakdown.total_s
results.append(round(virtual, 6))
self.assertEqual(results, [float(v) for v in expected])
class StateMachineTests(unittest.TestCase):
"""状态更新规则:accepted=false 不更新;/clear 不动频道。"""
@staticmethod
def _accepted_measure(virtual):
return {"accepted": True, "real_timestamp_ms": 0,
"virtual_time_s": virtual,
"measure_result": "no_signal"}
def test_clear_keeps_channel(self):
state = RobotState()
state.entered = True
measure_req = {"position": {"x": 300, "y": 400}, "channel": 2}
state.apply_measure(measure_req, self._accepted_measure(106))
self.assertEqual(state.channel, 2)
clear_req = {"position": {"x": 300, "y": 0}, "channel": 3}
response = {"accepted": True, "real_timestamp_ms": 0,
"virtual_time_s": 189,
"clear_result": CLEAR_NO_TARGET}
state.apply_clear(clear_req, response)
self.assertEqual(state.channel, 2, "/clear 不得改变测向机频道")
def test_rejected_response_never_updates(self):
state = RobotState(position=(10, 20), channel=5, virtual_time_s=77)
rejected = {"accepted": False, "real_timestamp_ms": 1, "virtual_time_s": 0}
request = {"position": {"x": 0, "y": 0}, "channel": 1}
with self.assertRaises(StateError):
state.apply_measure(request, rejected)
with self.assertRaises(StateError):
state.apply_clear(request, rejected)
with self.assertRaises(StateError):
state.apply_enter(rejected)
self.assertEqual((state.position, state.channel, state.virtual_time_s),
((10, 20), 5, 77), "accepted=false 不得更新任何状态")
def test_virtual_time_zero_of_rejection_is_not_a_clock(self):
"""accepted=false 的 virtual_time_s=0 不是当前时刻(回归附件2 4.1)。"""
state = RobotState(virtual_time_s=300)
rejected = {"accepted": False, "real_timestamp_ms": 9, "virtual_time_s": 0}
with self.assertRaises(StateError):
state.apply_measure({"position": {"x": 0, "y": 0}, "channel": 1}, rejected)
self.assertEqual(state.virtual_time_s, 300)
def test_strict_mode_flags_prediction_gap(self):
state = RobotState()
state.entered = True
request = {"position": {"x": 100, "y": 0}, "channel": 1}
bad = {"accepted": True, "real_timestamp_ms": 0, "virtual_time_s": 3,
"measure_result": "no_signal"} # 应为 25s,伪造 3s
with self.assertRaises(StateError):
state.apply_measure(request, bad, strict=True)
class FieldTableSimTests(unittest.TestCase):
"""字段表与错误语义(附件2 5.1/5.3/6.3/7.4)。"""
def setUp(self):
self.sim = LocalSimulator([], robot_id="T001")
self.base = {"arena_id": "default", "robot_id": "T001"}
def _measure(self, **override):
payload = {**self.base, "request_id": "m1",
"position": {"x": 0, "y": 0}, "channel": 1, **override}
return self.sim.handle("/measure", payload)
def test_measure_before_enter_rejected(self):
status, body = self._measure()
self.assertEqual((status, body["accepted"]), (200, False))
self.assertEqual(body["virtual_time_s"], 0)
self.assertEqual(set(body) >= {"accepted", "real_timestamp_ms", "virtual_time_s"}, True)
def test_unknown_field_rejected_but_id_not_consumed(self):
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
status, body = self._measure(extra_field=1) # 未声明字段
self.assertEqual((status, body["accepted"]), (200, False))
status, body = self._measure() # 同 ID 修正后可复用
self.assertEqual((status, body["accepted"]), (200, True))
def test_wrong_robot_id_rejected(self):
status, body = self.sim.handle("/enter", {**self.base, "robot_id": "OTHER",
"request_id": "e1"})
self.assertEqual((status, body["accepted"]), (200, False))
def test_channel_and_coordinate_validation_400(self):
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
for channel in (0, 21, 1.5):
status, _ = self._measure(request_id=f"c{channel}", channel=channel)
self.assertEqual(status, 400)
status, _ = self._measure(request_id="far",
position={"x": 2_000_001, "y": 0})
self.assertEqual(status, 400)
def test_position_unknown_field_rejected(self):
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
status, body = self._measure(request_id="p1",
position={"x": 0, "y": 0, "z": 0})
self.assertEqual((status, body["accepted"]), (200, False))
def test_double_enter_rejected_then_measure_ok(self):
status, body = self.sim.handle("/enter", {**self.base, "request_id": "e1"})
self.assertEqual(body["accepted"], True)
status, body = self.sim.handle("/enter", {**self.base, "request_id": "e2"})
self.assertEqual((status, body["accepted"]), (200, False))
status, body = self._measure(request_id="m1")
self.assertEqual(body["accepted"], True)
class WorkedExampleEndToEndTests(unittest.TestCase):
"""完整链路:客户端 → 本地模拟器复现附件示例。"""
def test_attachment_example_reaches_199(self):
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
client.enter()
self.assertTrue(client.state.entered)
self.assertEqual((client.state.position, client.state.channel), ((0.0, 0.0), 1))
got = [client.measure((300, 400), 1).virtual_time_s,
client.measure((300, 400), 2).virtual_time_s,
client.clear((300, 0), 3).virtual_time_s,
client.measure((300, 0), 2).virtual_time_s]
self.assertEqual([round(v, 6) for v in got], [105, 111, 194, 199])
self.assertEqual(client.state.channel, 2)
info = client.exit()
self.assertEqual((info.reason, round(info.virtual_time_s, 6)),
("user_exit", 199))
self.assertEqual(client.state.mismatches, [], "预测与服务器计时应完全一致")
def test_rejected_action_keeps_client_state(self):
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
with self.assertRaises(ActionRejected):
client.measure((0, 0), 1) # 未 enter
self.assertEqual(client.state.virtual_time_s, 0)
self.assertFalse(client.state.entered)
client.enter()
self.assertEqual(client.state.virtual_time_s, 0)
if __name__ == "__main__":
unittest.main()