6
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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}}')
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 # 同一地点重复测量读数不变 [题面]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user