refactor(arch): redesign the code structure
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import math
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from mcm26b.geometry import Measurement, solve, smallest_enclosing_circle
|
||||
from mcm26b.geometry.localization import halfplanes, distance
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def test_equilateral_from_legal_bearings(self):
|
||||
h = 20 * math.sqrt(3)
|
||||
stations = [Measurement(-1000, 0, 1),
|
||||
Measurement(540, -500 * math.sqrt(3), 121),
|
||||
Measurement(520, h + 500 * math.sqrt(3), 241)]
|
||||
result = solve(stations)
|
||||
self.assertEqual(result['status'], 'polygon')
|
||||
self.assertEqual(len(result['vertices']), 3)
|
||||
self.assertAlmostEqual(result['diameter_m'], 40, places=7)
|
||||
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'], 40/math.sqrt(3), places=7)
|
||||
self.assertFalse(result['diameter_circle']['covers'])
|
||||
self.assertFalse(result['guaranteed_clearable_at_mec_center'])
|
||||
|
||||
def test_no_observations_and_single_bearing(self):
|
||||
self.assertEqual(solve([])['status'], 'unbounded')
|
||||
r = solve([Measurement(20, 30, 359.8)])
|
||||
self.assertEqual(r['status'], 'unbounded')
|
||||
self.assertTrue(r['diameter_infinite'])
|
||||
self.assertIsNone(r['diameter_m'])
|
||||
|
||||
def test_inconsistent(self):
|
||||
result = solve([Measurement(-100, 0, 180), Measurement(100, 0, 0)])
|
||||
self.assertEqual(result['status'], 'empty')
|
||||
|
||||
def test_exact_segment_and_point(self):
|
||||
segment = [Measurement(0, 0, 0, 0), Measurement(100, 0, 180, 0)]
|
||||
r = solve(segment)
|
||||
self.assertEqual(r['status'], 'segment')
|
||||
self.assertAlmostEqual(r['diameter_m'], 100)
|
||||
self.assertTrue(r['diameter_circle']['covers'])
|
||||
r = solve(segment + [Measurement(30, -30, 90, 0)])
|
||||
self.assertEqual(r['status'], 'point')
|
||||
self.assertAlmostEqual(r['vertices'][0][0], 30)
|
||||
self.assertAlmostEqual(r['diameter_m'], 0)
|
||||
|
||||
def test_forward_ray_not_line(self):
|
||||
r = solve([Measurement(0, 0, 0, 0), Measurement(-1, 0, 180, 0)])
|
||||
self.assertEqual(r['status'], 'empty')
|
||||
|
||||
def test_angle_wrap_and_repeated_station(self):
|
||||
a = [Measurement(-500, -1, 359.9), Measurement(0, -500, 90)]
|
||||
b = [Measurement(-500, -1, -.1), Measurement(0, -500, 450)]
|
||||
self.assertAlmostEqual(solve(a)['diameter_m'], solve(b)['diameter_m'])
|
||||
self.assertAlmostEqual(solve(a+a)['diameter_m'], solve(a)['diameter_m'])
|
||||
|
||||
def test_close_parallel_remains_finite(self):
|
||||
# Two nearly parallel wedges can still be bounded, very far away.
|
||||
r = solve([Measurement(0, 0, 1), Measurement(0, 100, -1.000001)])
|
||||
self.assertEqual(r['status'], 'polygon')
|
||||
self.assertGreater(r['diameter_m'], 1e9)
|
||||
|
||||
def test_circle_known_shapes(self):
|
||||
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
|
||||
self.assertEqual(c, (1,1))
|
||||
self.assertAlmostEqual(r, math.sqrt(2))
|
||||
c, r = smallest_enclosing_circle([(0,0),(2,0),(.4,.2)])
|
||||
self.assertAlmostEqual(r, 1)
|
||||
c, r = smallest_enclosing_circle([(0,0),(1,0),(3,0)])
|
||||
self.assertAlmostEqual(r, 1.5)
|
||||
|
||||
def test_random_bounded_truth_containment_and_monotonicity(self):
|
||||
rng = random.Random(20260910)
|
||||
for _ in range(100):
|
||||
target = (rng.uniform(-300,300), rng.uniform(-300,300))
|
||||
ms = []
|
||||
for i in range(4):
|
||||
ang = (i*90 + rng.uniform(-20,20))*math.pi/180
|
||||
radius = rng.uniform(300,1200)
|
||||
p = (target[0]+radius*math.cos(ang), target[1]+radius*math.sin(ang))
|
||||
bearing = math.degrees(math.atan2(target[1]-p[1],target[0]-p[0]))
|
||||
ms.append(Measurement(*p, bearing+rng.uniform(-.99,.99)))
|
||||
r3, r4 = solve(ms[:3]), solve(ms)
|
||||
self.assertEqual(r4['status'], 'polygon')
|
||||
self.assertLessEqual(r4['diameter_m'], r3['diameter_m']+1e-5)
|
||||
for m in ms:
|
||||
for a,b,c in halfplanes(m):
|
||||
self.assertLessEqual(a*target[0]+b*target[1], c+1e-7)
|
||||
mec = r4['minimum_enclosing_circle']
|
||||
self.assertLessEqual(distance(mec['center'], target), mec['radius_m']+1e-5)
|
||||
for v in r4['vertices']:
|
||||
self.assertLessEqual(distance(mec['center'], v), mec['radius_m']+1e-5)
|
||||
|
||||
def test_translation_rotation_scaling(self):
|
||||
ms = [Measurement(-500,0,1), Measurement(0,-500,89), Measurement(400,400,225)]
|
||||
original = solve(ms)
|
||||
a=.37
|
||||
changed = [Measurement(1e6+3*(m.x*math.cos(a)-m.y*math.sin(a)),
|
||||
-2e6+3*(m.x*math.sin(a)+m.y*math.cos(a)),
|
||||
m.bearing_deg+math.degrees(a)) for m in ms]
|
||||
moved=solve(changed)
|
||||
self.assertAlmostEqual(moved['diameter_m'], original['diameter_m']*3, places=6)
|
||||
|
||||
def test_invalid_input(self):
|
||||
for args in [(float('nan'),0,0),(0,0,float('inf')),(0,0,0,90),(0,0,0,-1),(True,0,0)]:
|
||||
with self.assertRaises(ValueError): Measurement(*args)
|
||||
|
||||
def test_independent_rectangle_clipping_reference(self):
|
||||
# A second construction: clip a demonstrably inactive finite box.
|
||||
# The production algorithm never uses this box.
|
||||
rng=random.Random(4001)
|
||||
for _ in range(60):
|
||||
ms=[]
|
||||
for x,y in [(-500,-300),(600,-200),(0,700)]:
|
||||
ms.append(Measurement(x,y,math.degrees(math.atan2(-y,-x))+rng.uniform(-.9,.9)))
|
||||
poly=[(-2000.,-2000.),(2000.,-2000.),(2000.,2000.),(-2000.,2000.)]
|
||||
for m in ms:
|
||||
for a,b,c in halfplanes(m):
|
||||
out=[]
|
||||
for p,q in zip(poly,poly[1:]+poly[:1]):
|
||||
f=a*p[0]+b*p[1]-c;g=a*q[0]+b*q[1]-c
|
||||
if f<=0:out.append(p)
|
||||
if f*g<0:
|
||||
t=f/(f-g);out.append((p[0]+t*(q[0]-p[0]),p[1]+t*(q[1]-p[1])))
|
||||
poly=out
|
||||
self.assertTrue(poly)
|
||||
self.assertTrue(all(max(abs(x),abs(y))<1900 for x,y in poly))
|
||||
expected=max(math.dist(p,q) for p in poly for q in poly)
|
||||
self.assertAlmostEqual(solve(ms)['diameter_m'],expected,places=6)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,108 @@
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from mcm26b.geometry import (Measurement, solve, smallest_enclosing_circle,
|
||||
first_bearing_candidate_disk)
|
||||
from mcm26b.geometry.localization import _enclosing_circle_reference, distance
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CertificateTests(unittest.TestCase):
|
||||
def test_incremental_circle_against_exhaustive_reference(self):
|
||||
rng = random.Random(271828)
|
||||
for _ in range(250):
|
||||
points = [(rng.uniform(-20, 20), rng.uniform(-20, 20))
|
||||
for _ in range(rng.randrange(1, 20))]
|
||||
expected_c, expected_r = _enclosing_circle_reference(points)
|
||||
center, radius = smallest_enclosing_circle(points)
|
||||
self.assertAlmostEqual(radius, expected_r, places=7)
|
||||
self.assertLess(distance(center, expected_c), 1e-6)
|
||||
self.assertTrue(all(distance(center, p) <= radius for p in points))
|
||||
|
||||
def test_circle_large_cocircular_duplicate_and_collinear_sets(self):
|
||||
# More than Python's recursion limit, with analytically known answers.
|
||||
points = [(1e6+30*math.cos(i*math.tau/1500),
|
||||
-2e6+30*math.sin(i*math.tau/1500)) for i in range(1500)]
|
||||
center, radius = smallest_enclosing_circle(points+points[:100])
|
||||
self.assertLess(distance(center, (1e6, -2e6)), 1e-7)
|
||||
self.assertAlmostEqual(radius, 30, places=7)
|
||||
c, r = smallest_enclosing_circle([(i, i*2) for i in range(1500)])
|
||||
self.assertAlmostEqual(r, 1499*math.sqrt(5)/2)
|
||||
self.assertEqual(c, (749.5, 1499))
|
||||
self.assertEqual(smallest_enclosing_circle([(3, 4)]*10), ((3, 4), 0))
|
||||
|
||||
def test_witness_and_clearance_margin_from_saved_observations(self):
|
||||
path = next((ROOT/'output/q1/practice').glob('*_observations.json'))
|
||||
rows = json.loads(path.read_text(encoding='utf-8'))['measurements']
|
||||
result = solve([Measurement(**m) for m in rows])
|
||||
circle = result['diameter_circle']
|
||||
self.assertFalse(circle['covers'])
|
||||
self.assertIn(circle['witness_vertex'], result['vertices'])
|
||||
self.assertAlmostEqual(distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
|
||||
circle['max_excess_m'])
|
||||
for i in circle['outside_vertex_indices']:
|
||||
self.assertGreater(distance(result['vertices'][i], circle['center']),
|
||||
circle['radius_m']+circle['coverage_tolerance_m'])
|
||||
cert = result['clearance_certificate']
|
||||
self.assertGreater(cert['remaining_margin_m'], 7.9)
|
||||
self.assertTrue(result['guaranteed_clearable_at_mec_center'])
|
||||
self.assertAlmostEqual(cert['remaining_margin_m']+cert['radius_with_margin_m'], 20)
|
||||
|
||||
def test_clearance_at_threshold_is_conservative(self):
|
||||
for length, expected in [(39.999, True), (40, False), (40.001, False)]:
|
||||
r = solve([Measurement(0, 0, 0, 0), Measurement(length, 0, 180, 0)])
|
||||
self.assertEqual(r['guaranteed_clearable_at_mec_center'], expected)
|
||||
self.assertTrue(r['diameter_circle']['covers'])
|
||||
self.assertIsNone(r['diameter_circle']['witness_vertex'])
|
||||
|
||||
def test_first_sector_disk_covers_all_angles_and_distances(self):
|
||||
for angle in [0, 359.9, -180]:
|
||||
for delta in [0, 1, 1.005, 45]:
|
||||
m = Measurement(-800, 1200, angle, delta)
|
||||
cert = first_bearing_candidate_disk(m)
|
||||
enclosing = cert['sector_enclosing_circle']
|
||||
candidate = cert['sufficient_candidate_disk']
|
||||
for j in range(21):
|
||||
for k in range(11):
|
||||
phi = math.radians(angle-delta+2*delta*j/20)
|
||||
p = (m.x+150*k*math.cos(phi), m.y+150*k*math.sin(phi))
|
||||
self.assertLessEqual(distance(p, enclosing['center']), enclosing['radius_m']+1e-9)
|
||||
if candidate:
|
||||
for t in range(8):
|
||||
q = (candidate['center'][0]+candidate['radius_m']*math.cos(t*math.tau/8),
|
||||
candidate['center'][1]+candidate['radius_m']*math.sin(t*math.tau/8))
|
||||
self.assertLessEqual(distance(p, q), 1000+1e-9)
|
||||
if delta <= 1.005:
|
||||
self.assertTrue(cert['candidate_nonempty_certified'])
|
||||
self.assertGreater(candidate['radius_m'], 249.88)
|
||||
|
||||
def test_candidate_equality_and_no_certificate(self):
|
||||
equality = first_bearing_candidate_disk(Measurement(0, 0, 0, 0), max_range_m=2000)
|
||||
self.assertTrue(equality['candidate_nonempty_certified'])
|
||||
self.assertEqual(equality['sufficient_candidate_disk']['radius_m'], 0)
|
||||
no_certificate = first_bearing_candidate_disk(Measurement(0, 0, 0), max_range_m=3000)
|
||||
self.assertIsNone(no_certificate['sufficient_candidate_disk'])
|
||||
self.assertNotIn('candidate_empty', no_certificate)
|
||||
self.assertTrue(no_certificate['distance_only'])
|
||||
with self.assertRaises(ValueError):
|
||||
first_bearing_candidate_disk(Measurement(0, 0, 0, 46))
|
||||
|
||||
def test_cli_explicit_rounding_margin(self):
|
||||
base = [sys.executable, '-m', 'mcm26b.geometry', 'examples/q1/triangle.json']
|
||||
outputs = [json.loads(subprocess.check_output(base+args, cwd=ROOT, encoding='utf-8'))
|
||||
for args in [[], ['--rounding-margin-deg', '0.005']]]
|
||||
self.assertEqual(outputs[0]['error_policy']['effective_half_angles_deg'], [1, 1, 1])
|
||||
self.assertEqual(outputs[1]['error_policy']['effective_half_angles_deg'], [1.005]*3)
|
||||
self.assertGreater(outputs[1]['diameter_m'], outputs[0]['diameter_m'])
|
||||
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT, capture_output=True)
|
||||
self.assertNotEqual(bad.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Offline verification of captured practice evidence; never contacts simulator."""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from mcm26b.geometry import Measurement, solve
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PracticeReplayTests(unittest.TestCase):
|
||||
def test_action_log_and_derived_geometry(self):
|
||||
folder=ROOT/'output/q1/practice'
|
||||
files=list(folder.glob('*_actions.json'))
|
||||
self.assertTrue(files, '缺少本次演练采集证据')
|
||||
for path in files:
|
||||
events=json.loads(path.read_text(encoding='utf-8'))
|
||||
pos=(0,0);channel=1;virtual=0
|
||||
self.assertEqual(events[0]['path'],'/enter')
|
||||
self.assertEqual(events[-1]['path'],'/exit')
|
||||
self.assertEqual(events[-1]['response']['exit_reason'],'user_exit')
|
||||
ids=[]
|
||||
for event in events:
|
||||
self.assertIn(event['path'],('/enter','/measure','/exit'))
|
||||
request,response=event['request'],event['response']
|
||||
self.assertTrue(response['accepted'])
|
||||
ids.append(request['request_id'])
|
||||
if event['path']=='/measure':
|
||||
new=(request['position']['x'],request['position']['y'])
|
||||
virtual+=math.dist(pos,new)/5+5+(request['channel']!=channel)
|
||||
pos,channel=new,request['channel']
|
||||
self.assertAlmostEqual(virtual,response['virtual_time_s'],places=5)
|
||||
self.assertEqual(len(ids),len(set(ids)))
|
||||
observation_path=path.with_name(path.name.replace('_actions','_observations'))
|
||||
data=json.loads(observation_path.read_text(encoding='utf-8'))
|
||||
ms=[Measurement(**m) for m in data['measurements']]
|
||||
recomputed=solve(ms)
|
||||
self.assertAlmostEqual(recomputed['diameter_m'],data['result']['diameter_m'],places=9)
|
||||
self.assertEqual(data['source'],'official_practice')
|
||||
self.assertIsNone(data['ground_truth'])
|
||||
directions=[e for e in events if e['response'].get('measure_result')=='direction']
|
||||
self.assertEqual(directions[0]['request']['position'],directions[1]['request']['position'])
|
||||
self.assertEqual(directions[0]['response']['svd_deg'],directions[1]['response']['svd_deg'])
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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.sim import (LocalSimTransport, LocalSimulator,
|
||||
PlaybackTransport, ScriptedFaultTransport, 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 = [body for body in 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, faults, 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, faults, simulator = 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, faults, 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, faults, simulator = 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_cannot_be_kept(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"))
|
||||
self.assertTrue(files, "缺少演练采集证据")
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""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.sim 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()
|
||||
Reference in New Issue
Block a user