6
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""测试侧脚手架:把独立评测机(referee 包)接入本队传输与测试。
|
||||
|
||||
分层边界(与官方模拟器同构):
|
||||
- 评测机本体在独立包 ``referee``(零 mcm26b 依赖),运行时代码绝不导入它;
|
||||
- 本包只在测试/自检入口把 referee 的接口边界适配成 :class:`Transport`,
|
||||
并提供故障注入与官方演练日志回放。
|
||||
|
||||
策略端只见 HTTP 响应;真值 Source 只存在于 referee 与测试端。
|
||||
"""
|
||||
|
||||
from .playback import PlaybackTransport
|
||||
from .scenes import mixed_scene, random_omni_scene, referee_spec
|
||||
from .simulator import LocalSimulator
|
||||
from .transport import LocalSimTransport, ScriptedFaultTransport
|
||||
|
||||
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
|
||||
"ScriptedFaultTransport", "mixed_scene", "random_omni_scene",
|
||||
"referee_spec"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
|
||||
|
||||
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
|
||||
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
|
||||
状态必须与当时服务器返回的虚拟时刻逐步吻合。
|
||||
|
||||
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
|
||||
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..protocol.transport import Transport, TransportResponse
|
||||
|
||||
|
||||
class PlaybackTransport(Transport):
|
||||
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
|
||||
|
||||
def __init__(self, events: list[dict]):
|
||||
self.events = list(events)
|
||||
self.index = 0
|
||||
self.served: list[dict] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
if self.index >= len(self.events):
|
||||
raise AssertionError("回放日志已耗尽仍有请求")
|
||||
event = self.events[self.index]
|
||||
path = urlsplit(url).path
|
||||
if path != event["path"]:
|
||||
raise AssertionError(
|
||||
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
|
||||
self.index += 1
|
||||
self.served.append(event)
|
||||
return TransportResponse(
|
||||
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""自检场景生成(测试端专用):委托 referee.scenes,映射本队配置。
|
||||
|
||||
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
|
||||
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内
|
||||
均匀分布、频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
|
||||
均匀分布是测试假设,题面并未规定官方场景的概率分布。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from referee import scenes as _referee_scenes
|
||||
|
||||
from ..config import ProblemConfig
|
||||
|
||||
|
||||
def referee_spec(config: ProblemConfig = ProblemConfig(), *,
|
||||
max_real_duration_s: float | None = None):
|
||||
"""把本队 ProblemConfig 的题面常量映射为 referee 的 Spec。
|
||||
|
||||
max_real_duration_s 仅用于 --self-test 的实机预算覆盖(默认 1800s);
|
||||
正式对接以服务器 /enter 返回为准,评测机默认保持题面 1200s。
|
||||
"""
|
||||
from referee import CostSpec, FieldSpec, Spec
|
||||
field, cost = config.field, config.cost
|
||||
return Spec(
|
||||
field=FieldSpec(
|
||||
omega_radius_m=field.omega_radius_m,
|
||||
channel_min=field.channel_min, channel_max=field.channel_max,
|
||||
source_count_min=field.source_count_min,
|
||||
source_count_max=field.source_count_max,
|
||||
r_eff_min_m=field.r_eff_min_m, r_eff_max_m=field.r_eff_max_m,
|
||||
near_radius_m=field.near_radius_m, clear_radius_m=field.clear_radius_m,
|
||||
bearing_error_half_deg=field.bearing_error_half_deg,
|
||||
bearing_decimals=field.bearing_decimals,
|
||||
coord_abs_limit_m=field.coord_abs_limit_m,
|
||||
directional_half_angle_deg=field.directional_half_angle_deg,
|
||||
max_virtual_duration_s=field.max_virtual_duration_s,
|
||||
max_real_duration_s=(field.max_real_duration_s if max_real_duration_s is None
|
||||
else max_real_duration_s),
|
||||
test_window_s=field.test_window_s),
|
||||
cost=CostSpec(speed_mps=cost.speed_mps, measure_s=cost.measure_s,
|
||||
switch_s=cost.switch_s, clear_success_s=cost.clear_success_s,
|
||||
clear_fail_s=cost.clear_fail_s))
|
||||
|
||||
|
||||
def random_omni_scene(seed: int = 20260910, count: int | None = None,
|
||||
config: ProblemConfig = ProblemConfig()):
|
||||
"""全向源场景 [题面 问题3];签名沿用重构前 mcm26b.sim.scenes。"""
|
||||
return _referee_scenes.random_omni_scene(seed, count, referee_spec(config))
|
||||
|
||||
|
||||
def mixed_scene(seed: int = 20260910, count: int | None = None,
|
||||
directional: int = 3, config: ProblemConfig = ProblemConfig()):
|
||||
"""问题4混合场景:count 个源中 directional 个为定向源。"""
|
||||
return _referee_scenes.mixed_scene(seed, count, directional, referee_spec(config))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""兼容构造入口:本队 ProblemConfig -> referee.LocalSimulator。
|
||||
|
||||
评测机实现在独立包 referee(config= 换成了 spec=);本子类只做配置映射,
|
||||
让既有测试与脚本继续以 ``config=ProblemConfig`` 构造,映射规则见
|
||||
:func:`harness.scenes.referee_spec`。真值类型 Source 亦来自 referee。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from referee import LocalSimulator as _RefereeLocalSimulator
|
||||
|
||||
from ..config import ProblemConfig
|
||||
from .scenes import referee_spec
|
||||
|
||||
|
||||
class LocalSimulator(_RefereeLocalSimulator):
|
||||
|
||||
def __init__(self, sources, *, robot_id: str = "2026001",
|
||||
config: ProblemConfig = ProblemConfig(), **kwargs):
|
||||
super().__init__(sources, robot_id=robot_id,
|
||||
spec=referee_spec(config), **kwargs)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""传输适配:评测机接口边界 <-> 本队 Transport 接口 + 故障注入脚本。
|
||||
|
||||
LocalSimTransport 把 referee 包的 handle_raw(与 HTTP 服务器共用的同一
|
||||
边界实现)包成内存回环传输,不经过网络栈;故障注入语义与既有测试一致:
|
||||
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
|
||||
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
|
||||
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
|
||||
- ``None`` 正常放行。
|
||||
脚本耗尽后一律放行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from referee.interface import handle_raw
|
||||
|
||||
from ..protocol.transport import Transport, TransportError, TransportResponse
|
||||
|
||||
|
||||
class LocalSimTransport(Transport):
|
||||
"""内存回环:把独立评测机接到 Transport 接口(与真实 HTTP 同一边界)。"""
|
||||
|
||||
def __init__(self, simulator):
|
||||
# simulator: referee.LocalSimulator(类型注解走字符串,避免导入开销)
|
||||
self.simulator = simulator
|
||||
self.received: list[tuple[str, dict]] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
return self.request("POST", url, body, headers, timeout)
|
||||
|
||||
def request(self, method, url, body, headers, timeout=None):
|
||||
"""验证原始请求边界;内存回环不模拟 TCP、UI 或连接限流。"""
|
||||
target = urlsplit(url)
|
||||
raw_target = target.path + (f"?{target.query}" if target.query else "")
|
||||
result = handle_raw(self.simulator, method, raw_target,
|
||||
list(headers.items()), bytes(body),
|
||||
on_request=lambda path, payload, _res:
|
||||
self.received.append((path, payload)))
|
||||
return TransportResponse(result.status, result.body)
|
||||
|
||||
|
||||
class ScriptedFaultTransport(Transport):
|
||||
"""按脚本注入故障后转发给内层传输。"""
|
||||
|
||||
def __init__(self, inner: Transport, script: list):
|
||||
self.inner = inner
|
||||
self.script = list(script)
|
||||
self.sent_requests: list[bytes] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
self.sent_requests.append(body)
|
||||
fault = self.script.pop(0) if self.script else None
|
||||
if fault == "drop_before":
|
||||
raise TransportError("injected: connection refused before send")
|
||||
if fault == "timeout_after":
|
||||
self.inner.post(url, body, headers, timeout) # 服务器已受理
|
||||
raise TransportError("injected: read timeout after server applied")
|
||||
if isinstance(fault, tuple) and fault and fault[0] == "status":
|
||||
return TransportResponse(int(fault[1]), b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}')
|
||||
return self.inner.post(url, body, headers, timeout)
|
||||
Reference in New Issue
Block a user