Files
mcm-26-b/tests/test_training_ui.py
T
2026-09-11 21:00:39 +08:00

667 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""训练前端回归:配置契约、进度解析、HTTP API 与真实子进程生命周期。
覆盖范围与动机:
- ``/api/train/schema`` 与 ``TrainConfig`` 必须同源(字段、默认值、预设能直接
提交),否则界面会允许后端拒绝的输入;
- SB3 表格解析必须给每一行打上 ``total_timesteps`` 横轴(曾把表格行当独立
点,曲线上出现重复的幽灵点);
- ``metric_offset``/``metric_end`` 游标契约(前端增量拉取依赖它);
- 用一段桩训练脚本真起子进程,验证启动/轮询/停止/退出码全链路。
"""
from __future__ import annotations
import http.client
import json
import sys
import threading
import time
from pathlib import Path
import pytest
pytest.importorskip("mcm26b")
from mcm26b.training_ui import demo, server as td
from mcm26b.training_ui.schema import (CONFIG_FIELDS, config_schema,
preset_payloads)
REPO = Path(__file__).resolve().parents[1]
# 桩训练脚本:先写 Monitor CSV,再按轮次输出"progress JSON + SB3 表格",
# 最后打印一行元数据。全部行为由参数控制,不依赖 torch/SB3。
STUB_SCRIPT = r'''
"""训练前端测试桩:模拟 SB3 verbose=1 表格 + --progress-json 输出。"""
import argparse, json, signal, sys, time
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("--problem", type=int, default=3)
parser.add_argument("--stage", type=int, default=1)
parser.add_argument("--timesteps", type=int, default=96)
parser.add_argument("--model", default="out/model.zip")
parser.add_argument("--rollout-steps", type=int, default=32)
parser.add_argument("--sleep", type=float, default=0.0)
parser.add_argument("--lines", type=int, default=4)
parser.add_argument("--table-first", action="store_true")
parser.add_argument("--progress-json", action="store_true")
parser.add_argument("--crash", action="store_true")
parser.add_argument("--ignore-sigterm", action="store_true")
args, _extra = parser.parse_known_args()
model = Path(args.model)
model.parent.mkdir(parents=True, exist_ok=True)
monitor = model.parent / "high_curve_q3_env0.monitor.csv"
with monitor.open("w", encoding="utf-8") as handle:
handle.write("#{\"t_start\": 0.0}\n")
handle.write("r,l,t\n")
for index in range(3):
handle.write("%.3f,%d,%.3f\n" % (float(index), 10 + index, index * 5.0))
if args.ignore_sigterm:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
print("NORMAL-LOG-LINE")
sys.stdout.flush()
def render_table(step, fps):
return (
"-----------------------------\n"
"| rollout/ | |\n"
"| ep_len_mean | 9.67 |\n"
"| ep_rew_mean | -3.24 |\n"
"| time/ | |\n"
"| fps | %-8d |\n"
"| total_timesteps | %-8d |\n"
"-----------------------------\n" % (fps, step)
)
for index in range(args.lines):
if args.sleep:
time.sleep(args.sleep)
step = 32 * (index + 1)
fps = 17 + index
progress = {
"event": "progress", "timesteps": step, "n_updates": index + 1,
"ep_rew_mean": -3.2 + index, "ep_len_mean": 9.7,
"value_loss": 12.5 - index, "approx_kl": 0.01,
"entropy_loss": 1.1, "explained_variance": 0.2, "fps": float(fps),
}
payload = "PROGRESS-" + json.dumps(progress)
table = render_table(step, fps)
if args.table_first:
print(table, end="")
print(payload, flush=True)
else:
print(payload, flush=True)
print(table, end="", flush=True)
print("EPISODE-%d r=%.2f" % (index, float(index)), flush=True)
print("INFO " + json.dumps({"event": "metadata", "actual_timesteps": args.timesteps}))
if args.crash:
raise SystemExit(3)
raise SystemExit(0)
'''
def wait_until(condition, timeout=15.0, interval=0.05):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
return False
@pytest.fixture()
def stub_repo(tmp_path, monkeypatch):
"""把 TRAIN_SCRIPT 指到桩脚本,避免测试真的训练神经网络。"""
script = tmp_path / "stub_train.py"
script.write_text(STUB_SCRIPT, encoding="utf-8")
monkeypatch.setattr(td, "TRAIN_SCRIPT", script)
return script
def make_config(**overrides):
payload = {"problem": 3, "stage": 1, "timesteps": 96, "seed": 1,
"envs": 1, "vec_env": "dummy", "device": "cpu",
"config_profile": "coarse-pretrain", "reward_profile": "time",
"model": "output/hrl/ui/test/q3.zip", "eval_episodes": 0,
"rollout_steps": 32, "batch_size": 32, "n_epochs": 2,
"max_wall_s": 300.0, "verbose": 1, "force": True}
payload.update(overrides)
return td.TrainConfig.from_payload(payload)
def run_job(config, timeout=30.0):
job = td.TrainingJob(config)
job.start()
assert wait_until(lambda: job.status in ("exited", "failed", "stopped"),
timeout=timeout), f"子进程未在 {timeout}s 内结束: {job.status}"
return job
# ---------------------------------------------------------------- 配置契约
def test_schema_matches_train_config_defaults():
schema = config_schema()
assert schema["fields"] and schema["groups"] and schema["metrics"]
defaults = td.TrainConfig()
for field in schema["fields"]:
assert hasattr(defaults, field["key"]), f"字段 {field['key']} 不在 TrainConfig 中"
assert field["default"] == getattr(defaults, field["key"]), \
f"{field['key']} 默认值与后端不一致"
exposed = {field["key"] for field in schema["fields"]}
missing = set(defaults.__dataclass_fields__) - exposed
assert not missing, f"TrainConfig 字段未暴露给界面: {sorted(missing)}"
groups = {group["key"] for group in schema["groups"]}
for field in schema["fields"]:
assert field["group"] in groups, f"{field['key']} 的分组不存在"
def test_schema_metric_catalog_covers_progress_payload():
"""进度 JSON 的每个键都要么在指标字典里,要么有明确理由不进图。"""
emitted = set(demo.demo_metrics(count=2)[0]) | {"ep_len_mean"}
catalog = {metric["key"] for metric in config_schema()["metrics"]}
known_extras = {"timesteps", "total_timesteps", "iterations", "time_elapsed"}
assert emitted <= catalog | known_extras, sorted(emitted - catalog - known_extras)
def test_presets_are_accepted_by_backend():
for key, payload in preset_payloads().items():
config = td.TrainConfig.from_payload(payload)
assert config.timesteps > 0, key
command = td.build_command(config)
assert "--progress-json" in command and "--model" in command
def test_config_rejects_out_of_range_and_unknown_combinations():
with pytest.raises(ValueError):
make_config(problem=5)
with pytest.raises(ValueError):
make_config(stage=9)
with pytest.raises(ValueError):
make_config(rollout_steps=4096) # > timesteps
with pytest.raises(ValueError):
make_config(resume=True, force=True) # 互斥
with pytest.raises(ValueError):
make_config(model="")
with pytest.raises(ValueError):
make_config(vec_env="joblib")
def test_config_accepts_paths_with_separators():
r"""回归:_SAFE_ARG_RE 曾把 \x00 写成字面反斜杠,导致所有相对路径被拒。"""
config = make_config(model="output/hrl/ui/smoke/q3.zip",
init="output/hrl/ui/q3-main/q3.zip")
assert config.model_path.is_absolute()
assert config.model_path.as_posix().endswith("output/hrl/ui/smoke/q3.zip")
assert "--init" in td.build_command(config)
with pytest.raises(ValueError):
make_config(model="bad\x01name.zip") # 控制字符仍须拒绝
def test_build_command_is_shell_free_argument_list():
config = make_config(model="output/x/q3.zip")
command = td.build_command(config)
assert all(isinstance(item, str) for item in command)
assert str(td.TRAIN_SCRIPT) in command
assert "output/x/q3.zip" in " ".join(command)
# 参数列表不经过 shell:没有把整条命令拼成一个字符串
assert not any(" --" in item for item in command)
# ---------------------------------------------------------------- 进度解析
class _FakeJob:
"""借用 TrainingJob 的解析器,但不启动进程、不需要真实配置。"""
def __init__(self):
import collections
self.logs = collections.deque(maxlen=100)
self.metrics = collections.deque(maxlen=100)
self._lock = threading.RLock()
self._table_sections = []
self._table_steps = None
self._pending_table = None
self._last_table_steps = None
self._last_json_steps = None
self._last_monitor_sig = None
self.config = make_config()
_on_line = td.TrainingJob._on_line
_parse_progress_json = td.TrainingJob._parse_progress_json
_parse_sb3_table = td.TrainingJob._parse_sb3_table
_flush_table = td.TrainingJob._flush_table
_flush_pending_with_fallback = td.TrainingJob._flush_pending_with_fallback
_append_metric = td.TrainingJob._append_metric
_refresh_monitor = td.TrainingJob._refresh_monitor
def test_sb3_table_rows_get_timestep_axis():
"""表格行必须继承所属表的 total_timesteps,不能退化为点序号横轴。
刻意不打印表间分隔线,复刻"只有段头、没有横线"的真实输出变体。
"""
job = _FakeJob()
for line in ("| rollout/ | |",
"| ep_len_mean | 9.67 |",
"| ep_rew_mean | -3.24 |",
"| time/ | |",
"| fps | 17 |",
"| total_timesteps | 32 |",
"| rollout/ | |",
"| ep_rew_mean | -2.90 |",
"| time/ | |",
"| fps | 19 |",
"| total_timesteps | 64 |",
"-----------------------------"):
job._on_line(line)
job._flush_table()
assert [point["timesteps"] for point in job.metrics] == [32, 64], list(job.metrics)
# 每张表合并为一条(rollout/ 与 time/ 的字段都在同一条里),刻度取本表
assert job.metrics[0]["ep_rew_mean"] == -3.24
assert job.metrics[0]["fps"] == 17
assert job.metrics[1]["ep_rew_mean"] == -2.9
assert job.metrics[1]["fps"] == 19
assert all(point["timesteps"] != float(index) for index, point in
enumerate(job.metrics)), "横轴不得退化为点序号"
def test_sb3_table_with_separators_emits_one_point_per_table():
"""带分隔线的标准 SB3 输出:每张表恰好一条进度点,刻度取自本表。"""
job = _FakeJob()
for index, steps in enumerate((32, 64, 96)):
job._on_line("-----------------------------")
job._on_line("| rollout/ | |")
job._on_line("| ep_rew_mean | %.2f |" % (-3.0 + index))
job._on_line("| time/ | |")
job._on_line("| total_timesteps | %d |" % steps)
job._on_line("-----------------------------")
job._flush_table()
assert [point["timesteps"] for point in job.metrics] == [32, 64, 96], list(job.metrics)
assert [point["ep_rew_mean"] for point in job.metrics] == [-3.0, -2.0, -1.0]
def test_truncated_table_falls_back_to_last_known_axis():
"""被截断的表(缺少 time/ 段)不用旧刻度冒充,挂起到进程结束才兜底提交。"""
job = _FakeJob()
for line in ("| rollout/ | |",
"| ep_rew_mean | -3.24 |",
"| time/ | |",
"| total_timesteps | 32 |",
"-----------------------------",
"| rollout/ | |",
"| ep_rew_mean | -2.90 |",
"-----------------------------"):
job._on_line(line)
job._flush_table()
assert [point["timesteps"] for point in job.metrics] == [32], list(job.metrics)
job._flush_pending_with_fallback()
assert [point["timesteps"] for point in job.metrics] == [32, 32], list(job.metrics)
assert job.metrics[-1]["ep_rew_mean"] == -2.9
def test_table_without_any_axis_is_discarded():
"""完全没有刻度来源的表宁可丢弃,也不能让曲线出现序号横轴的幽灵点。"""
job = _FakeJob()
for line in ("| rollout/ | |",
"| ep_rew_mean | -3.24 |",
"-----------------------------"):
job._on_line(line)
job._flush_table()
job._flush_pending_with_fallback()
assert not job.metrics
def test_progress_json_is_not_deduped_against_table_by_timestep():
"""同一 timesteps 的表格行与 JSON 进度行只保留一份,避免重复点。"""
job = _FakeJob()
job._on_line('{"event": "progress", "timesteps": 32, "ep_rew_mean": -1.0}')
for line in ("| rollout/ | |",
"| ep_rew_mean | -1.0 |",
"| total_timesteps | 32 |"):
job._on_line(line)
job._flush_table()
assert len(job.metrics) == 1, list(job.metrics)
def test_metadata_json_is_not_treated_as_progress():
"""收尾打印的模型元数据不带 event=progress,不能污染曲线。"""
job = _FakeJob()
job._on_line(json.dumps({"level": "high", "actual_timesteps": 96,
"fps": 999, "value_loss": 12345}))
assert not job.metrics
def test_convergence_states_are_distinguishable():
def point(step, reward, kl=0.01, ev=0.5, fps=100.0):
return {"timesteps": step, "ep_rew_mean": reward, "approx_kl": kl,
"explained_variance": ev, "fps": fps}
empty = td.summarize_convergence([], [])
assert empty["tone"] == "gray" and empty["reward_points"] == 0
rising = td.summarize_convergence(
[point(index * 100, 1.0 + index * 0.5) for index in range(1, 15)], [])
assert rising["tone"] == "ok"
assert "斜率为正" in " ".join(rising["notes"])
plateau = td.summarize_convergence(
[point(index * 100, 5.0) for index in range(1, 15)], [])
assert "平稳" in plateau["status"], plateau
assert plateau["reward_spread"] == 0.0
unstable = td.summarize_convergence(
[point(index * 100, 1.0 * index, kl=0.4) for index in range(1, 15)], [])
assert unstable["tone"] == "bad" and "KL" in unstable["status"]
unfitted = td.summarize_convergence(
[point(index * 100, 1.0 * index, ev=-0.3) for index in range(1, 15)], [])
assert unfitted["tone"] == "warn"
monitor_only = td.summarize_convergence(
[], [{"r": 1.0}, {"r": 2.0}, {"r": 3.0}])
assert monitor_only["reward_points"] == 3
missing = [dict(point(index * 100, 1.0 * index), approx_kl=None,
explained_variance=None) for index in range(1, 9)]
still_ok = td.summarize_convergence(missing, [])
assert still_ok["latest_kl"] is None and still_ok["tone"] == "ok"
def test_describe_process_progress_and_eta():
snapshot = {
"status": "running", "elapsed_s": 10.0,
"config": {"timesteps": 4000, "max_wall_s": 900.0},
"metrics": [{"timesteps": 500, "fps": 50.0},
{"timesteps": 1000, "fps": 100.0}],
"convergence": {}, "log_total": 3, "metric_total": 2,
}
process = td.describe_process(snapshot)
assert process["percent"] == 25.0
assert process["done_steps"] == 1000
assert process["rate_per_s"] == 75.0
assert process["remaining_s"] == pytest.approx(40.0, rel=1e-6)
assert process["phase"] == "update"
assert [node["key"] for node in process["timeline"]][-1] == "done"
assert process["timeline"][-1]["state"] == "pending"
# 无进度点时退化为墙钟上限估计,而不是给出 None
bare = td.describe_process({"status": "running", "elapsed_s": 30.0,
"config": {"timesteps": 1000, "max_wall_s": 100.0},
"metrics": [], "convergence": {}})
assert bare["percent"] == 0.0
assert bare["remaining_s"] == pytest.approx(70.0)
# fps 只出现在 SB3 表格行里,中间夹着若干无 fps 的 JSON 行时仍要能估速率
gapped = td.describe_process({
"status": "running", "elapsed_s": 30.0,
"config": {"timesteps": 640, "max_wall_s": 300.0},
"metrics": [{"timesteps": 100, "fps": None}, {"timesteps": 120, "fps": 18.0},
{"timesteps": 140, "fps": None}, {"timesteps": 160, "fps": None},
{"timesteps": 180, "fps": None}, {"timesteps": 200, "fps": None},
{"timesteps": 220, "fps": 20.0}],
"convergence": {}})
assert gapped["rate_per_s"] == 19.0, gapped["rate_per_s"]
assert gapped["remaining_s"] == pytest.approx(420 / 19.0, rel=1e-6)
# 完全没有 fps 时退化为墙钟上限估计
no_fps = td.describe_process(
{"status": "running", "elapsed_s": 30.0,
"config": {"timesteps": 1000, "max_wall_s": 100.0},
"metrics": [{"timesteps": 10}, {"timesteps": 20}], "convergence": {}})
assert no_fps["rate_per_s"] is None
assert no_fps["remaining_s"] == pytest.approx(70.0)
# 已结束:时间线最后节点为 active,且不再外推剩余时间
ended = td.describe_process({"status": "exited", "elapsed_s": 50.0,
"config": {"timesteps": 1000, "max_wall_s": 100.0},
"metrics": [{"timesteps": 1000, "fps": 10.0}],
"convergence": {}})
assert ended["terminal"] is True
assert ended["timeline"][-1]["state"] == "active"
assert ended["remaining_s"] is None
def test_demo_snapshot_contract():
for scenario in demo.DEMO_SCENARIOS:
snapshot = demo.demo_snapshot(scenario=scenario)
job = td.TrainingJob(make_config())
# 示例快照必须与真实快照同键,否则前端在预览模式下会缺字段
for key in job.snapshot(log_tail=1):
assert key in snapshot, f"{scenario} 缺少快照键 {key}"
assert snapshot["process"]["percent"] < 100.0
assert snapshot["convergence"]["reward_points"] > 0
assert json.dumps(snapshot, ensure_ascii=False, allow_nan=False)
with pytest.raises(ValueError):
demo.demo_metrics(scenario="nope")
# ---------------------------------------------------------------- 子进程
def test_job_lifecycle_with_stub_script(stub_repo):
config = make_config(model="output/hrl/ui/test/live/q3.zip",
verbose=1, force=True)
job = run_job(config)
assert job.status == "exited" and job.returncode == 0
snapshot = job.snapshot(log_tail=50)
assert snapshot["status"] == "exited"
assert snapshot["process"]["percent"] == 100.0
assert snapshot["process"]["terminal"] is True
assert any("NORMAL-LOG-LINE" in line for line in snapshot["log_lines"])
assert len(snapshot["monitor"]) == 3
steps = [point.get("timesteps") for point in snapshot["metrics"]]
assert steps == [32, 64, 96, 128], steps
assert snapshot["metric_end"] == len(snapshot["metrics"]) + snapshot["metric_offset"]
assert snapshot["convergence"]["reward_points"] >= 1
def test_job_reports_nonzero_exit_code(stub_repo):
config = make_config(model="output/hrl/ui/test/crash/q3.zip")
job = td.TrainingJob(config)
job.command = td.build_command(config) + ["--crash"]
job.start()
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
assert job.status == "failed"
assert job.returncode == 3
assert job.snapshot(log_tail=5)["process"]["tone"] == "bad"
def test_stop_sends_sigterm_and_marks_stopped(stub_repo):
config = make_config(model="output/hrl/ui/test/stop/q3.zip")
job = td.TrainingJob(config)
job.command = td.build_command(config) + ["--sleep", "5", "--lines", "50"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15)
assert job.status == "stopped"
def test_reader_flushes_truncated_table_on_exit(stub_repo):
"""进程被截断时,_read_loop 收尾必须把半张表按已知刻度兜底提交。"""
config = make_config(model="output/hrl/ui/test/truncated/q3.zip")
job = td.TrainingJob(config)
job.start()
# 直接注入一段"只有 rollout/ 段、没有 time/ 段"的表格,模拟中途被打断
job._on_line("| rollout/ | |")
job._on_line("| ep_rew_mean | -3.24 |")
job._on_line("| total_timesteps | 32 |")
job._on_line("| rollout/ | |")
job._on_line("| ep_rew_mean | -2.90 |")
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
snapshot = job.snapshot(log_tail=5)
steps = [point["timesteps"] for point in snapshot["metrics"]]
# 桩脚本自己产出的四张完整表 + 兜底提交的半张表(刻度沿用 128)
assert steps[-1] == 128, steps
assert any(point.get("ep_rew_mean") == -2.9 for point in snapshot["metrics"]), steps
def test_metric_cursor_pagination(stub_repo):
config = make_config(model="output/hrl/ui/test/cursor/q3.zip")
job = run_job(config)
full = job.snapshot(log_tail=5)["metrics"]
assert [point["timesteps"] for point in full] == [32, 64, 96, 128]
first = job.snapshot(log_tail=5, after_metric=0)
assert first["metric_offset"] == 0
assert first["metric_end"] == len(first["metrics"])
tail = job.snapshot(log_tail=5, after_metric=3)
assert tail["metric_offset"] == 3
assert len(tail["metrics"]) == 1
assert tail["metric_end"] == 4
beyond = job.snapshot(log_tail=5, after_metric=9999)
assert beyond["metrics"] == []
assert beyond["metric_offset"] == 4
# ---------------------------------------------------------------- HTTP
@pytest.fixture()
def client(stub_repo):
ui = td.TrainingUI(ui_port=0)
threading.Thread(target=ui.serve_forever, daemon=True).start()
port = ui.httpd.server_address[1]
yield ui, port
ui.shutdown()
def call(port, method, path, obj=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
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()
try:
parsed = json.loads(data) if data else None
except ValueError:
parsed = data.decode("utf-8", "replace")
return response.status, parsed
def test_http_static_whitelist(client):
_ui, port = client
for path, needle in (("/", "<!doctype html>"), ("/app.js", "absorbMetrics"),
("/charts.js", "sparkline"), ("/style.css", ".panel")):
status, body = call(port, "GET", path)
assert status == 200, path
assert needle in body, path
status, body = call(port, "GET", "/../pyproject.toml")
assert status == 404 and "error" in body
def test_http_schema_and_preview(client):
_ui, port = client
status, schema = call(port, "GET", "/api/train/schema")
assert status == 200
assert len(schema["fields"]) == len(CONFIG_FIELDS)
assert all(key in schema for key in ("groups", "presets", "metrics"))
status, payload = call(port, "GET", "/api/train/state?preview=1&scenario=kl-spike")
assert status == 200
job = payload["job"]
assert job["preview"] is True
assert "KL" in job["convergence"]["status"]
status, body = call(port, "GET", "/api/train/state?preview=1&scenario=bogus")
assert status == 400 and "bogus" in body["error"]
def test_http_state_query_validation(client):
_ui, port = client
status, body = call(port, "GET", "/api/train/state?log_tail=abc")
assert status == 400 and "log_tail" in body["error"]
status, body = call(port, "GET", "/api/train/state?log_tail=0")
assert status == 400
status, body = call(port, "GET", "/api/train/state?metric_limit=999999")
assert status == 400
status, body = call(port, "POST", "/api/train/start", [1, 2, 3])
assert status == 400
status, body = call(port, "POST", "/api/train/nope", {})
assert status == 404
def test_http_start_poll_stop(client):
_ui, port = client
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http/q3.zip",
"timesteps": 128, "rollout_steps": 32, "batch_size": 32})
assert status == 200
first = body["job"]
assert first["status"] in ("running", "created")
assert first["pid"]
assert "--progress-json" in first["command"]
def finished():
state = call(port, "GET", "/api/train/state?log_tail=5")[1]
return state["job"]["status"] == "exited"
assert wait_until(finished, timeout=30)
status, state = call(port, "GET", "/api/train/state?log_tail=50")
job = state["job"]
assert job["process"]["percent"] == 100.0
assert job["process"]["label"] == "正常结束"
assert len(job["monitor"]) == 3
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http2/q3.zip"})
assert status == 200
started = body["job"]
assert started["job_id"] != first["job_id"]
assert started["status"] in ("running", "created")
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is True
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is False # 已结束,返回 False 而不是报错
def test_http_rejects_second_job_while_running(client):
_ui, port = client
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/busy/q3.zip",
"timesteps": 9000, "rollout_steps": 3000,
"batch_size": 1000})
assert status == 200
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/busy2/q3.zip"})
assert status == 409 and "已有训练任务" in body["error"]
call(port, "POST", "/api/train/stop", {})
def test_http_invalid_config_returns_409(client):
_ui, port = client
status, body = call(port, "POST", "/api/train/start", {"stage": 99})
assert status == 409 and "stage" in body["error"]
status, body = call(port, "POST", "/api/train/start",
{"model": "output/x.zip", "resume": True, "force": True})
assert status == 409 and "互斥" in body["error"]
def test_http_binds_loopback_only(client):
ui, _port = client
host = ui.httpd.server_address[0]
assert host == "127.0.0.1"
def test_port_conflict_reports_actionable_error(client):
ui, port = client
with pytest.raises(OSError) as excinfo:
td.TrainingUI(ui_port=port)
assert "ui-port" in str(excinfo.value)
# ---------------------------------------------------------------- CLI
def test_cli_print_schema_and_preview(capsys):
assert td.run_training_ui(["--print-schema"]) == 0
schema = json.loads(capsys.readouterr().out)
assert schema["fields"]
assert td.run_training_ui(["--print-preview"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["job"]["preview"] is True
def test_training_ui_module_entrypoint_exists():
text = (REPO / "scripts" / "train_hrl_ui.py").read_text(encoding="utf-8")
assert "run_training_ui" in text
assert sys.executable # 仅让 Windows 上的路径断言有明确前置条件