This commit is contained in:
Horldsence
2026-09-11 11:36:08 +08:00
parent 2d2a9a4e6a
commit a18a139a1d
93 changed files with 9245 additions and 3806 deletions
+131
View File
@@ -0,0 +1,131 @@
"""真实 HTTP 承载测试:接口仅回环、仅窗口期开放、协议边界与官方一致。"""
import http.client
import json
import time
import pytest
from referee import Referee
from referee.server import ActionInterfaceServer
def wait_until(condition, timeout=5.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
return False
@pytest.fixture()
def referee(tmp_path):
ref = Referee(action_port=0, data_dir=tmp_path / "data")
ActionInterfaceServer(ref)
return ref
def open_port(server):
assert server._httpd is not None
return server._httpd.server_address[1]
def post(port, path, obj=None, *, headers=None, method="POST", raw=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
body = raw if raw is not None else json.dumps(obj or {}).encode("utf-8")
conn.request(method, path, body=body,
headers=headers or {"Content-Type": "application/json"})
response = conn.getresponse()
data = response.read()
conn.close()
parsed = json.loads(data) if data else None
return response.status, parsed
def payload(id="e", **extra):
return dict(arena_id="default", robot_id="2026001", request_id=id, **extra)
def start_open_session(referee, **opts):
opts.setdefault("countdown_s", 0.05)
session = referee.start("q3_practice", "2026001", **opts)
assert wait_until(lambda: session.phase == "open")
return session
def test_binding_is_loopback_only(referee):
session = start_open_session(referee)
server = referee._open_interface.__self__ # ActionInterfaceServer
port = open_port(server)
assert server._httpd.server_address[0] == "127.0.0.1"
status, body = post(port, "/enter", payload("enter"))
assert status == 200 and body["accepted"] is True
assert body["remaining_real_duration_s"] == 1200
def test_interface_refuses_connections_outside_window(referee):
session = referee.start("q3_practice", "2026001", countdown_s=5)
server = referee._open_interface.__self__
assert server.is_open is False # 倒计时期间未监听
assert wait_until(lambda: session.phase == "open", timeout=6)
assert server.is_open is True # 窗口开启后才绑定端口
def test_full_protocol_round_trip_over_http(referee):
session = start_open_session(referee)
server = referee._open_interface.__self__
port = open_port(server)
status, body = post(port, "/enter", payload("enter-1"))
assert status == 200 and body["accepted"] is True
assert body["virtual_time_s"] == 0
# 远点检测:目标域内源 r_eff<=1500,必然 no_signal
status, body = post(port, "/measure", payload(
"m-1", position=dict(x=5000, y=0), channel=1))
assert status == 200 and body["measure_result"] == "no_signal"
assert body["virtual_time_s"] == 1005 # 移动 1000s + 检测 5s
status, body = post(port, "/clear", payload(
"c-1", position=dict(x=5000, y=0), channel=2))
assert body["clear_result"] == "no_target_in_range"
assert body["virtual_time_s"] == 1008 # 未发现 3s
status, body = post(port, "/exit", payload("exit-1"))
assert body["exit_reason"] == "user_exit"
assert wait_until(lambda: session.phase == "finished")
assert session.end_reason == "user_exit"
def test_protocol_error_statuses_over_http(referee):
start_open_session(referee)
server = referee._open_interface.__self__
port = open_port(server)
assert post(port, "/unknown", payload())[0] == 404
assert post(port, "/enter/", payload())[0] == 404
assert post(port, "/enter", payload(), method="GET")[0] == 405
assert post(port, "/enter", payload(),
headers={"Content-Type": "text/plain"})[0] == 415
assert post(port, "/enter", raw=b'{"request_id":"a","request_id":"b"}')[0] == 400
assert post(port, "/enter", raw=b"{}" + b" " * 65535)[0] == 413
status, body = post(port, "/enter", payload("real"))
assert status == 200 and body["accepted"] is True
def test_interface_closes_after_window_timeout(referee):
session = start_open_session(referee, window_s=0.4)
server = referee._open_interface.__self__
port = open_port(server)
assert wait_until(lambda: session.phase == "finished", timeout=5)
assert session.end_reason == "window_timeout"
assert wait_until(lambda: server._httpd is None)
with pytest.raises(OSError):
post(port, "/enter", payload("late"))
def test_recorded_feed_contains_every_request(referee):
session = start_open_session(referee)
server = referee._open_interface.__self__
port = open_port(server)
post(port, "/enter", payload("enter-1"))
post(port, "/unknown", payload("bad"))
assert wait_until(lambda: session.request_count >= 2)
paths = [(entry["path"], entry["status"]) for entry in session.feed]
assert ("/enter", 200) in paths and ("/unknown", 404) in paths
+90
View File
@@ -0,0 +1,90 @@
"""接口边界(与传输无关):handle_raw 承载附件2 §5 的全部语法层规则。
参赛队的内存传输与本包 HTTP 服务器共用这一实现,两条路径行为一致。
"""
import json
import pytest
from referee import LocalSimulator, SimulatorClosed
from referee.interface import handle_raw
def payload(id="e", **extra):
return dict(arena_id="default", robot_id="2026001", request_id=id, **extra)
def post(sim, path, body=None, headers=None, method="POST", target=None):
raw = json.dumps(body if body is not None else payload()).encode("utf-8")
return handle_raw(sim, method, target or path,
headers or [("Content-Type", "application/json")], raw)
def test_raw_json_format_errors():
sim = LocalSimulator([])
cases = [
(b'{"request_id":"a","request_id":"b"}', 400), # 重复键
(b'{"position":{"x":1,"x":2}}', 400), # 嵌套重复键
(b'{}' + b' ' * 65535, 413), # 超 65536 字节
(b'\xef\xbb\xbf{}', 400), (b'\xff', 400), (b'{', 400), # BOM/坏编码/语法
(b'[]', 400), (b'{"x":NaN}', 400), (b'{"x":Infinity}', 400),
(b'{"a":' + b'[' * 16 + b'0' + b']' * 16 + b'}', 400), # 深度 17
]
for raw, expected in cases:
result = handle_raw(sim, "POST", "/enter", [("Content-Type", "application/json")], raw)
assert result.status == expected, (raw[:40], result.status)
body = json.loads(result.body)
assert body["accepted"] is False
assert set(body) >= {"accepted", "real_timestamp_ms", "virtual_time_s"}
assert not sim.entered
def test_raw_size_and_depth_boundaries_are_inclusive():
sim = LocalSimulator([])
headers = [("Content-Type", "application/json")]
deep = b'{"extra":' + b'[' * 15 + b'0' + b']' * 15 + b'}' # 深度 16 合法
body = json.dumps({**payload(), **json.loads(deep)}).encode()
assert handle_raw(sim, "POST", "/enter", headers, body).status == 200
exact = json.dumps(payload()).encode().ljust(65536) # 恰好 65536 字节
result = handle_raw(sim, "POST", "/enter", headers, exact)
assert result.status == 200 and json.loads(result.body)["accepted"]
@pytest.mark.parametrize("method,path,headers,status", [
("POST", "/enter?x=1", None, 404),
("POST", "/enter?", None, 404),
("POST", "/enter/", None, 404),
("POST", "/measure/x", None, 404),
("GET", "/enter", None, 405),
("PUT", "/clear", None, 405),
("POST", "/enter", [("Content-Type", "text/plain")], 415),
("POST", "/enter", [("Content-Type", "application/json;foo=bar")], 415),
("POST", "/enter", [("Content-Type", "application/json"), ("Content-Type", "application/json")], 415),
("POST", "/enter", [("Content-Type", "application/json"), ("Content-Encoding", "gzip")], 415),
("POST", "/enter", [("content-type", "Application/JSON; charset=UTF-8")], 200),
("POST", "/enter", [("content-type", 'application/json;charset="utf-8"')], 200),
])
def test_http_boundary(method, path, headers, status):
sim = LocalSimulator([])
raw = json.dumps(payload()).encode()
result = handle_raw(sim, method, path, headers or [("Content-Type", "application/json")], raw)
assert result.status == status
def test_closed_simulator_raises_connection_level_error():
sim = LocalSimulator([], clock=lambda: 9999, window_started_at=0)
with pytest.raises(SimulatorClosed):
handle_raw(sim, "POST", "/enter",
[("Content-Type", "application/json")], json.dumps(payload()).encode())
def test_on_request_callback_receives_parsed_payload():
sim = LocalSimulator([])
seen = []
raw = json.dumps(payload("enter-1")).encode()
handle_raw(sim, "POST", "/enter", [("Content-Type", "application/json")], raw,
on_request=lambda path, payload_dict, result: seen.append((path, payload_dict, result)))
assert seen[0][0] == "/enter"
assert seen[0][1]["request_id"] == "enter-1"
assert seen[0][2].status == 200
+56
View File
@@ -0,0 +1,56 @@
"""JSON 序列化与解析的规范化行为 [题面 附件2 4.1/5.1]。"""
import json
import pytest
from referee.jsonio import (RequestFormatError, dumps, format_number,
parse_request_object)
@pytest.mark.parametrize("value,text", [
(105, "105"), (0, "0"), (5.0, "5"), (105.5, "105.5"),
(5282.843, "5282.843"), # 微秒时刻的最短表示
(0.300001, "0.300001"),
(123.45, "123.45"), # svd_deg 两位小数
(True, None), # 布尔不是数值,放这里只为参数化完整
])
def test_format_number(value, text):
if text is None:
return
assert format_number(value) == text
def test_dumps_matches_official_number_style():
body = {"accepted": True, "virtual_time_s": 105.0,
"real_timestamp_ms": 1760000000000, "svd_deg": 123.45,
"note": "中文", "ok": False, "none": None, "list": [1, 2.5]}
text = dumps(body)
assert '"virtual_time_s":105' in text # 不输出 105.0
assert '"svd_deg":123.45' in text
assert '"real_timestamp_ms":1760000000000' in text
assert '"note":"中文"' in text # 非 ASCII 不转义
assert json.loads(text) == body # 解析后语义不变
@pytest.mark.parametrize("raw", [
b'{"a":1,"a":2}', # 重复键
b'\xef\xbb\xbf{}', # BOM
b'[]', b'"str"', b'3', b'null', # 非对象
b'{"a":NaN}', b'{"a":Infinity}',
b'{"a":' + b'[' * 17 + b'0' + b']' * 17 + b'}', # 深度 17
b'{"a":', b'', b'\xff',
])
def test_parse_rejections(raw):
with pytest.raises(RequestFormatError):
parse_request_object(raw)
def test_parse_depth_16_is_legal():
payload = json.loads(b'{"extra":' + b'[' * 15 + b'0' + b']' * 15 + b'}')
assert parse_request_object(json.dumps(payload).encode()) == payload
def test_parse_rejects_duplicated_nested_keys():
with pytest.raises(RequestFormatError):
parse_request_object(b'{"position":{"x":1,"x":2}}')
+36
View File
@@ -0,0 +1,36 @@
"""场景生成:源数量、频道、位置与接收半径满足题面范围。"""
import math
from referee import DEFAULT_SPEC, mixed_scene, random_omni_scene
def test_random_omni_scene_within_spec():
for seed in range(5):
sources = random_omni_scene(seed=seed)
assert 10 <= len(sources) <= 16 # 源总数范围 [题面]
channels = [s.channel for s in sources]
assert len(set(channels)) == len(channels) # 每频道至多一个源
assert all(1 <= c <= 20 for c in channels)
for s in sources:
assert math.hypot(s.x, s.y) <= DEFAULT_SPEC.field.omega_radius_m
assert 1000 <= s.r_eff_m <= 1500
assert not s.directional
def test_scene_is_reproducible_by_seed():
a = random_omni_scene(seed=123, count=12)
b = random_omni_scene(seed=123, count=12)
assert a == b
def test_mixed_scene_directional_count():
sources = mixed_scene(seed=7, count=12, directional=4)
assert sum(1 for s in sources if s.directional) == 4
assert all(0 <= s.heading_deg < 360 for s in sources if s.directional)
def test_count_is_validated():
import pytest
with pytest.raises(ValueError):
random_omni_scene(seed=1, count=0)
+191
View File
@@ -0,0 +1,191 @@
"""会话状态机测试:官方流程(倒计时/窗口/结束原因/真值策略/次数/日志)。"""
import json
import time
import pytest
from referee import (FORMAL_ATTEMPTS, FieldSpec, Referee, RefereeError, Spec)
from referee.interface import InterfaceResult
from referee.session import MODULES
def wait_until(condition, timeout=5.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
return False
def fake_result(response, status=200):
from referee.jsonio import dumps
return InterfaceResult(status, dumps(response).encode("utf-8"), response)
def test_countdown_then_open_then_window_timeout(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
opened = []
referee.set_interface_callbacks(lambda s: opened.append(1), lambda s: None)
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=0.3)
assert session.phase == "countdown"
assert wait_until(lambda: session.phase == "open")
assert opened == [1]
assert session.simulator is not None
assert wait_until(lambda: session.phase == "finished", timeout=3)
assert session.end_reason == "window_timeout"
def test_abort_during_countdown_and_running(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
session = referee.start("q3_practice", "2026001", countdown_s=5)
assert referee.abort() is True
assert session.phase == "finished" and session.end_reason == "manual_abort"
assert referee.abort() is False # 已结束,不能重复中止
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=30)
assert wait_until(lambda: session.phase == "open")
assert referee.abort() is True
assert session.end_reason == "manual_abort"
def test_cannot_start_while_running_and_restart_after_finish(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
first = referee.start("q3_practice", "2026001", countdown_s=5)
with pytest.raises(RefereeError):
referee.start("q3_practice", "2026001", countdown_s=0.05)
assert referee.abort()
second = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=1)
assert second.case_code != first.case_code # 新的一局、新的案例编码
referee.abort()
# 结束后不能复位继续原测试,只能开始新的一局 [题面 4.5]
assert referee.session.phase == "finished"
def test_formal_attempts_accounting(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
for _ in range(FORMAL_ATTEMPTS):
session = referee.start("q3_formal", "2026001", countdown_s=0.05)
referee.abort() # 正式中止仍占用机会 [题面 4.5]
with pytest.raises(RefereeError):
referee.start("q3_formal", "2026001", countdown_s=0.05)
# 演练不限次数
for _ in range(FORMAL_ATTEMPTS + 2):
referee.start("q3_practice", "2026001", countdown_s=0.05)
referee.abort()
def test_truth_policy_practice_vs_formal(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
practice = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=5,
seed=42, source_count=12)
assert wait_until(lambda: practice.phase == "open")
referee.abort()
state = referee.snapshot()
assert state["truth"]["revealed"] is True # 演练结束显示真值统计
assert state["truth"]["source_count"] == 12
assert {"omni_count", "directional_count"} <= set(state["truth"])
formal = referee.start("q4_formal", "2026001", countdown_s=0.05, seed=42)
assert wait_until(lambda: formal.phase == "open")
# 运行中考官视图也不显示 [题面 4.6:正式测试任何界面不显示真值]
assert referee.snapshot(examiner=True)["truth"]["revealed"] is False
referee.abort()
assert referee.snapshot(examiner=True)["truth"]["revealed"] is False
assert "sources" not in referee.snapshot()["truth"]
def test_exit_finishes_session_with_user_exit(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=20)
assert wait_until(lambda: session.phase == "open")
payload = dict(arena_id="default", robot_id="2026001", request_id="enter")
session.simulator.handle("/enter", payload)
session.simulator.handle("/exit", {**payload, "request_id": "exit"})
assert wait_until(lambda: session.phase == "finished")
assert session.end_reason == "user_exit"
def test_program_timeout_after_enter(tmp_path):
spec = Spec(field=FieldSpec(max_real_duration_s=0.3))
referee = Referee(action_port=0, data_dir=tmp_path, spec=spec)
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=10)
assert wait_until(lambda: session.phase == "open")
session.simulator.handle("/enter", dict(arena_id="default", robot_id="2026001",
request_id="enter"))
assert wait_until(lambda: session.phase == "finished", timeout=5)
assert session.end_reason == "program_timeout"
def test_record_action_updates_feed_trail_and_counts(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=5)
assert wait_until(lambda: session.phase == "open")
sim = session.simulator
referee.record_action(session, "POST", "/measure",
{"request_id": "m1", "position": {"x": 5000, "y": 0},
"channel": 1},
fake_result({"accepted": True, "virtual_time_s": 105.0,
"measure_result": "no_signal"}))
referee.record_action(session, "POST", "/clear",
{"request_id": "c1", "position": {"x": 5000, "y": 0},
"channel": 2},
fake_result({"accepted": True, "virtual_time_s": 113.0,
"clear_result": "no_target_in_range"}))
assert session.request_count == 2
assert session.measure_count == 1 and session.clear_count == 1
assert len(session.trail) == 2
state = referee.snapshot(feed_limit=10)
assert len(state["feed"]) == 2
assert state["feed"][-1]["response"]["clear_result"] == "no_target_in_range"
def test_behavior_log_and_meta_written(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
session = referee.start("q3_practice", "2026001", countdown_s=0.05, window_s=1,
seed=7, source_count=10)
assert wait_until(lambda: session.phase == "open")
referee.record_action(session, "POST", "/measure",
{"request_id": "m1", "position": {"x": 0, "y": 0},
"channel": 1},
fake_result({"accepted": True, "virtual_time_s": 5.0,
"measure_result": "no_signal"}))
referee.abort()
log_lines = [json.loads(line) for line in
session.log_path.read_text(encoding="utf-8").splitlines()]
events = [entry["event"] for entry in log_lines]
assert events[0] == "session_start" and events[-1] == "session_end"
assert "action" in events
assert log_lines[0]["truth"][0]["channel"] >= 1 # 评测机侧真值留档
meta = json.loads(session.log_path.with_name("meta.json").read_text(encoding="utf-8"))
assert meta["module"] == "q3_practice" and len(meta["truth"]) == 10
def test_display_limit_and_port_settings(tmp_path):
referee = Referee(action_port=2026, data_dir=tmp_path)
referee.set_display_limit(100)
with pytest.raises(RefereeError):
referee.set_display_limit(50)
with pytest.raises(RefereeError):
referee.set_display_limit(5001)
session = referee.start("q3_practice", "2026001", countdown_s=5)
with pytest.raises(RefereeError):
referee.set_action_port(8080) # 测试进行中不能改端口
referee.abort()
referee.set_action_port(8080)
assert referee.action_port == 8080
def test_robot_id_validation(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path)
with pytest.raises(RefereeError):
referee.start("q3_practice", "", countdown_s=0.05)
with pytest.raises(RefereeError):
referee.start("q3_practice", "队号\n", countdown_s=0.05)
with pytest.raises(RefereeError):
referee.start("q3_practice", "x" * 65, countdown_s=0.05)
def test_modules_registry():
assert set(MODULES) == {"q3_practice", "q4_practice", "q3_formal", "q4_formal"}
assert MODULES["q3_formal"].formal and not MODULES["q3_practice"].formal
+271
View File
@@ -0,0 +1,271 @@
"""评测引擎一致性:时限、状态机、观测判定、幂等与量化(移植自参赛队仓库并扩展)。"""
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from threading import Event
import pytest
from referee import FieldSpec, LocalSimulator, SimulatorClosed, Source, Spec
def payload(id="e", **extra):
return dict(arena_id="default", robot_id="2026001", request_id=id, **extra)
def measure(id="m", x=0, y=0, channel=1):
return payload(id, position=dict(x=x, y=y), channel=channel)
# ---------------------------------------------------------------- 时限 [题面 附件1 2.5]
def test_late_enter_and_deadline():
now = [1400.25]
sim = LocalSimulator([], clock=lambda: now[0], wall_clock=lambda: 1234.5,
window_started_at=0)
response = sim.handle("/enter", payload())[1]
assert response["remaining_real_duration_s"] == 99 # 窗口剩余取早
assert response["real_timestamp_ms"] == 1234500
assert response["max_virtual_duration_s"] == 360000
assert response["max_real_duration_s"] == 1200
now[0] = 1500
for path, body in [("/measure", measure()), ("/exit", payload("x"))]:
with pytest.raises(SimulatorClosed):
sim.handle(path, body)
assert sim.virtual_time_s == 0
def test_program_deadline_and_subsecond_enter():
now = [100]
sim = LocalSimulator([], clock=lambda: now[0], window_started_at=0)
assert sim.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1200
now[0] = 1300
with pytest.raises(SimulatorClosed):
sim.handle("/measure", measure())
late = LocalSimulator([], clock=lambda: 1499.5, window_started_at=0)
assert late.handle("/enter", payload())[1]["remaining_real_duration_s"] == 0
expired = LocalSimulator([], clock=lambda: 1500, window_started_at=0)
with pytest.raises(SimulatorClosed):
expired.handle("/enter", payload())
def test_stress_mode_is_explicit():
spec = Spec(field=FieldSpec(max_real_duration_s=1800))
official = LocalSimulator([], spec=spec, clock=lambda: 0)
stress = LocalSimulator([], spec=spec, clock=lambda: 0, stress_mode=True)
assert official.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1200
assert stress.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1800
def test_virtual_crossing_action_finishes_then_connection_closes():
sim = LocalSimulator([])
sim.handle("/enter", payload())
assert sim.handle("/measure", measure(x=1_800_000))[1]["virtual_time_s"] == 360005
for path, body in [("/measure", measure("m2")), ("/exit", payload("x"))]:
with pytest.raises(SimulatorClosed):
sim.handle(path, body)
assert not sim.exited
def test_explicit_exit_closes_new_requests():
sim = LocalSimulator([])
sim.handle("/enter", payload())
assert sim.handle("/exit", payload("exit"))[1]["exit_reason"] == "user_exit"
assert sim.closed_reason == "user_exit"
with pytest.raises(SimulatorClosed):
sim.handle("/measure", measure())
# ---------------------------------------------------------------- 量化与幂等
def test_microseconds_accumulate_and_signed_zero_is_same_location():
sim = LocalSimulator([Source(1, 1000, 0, 1500)])
sim.handle("/enter", payload())
a = sim.handle("/measure", measure("a", 0.0))[1]
b = sim.handle("/measure", measure("b", -0.0))[1]
assert a["svd_deg"] == b["svd_deg"]
for i in range(100):
point = 1 if i % 2 == 0 else 0
sim.handle("/measure", measure(str(i), point, point))
assert sim.virtual_time_s == 10 + 100 * 5282843 / 1000000
def test_error_rejection_does_not_consume_id():
sim = LocalSimulator([])
for bad in ["e\n", "e\u200b", "e\u0085", "e\ud800"]:
assert sim.handle("/enter", payload(bad))[0] == 400
assert sim._idempotency == {}
assert sim.handle("/enter", payload())[1]["accepted"]
def test_idempotent_replay_and_conflict():
sim = LocalSimulator([])
sim.handle("/enter", payload("enter"))
first = sim.handle("/measure", measure("m1", 100, 0))[1]
replay = sim.handle("/measure", measure("m1", 100, 0))[1]
assert replay == first # 同 ID 同内容:回放首次响应
assert sim.virtual_time_s == 25 # 100m/5=20s + 检测5s;不重复推进
conflict = measure("m1", 200, 0)
assert sim.handle("/measure", conflict)[0] == 409 # 同 ID 不同内容
assert sim.handle("/measure", measure("m2", 200, 0))[1]["accepted"]
def test_idempotency_only_accepted_actions_occupy_id():
sim = LocalSimulator([])
sim.handle("/enter", payload("enter"))
# 未知字段:200 accepted=false,不占用 ID [题面 附件2 5.3]
rejected = payload("m1", extra=1, position=dict(x=0, y=0), channel=1)
assert sim.handle("/measure", rejected)[1]["accepted"] is False
assert sim.handle("/measure", measure("m1"))[1]["accepted"]
def test_concurrent_registration_and_action_finishing_after_deadline():
now = [0]
sim = LocalSimulator([], clock=lambda: now[0])
sim.handle("/enter", payload())
started, release = Event(), Event()
observe = sim._observe
calls = []
def blocking_observe(pos, channel):
calls.append(1)
started.set()
assert release.wait(5)
return observe(pos, channel)
sim._observe = blocking_observe
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(sim.handle, "/measure", measure())
try:
assert started.wait(5)
assert sim.handle("/measure", measure("other"))[0] == 409
assert sim.handle("/measure", measure(x=1))[0] == 409
waiting = Event()
completed = sim._pending["done"]
class ObservedEvent:
def wait(self):
waiting.set()
return completed.wait(5)
def set(self):
completed.set()
sim._pending["done"] = ObservedEvent()
duplicate = pool.submit(sim.handle, "/measure", measure())
assert waiting.wait(5)
finally:
release.set()
assert first.result(timeout=5) == duplicate.result(timeout=5)
assert calls == [1]
assert sim.virtual_time_s == 5
# 执行中跨过现实截止仍返回结果,后续新请求断连。
sim._observe = lambda pos, channel: (now.__setitem__(0, 1200) or observe(pos, channel))
assert sim.handle("/measure", measure("last"))[1]["accepted"]
with pytest.raises(SimulatorClosed):
sim.handle("/exit", payload("exit"))
# ---------------------------------------------------------------- 校验与业务拒绝
def test_huge_numeric_fields_return_bad_request():
sim = LocalSimulator([])
sim.handle("/enter", payload())
huge = 10 ** 400
assert sim.handle("/measure", measure(x=huge))[0] == 400
body = measure()
body["channel"] = huge
assert sim.handle("/measure", body)[0] == 400
assert sim.handle("/measure", measure())[1]["accepted"]
@pytest.mark.parametrize("body,expect", [
(dict(arena_id="default", robot_id="2026001", request_id="e", extra=1), False),
(dict(arena_id="other", robot_id="2026001", request_id="e"), False),
(dict(arena_id="default", robot_id="someone-else", request_id="e"), False),
])
def test_unknown_fields_and_mismatch_are_business_rejected(body, expect):
sim = LocalSimulator([])
status, response = sim.handle("/enter", body)
assert status == 200 and response["accepted"] is False
assert response["virtual_time_s"] == 0
def test_channel_and_position_validation():
sim = LocalSimulator([])
sim.handle("/enter", payload())
cases = [
dict(position=dict(x=0, y=0), channel=0),
dict(position=dict(x=0, y=0), channel=21),
dict(position=dict(x=0, y=0), channel=1.5),
dict(position=dict(x=0, y=0), channel=True),
dict(position=dict(x=float("nan"), y=0), channel=1),
dict(position=dict(x=0, y=float("inf")), channel=1),
dict(position=dict(x=2_000_001, y=0), channel=1),
dict(position=dict(x=0), channel=1),
dict(position="origin", channel=1),
]
for extra in cases:
assert sim.handle("/measure", payload("m", **extra))[0] == 400
assert sim.handle("/measure", payload("ok", position=dict(x=0, y=0), channel=1.0))[1]["accepted"]
def test_actions_before_enter_are_business_rejected():
sim = LocalSimulator([])
for path, body in [("/measure", measure()), ("/clear", payload("c", position=dict(x=0, y=0), channel=1)),
("/exit", payload("x"))]:
status, response = sim.handle(path, body)
assert status == 200 and response["accepted"] is False
def test_clear_rules():
sim = LocalSimulator([Source(5, 10, 0, 1500), Source(6, 15, 0, 1500, directional=True,
heading_deg=180)])
sim.handle("/enter", payload())
# 20m 内清除成功,且与定向朝向无关(源6 朝向 180°,机器人从西侧接近仍可清除)
ok = sim.handle("/clear", payload("c5", position=dict(x=25, y=0), channel=5))[1]
assert ok["clear_result"] == "success"
again = sim.handle("/clear", payload("c5b", position=dict(x=10, y=0), channel=5))[1]
assert again["clear_result"] == "no_target_in_range" # 同一源只能清除一次
assert sim.handle("/clear", payload("c6", position=dict(x=15, y=0), channel=6))[1]["clear_result"] == "success"
miss = sim.handle("/clear", payload("c9", position=dict(x=0, y=0), channel=9))[1]
assert miss["clear_result"] == "no_target_in_range"
assert sim.channel == 1 # /clear 不切换测向机频道 [题面 附件1 2.3]
assert sim.position == (0.0, 0.0)
def test_measure_observation_matrix():
sources = [Source(1, 1000, 0, 1500), # 全向,r_eff=1500
Source(2, 100, 0, 1500, directional=True, heading_deg=0), # 定向朝东
Source(3, 5000, 0, 1500)]
sim = LocalSimulator(sources, error_half_deg=0.0)
sim.handle("/enter", payload("enter"))
# 超出有效接收半径
assert sim.handle("/measure", measure("a", -600, 0, channel=1))[1]["measure_result"] == "no_signal"
# 接收半径边界:distance == r_eff 仍有信号 [题面 附件2 2.1 "超过"]
assert sim.handle("/measure", measure("b", -500, 0, channel=1))[1]["measure_result"] == "direction"
# 覆盖角边界:源2 朝东,正东方向(bearing=0°)恰在 ±90° 覆盖内
assert sim.handle("/measure", measure("c", 200, 0, channel=2))[1]["measure_result"] == "direction"
# 覆盖角边界:bearing=90°(正北)仍在覆盖内(含边界)[题面 附件2 2.2]
assert sim.handle("/measure", measure("c2", 100, 100, channel=2))[1]["measure_result"] == "direction"
# 覆盖角外(bearing≈135° > 90°):无信号
assert sim.handle("/measure", measure("d", 90, 10, channel=2))[1]["measure_result"] == "no_signal"
# 距离过近:不返回 svd_deg [题面 附件2 2.4]
near = sim.handle("/measure", measure("near", 998, 0, channel=1))[1]
assert near["measure_result"] == "near" and "svd_deg" not in near
# 有源但超有效接收半径(源3 在 5000m 外,测点在原点)
assert sim.handle("/measure", measure("f", 0, 0, channel=3))[1]["measure_result"] == "no_signal"
def test_svd_decimals_and_normalization():
sim = LocalSimulator([Source(1, -10, 0, 1500)], error_half_deg=0.0)
sim.handle("/enter", payload())
response = sim.handle("/measure", measure("m", 10, 0))[1]
assert response["measure_result"] == "direction"
assert response["svd_deg"] == 180.0 # [0,360) 归一化,西为 180°
def test_error_field_is_fixed_per_location():
sim = LocalSimulator([Source(1, 1000, 0, 1500)], error_seed=7)
sim.handle("/enter", payload())
first = sim.handle("/measure", measure("m1", 500, 0))[1]["svd_deg"]
sim.handle("/measure", measure("away", 300, 300))
again = sim.handle("/measure", measure("m1", 500, 0))[1]["svd_deg"]
assert first == again # 同一地点重复测量读数不变 [题面]
+45
View File
@@ -0,0 +1,45 @@
"""计时锚点:附件1 表2 / 附件2 第10节示例必须逐步复现 105/111/194/199。"""
from referee import CostBreakdown, LocalSimulator, Source, clear_cost, measure_cost
from referee.timing import move_time
def payload(id="e", **extra):
return dict(arena_id="default", robot_id="2026001", request_id=id, **extra)
def measure(id="m", x=0, y=0, channel=1):
return payload(id, position=dict(x=x, y=y), channel=channel)
def test_attachment_timing_example():
"""附件2 第10节:(0,0)→(300,400) 检测ch1 → 原地检测ch2 → (300,0)清除ch3(未发现)
→ 原地检测ch2 → /exit,虚拟时刻 105/111/194/199。"""
sim = LocalSimulator([Source(3, 10_000, 10_000, 1500)]) # 远源,保证"未发现"
sim.handle("/enter", payload())
assert sim.handle("/measure", measure("m1", 300, 400))[1]["virtual_time_s"] == 105
assert sim.handle("/measure", measure("m2", 300, 400, channel=2))[1]["virtual_time_s"] == 111
clear = payload("c1", position=dict(x=300, y=0), channel=3)
assert sim.handle("/clear", clear)[1]["virtual_time_s"] == 194
assert sim.handle("/measure", measure("m3", 300, 0, channel=2))[1]["virtual_time_s"] == 199
exit_info = sim.handle("/exit", payload("x"))[1]
assert exit_info["virtual_time_s"] == 199
assert exit_info["exit_reason"] == "user_exit"
def test_cost_breakdown_components():
breakdown = measure_cost((0, 0), (300, 400), 1, 1)
assert (breakdown.move_s, breakdown.switch_s, breakdown.action_s) == (100.0, 0.0, 5.0)
breakdown = measure_cost((300, 400), (300, 400), 1, 2)
assert (breakdown.move_s, breakdown.switch_s, breakdown.action_s) == (0.0, 1.0, 5.0)
assert clear_cost((300, 400), (300, 0), success=False).action_s == 3.0
assert clear_cost((300, 0), (300, 0), success=True).action_s == 5.0
assert move_time((0, 0), (0, 50)) == 10.0
def test_microsecond_quantization_is_per_segment():
"""逐段按整数微秒量化后累计 [题面 附件2 4.1],而非先加和再量化。"""
parts = CostBreakdown(move_s=0.0000004, switch_s=0.0000004, action_s=0.0000004)
assert parts.total_us == 0 + 0 + 0
parts = CostBreakdown(move_s=0.0000006, switch_s=0.0000006, action_s=0.0000006)
assert parts.total_us == 3
+153
View File
@@ -0,0 +1,153 @@
"""WebUI API 测试:状态快照、启动/中止/配置、会话列表与回放数据源。"""
import http.client
import json
import time
import pytest
from referee import Referee
from referee.server import ActionInterfaceServer
from referee.webui import WebUIServer
def wait_until(condition, timeout=5.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
return False
@pytest.fixture()
def ui(tmp_path):
referee = Referee(action_port=0, data_dir=tmp_path / "data")
ActionInterfaceServer(referee)
server = WebUIServer(referee, 0)
import threading
threading.Thread(target=server.serve_forever, daemon=True).start()
port = server.httpd.server_address[1]
yield referee, port
server.shutdown()
def call(port, method, path, obj=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
body = json.dumps(obj).encode("utf-8") if obj is not None else None
conn.request(method, path, body=body,
headers={"Content-Type": "application/json"})
response = conn.getresponse()
data = response.read()
conn.close()
return response.status, json.loads(data) if data else None
def test_state_idle_snapshot(ui):
referee, port = ui
status, state = call(port, "GET", "/ui/api/state")
assert status == 200
assert state["phase"] == "idle"
assert set(state["modules"]) == {"q3_practice", "q4_practice", "q3_formal", "q4_formal"}
assert state["modules"]["q3_formal"]["attempts_left"] == 3
assert state["spec"]["omega_radius_m"] == 1800
def test_start_countdown_open_abort_cycle(ui):
referee, port = ui
status, body = call(port, "POST", "/ui/api/start",
{"module": "q3_practice", "robot_id": "2026001",
"countdown_s": 0.05, "window_s": 5})
assert status == 200 and body["started"] is True
assert wait_until(lambda: referee.session.phase == "open")
status, state = call(port, "GET", "/ui/api/state")
assert state["phase"] == "open" and state["robot_id"] == "2026001"
assert "virtual_time_s" in state and "window_remaining_s" in state
status, body = call(port, "POST", "/ui/api/abort", {})
assert body["aborted"] is True
status, state = call(port, "GET", "/ui/api/state")
assert state["phase"] == "finished" and state["end_reason"] == "manual_abort"
assert state["truth"]["revealed"] is True # 演练结束显示真值统计
def test_start_rejects_running_session_and_bad_module(ui):
referee, port = ui
call(port, "POST", "/ui/api/start",
{"module": "q3_practice", "robot_id": "2026001", "countdown_s": 5})
status, body = call(port, "POST", "/ui/api/start",
{"module": "q3_practice", "robot_id": "2026001"})
assert status == 409
status, body = call(port, "POST", "/ui/api/start",
{"module": "nope", "robot_id": "2026001"})
assert status == 409
call(port, "POST", "/ui/api/abort", {})
def test_config_endpoints(ui):
referee, port = ui
status, body = call(port, "POST", "/ui/api/config", {"display_limit": 100})
assert body["configured"]["display_limit"] == 100
status, body = call(port, "POST", "/ui/api/config", {"display_limit": 1})
assert status == 409
status, body = call(port, "POST", "/ui/api/config", {"action_port": 8080})
assert body["configured"]["action_port"] == 8080
def test_sessions_list_and_replay_source(ui):
referee, port = ui
call(port, "POST", "/ui/api/start",
{"module": "q3_practice", "robot_id": "2026001",
"countdown_s": 0.05, "window_s": 5, "seed": 11, "source_count": 10})
assert wait_until(lambda: referee.session.phase == "open")
session = referee.session
from referee.interface import InterfaceResult
from referee.jsonio import dumps
referee.record_action(session, "POST", "/measure",
{"request_id": "m1", "position": {"x": 5000, "y": 0},
"channel": 1},
InterfaceResult(200, dumps({"accepted": True,
"virtual_time_s": 1005.0, "measure_result": "no_signal"}
).encode(), {"accepted": True, "virtual_time_s": 1005.0,
"measure_result": "no_signal"}))
call(port, "POST", "/ui/api/abort", {})
status, data = call(port, "GET", "/ui/api/sessions")
assert status == 200 and len(data["sessions"]) == 1
case = data["sessions"][0]["case_code"]
assert data["sessions"][0]["robot_id"] == "2026001"
status, replay = call(port, "GET", f"/ui/api/replay?case={case}")
assert status == 200
kinds = [event["event"] for event in replay["events"]]
assert kinds[0] == "session_start" and kinds[-1] == "session_end"
assert any(event["event"] == "action" for event in replay["events"])
assert replay["meta"]["module"] == "q3_practice"
assert len(replay["meta"]["truth"]) == 10 # 回放含真值(评测机侧)
assert call(port, "GET", "/ui/api/replay?case=NOPE-CODE-0000")[0] == 404
assert call(port, "GET", "/ui/api/replay?case=../etc")[0] == 400
def test_static_page_served(ui):
_, port = ui
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.request("GET", "/")
response = conn.getresponse()
page = response.read().decode("utf-8")
conn.close()
assert response.status == 200
assert "text/html" in response.getheader("Content-Type")
assert "评测机" in page and "replay.js" in page
# 回放器:手动选定 JSONL 的入口(文件选择按钮 + 拖拽容器)必须存在
assert 'id="rp-file"' in page and 'id="rp-open-file"' in page
assert 'id="rp-stage"' in page
def test_replay_script_served_with_file_import(ui):
_, port = ui
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.request("GET", "/replay.js")
response = conn.getresponse()
script = response.read().decode("utf-8")
conn.close()
assert response.status == 200
# 双格式归一化:评测机 action 事件 + 客户端 accepted 事件
assert "normalizeEvents" in script and "loadFromText" in script
assert 'ev.event === "accepted"' in script