"""训练前端回归:配置契约、进度解析、HTTP API 与真实子进程生命周期。 覆盖范围与动机: - ``/api/train/schema`` 与 ``TrainConfig`` 必须同源(字段、默认值、预设能直接 提交),否则界面会允许后端拒绝的输入; - SB3 表格解析必须给每一行打上 ``total_timesteps`` 横轴(曾把表格行当独立 点,曲线上出现重复的幽灵点); - ``metric_offset``/``metric_end`` 游标契约(前端增量拉取依赖它); - 用一段桩训练脚本真起子进程,验证启动/轮询/暂停/继续/停止/退出码全链路。 """ # pytest 用参数名注入 fixture:`stub_repo`/`client` 这类参数在函数体里不直接 # 引用也是正常用法(它们的作用是 setup/teardown),这里统一放行。 # pylint: disable=unused-argument from __future__ import annotations import dataclasses import http.client import json import sys import threading import time from pathlib import Path import pytest pytest.importorskip("mcm26b") from mcm26b import process_control as pc 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, os, 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) # 默认 sleep 可由环境变量控制:HTTP 端到端用例无法直接追加命令行参数, # 用小 sleep 拉长运行窗口,才能稳定地测"暂停/继续"。 parser.add_argument("--sleep", type=float, default=float(os.environ.get("STUB_TRAIN_SLEEP", "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") parser.add_argument("--ignore-control", action="store_true") parser.add_argument("--control-file", default=None) 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 read_control(): if not args.control_file or args.ignore_control: return {} try: with open(args.control_file, encoding="utf-8") as handle: return json.load(handle) except Exception: return {} def control_pause_or_stop(step): """返回 True 表示应退出;暂停时阻塞到继续或停止(模拟协作式暂停)。""" state = read_control() if state.get("stop"): print(json.dumps({"event": "stop_requested", "signal": "control_file"}), flush=True) return True if state.get("paused"): print(json.dumps({"event": "paused", "timesteps": step}), flush=True) while True: time.sleep(0.05) state = read_control() if state.get("stop"): print(json.dumps({"event": "stop_requested", "signal": "control_file"}), flush=True) return True if not state.get("paused"): break print(json.dumps({"event": "resumed", "timesteps": step}), flush=True) return False def sleep_with_control(seconds, step): """把长 sleep 切成 0.1s 片,保证停止/暂停请求能被及时响应。""" deadline = time.monotonic() + max(0.0, seconds) while True: if control_pause_or_stop(step): raise SystemExit(0) remaining = deadline - time.monotonic() if remaining <= 0: return time.sleep(min(0.1, remaining)) def render_table(step, fps): return ( "-----------------------------\n" "| rollout/ | |\n" "| ep_len_mean | 9.67 |\n" "| ep_rew_mean | -3.24 |\n" "| time/ | |\n" f"| fps | {fps:<8d} |\n" f"| total_timesteps | {step:<8d} |\n" "-----------------------------\n" ) for index in range(args.lines): step = 32 * (index + 1) sleep_with_control(args.sleep, step) 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 = {field.name for field in dataclasses.fields(defaults)} - 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.status = "running" self.config = make_config() _on_line = td.TrainingJob._on_line _sync_control_events = td.TrainingJob._sync_control_events _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(f"| ep_rew_mean | {-3.0 + index:.2f} |") job._on_line("| time/ | |") job._on_line(f"| total_timesteps | {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_describe_process_curriculum_transfer_uses_delta_steps(): """--init 课程迁移:计数器从上一阶段继续,进度/ETA 必须按本次增量算。""" snapshot = { "status": "running", "elapsed_s": 10.0, "config": {"timesteps": 8000, "max_wall_s": 3600.0}, "metrics": [{"timesteps": 110000, "start_timesteps": 109776, "fps": 50.0}, {"timesteps": 111776, "start_timesteps": 109776, "fps": 50.0}], "convergence": {}, "log_total": 2, "metric_total": 2, } process = td.describe_process(snapshot) assert process["done_steps"] == 111776 # 绝对计数照实显示 assert process["start_steps"] == 109776 assert process["trained_steps"] == 2000 # 本次增量 assert process["percent"] == 25.0 # 而不是被夹到 100% assert process["remaining_s"] == pytest.approx(120.0, rel=1e-6) 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 = list(job.command) + ["--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_job_command_carries_control_file(stub_repo): """控制文件路径必须随命令下发,否则暂停/停止退化成超时强杀。""" config = make_config(model="output/hrl/ui/test/control/q3.zip") job = td.TrainingJob(config, control_dir=Path(stub_repo).parent) assert "--control-file" in job.command assert str(job.control_path) in job.command assert job.control_path.parent == Path(stub_repo).parent def test_stop_gracefully_marks_stopped_without_forced_kill(stub_repo): config = make_config(model="output/hrl/ui/test/stop/q3.zip") job = td.TrainingJob(config) job.command = list(job.command) + ["--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" # 桩脚本读控制文件后主动退出:不应走到强杀分支 assert job.forced_kill is False def test_pause_and_resume_via_control_file(stub_repo): config = make_config(model="output/hrl/ui/test/pause/q3.zip") job = td.TrainingJob(config) job.command = list(job.command) + ["--sleep", "0.2", "--lines", "60"] job.start() assert wait_until(lambda: job.status == "running", timeout=10) assert job.pause() is True # 训练脚本回显 paused 事件;状态由 _sync_control_events 校正 assert wait_until( lambda: any('"event": "paused"' in line for line in job.snapshot()["log_lines"]), timeout=10), "训练脚本未回显 paused 事件" assert job.status == "paused" paused_metrics = len(job.snapshot()["metrics"]) time.sleep(0.6) assert len(job.snapshot()["metrics"]) <= paused_metrics + 1, "暂停后仍在产出指标" assert job.resume() is True assert wait_until( lambda: any('"event": "resumed"' in line for line in job.snapshot()["log_lines"]), timeout=10), "训练脚本未回显 resumed 事件" assert wait_until(lambda: job.status == "running", timeout=5) assert job.stop() is True assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15) assert job.status == "stopped" def test_stop_forced_kill_when_control_ignored(stub_repo, monkeypatch): """训练进程不读控制文件时必须超时强杀,并留下恢复指引。""" monkeypatch.setattr(td, "STOP_GRACE_S", 0.5) config = make_config(model="output/hrl/ui/test/stubborn/q3.zip") job = td.TrainingJob(config) job.command = list(job.command) + ["--ignore-control", "--sleep", "5", "--lines", "50"] job.start() assert wait_until(lambda: job.status == "running", timeout=10) # 造一份"磁盘上已有滚动恢复点"的现场,强杀后应被记录进恢复指引 ckpt_dir = job.config.model_path.parent / "checkpoints" ckpt_dir.mkdir(parents=True, exist_ok=True) latest = ckpt_dir / "q3_ckpt_latest.zip" latest.write_bytes(b"PK\x03\x04fake") assert job.stop() is True assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=20) assert job.forced_kill is True assert job.snapshot(log_tail=5)["forced_kill"] is True marker = json.loads( (job.config.model_path.parent / "forced_stop.json").read_text(encoding="utf-8")) assert marker["event"] == "forced_stop" assert marker["reason"] == "grace_timeout" assert marker["recovery"]["model"] == str(latest) assert marker["recovery"]["can_resume"] is True def test_discover_recovery_prefers_resume_hint(tmp_path): run_dir = tmp_path / "run" (run_dir / "checkpoints").mkdir(parents=True) latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip" latest.write_bytes(b"PK\x03\x04fake") final = run_dir / "q3.zip" final.write_bytes(b"PK\x03\x04fake") (run_dir / "resume.json").write_text(json.dumps({ "model": str(latest), "timesteps": 128, "reason": "interval", "created_unix_s": 5.0}), encoding="utf-8") state = td.discover_recovery(run_dir, final) assert state["can_resume"] is True assert state["model"] == str(latest) assert state["timesteps"] == 128 assert state["source"] == "resume.json" def test_discover_recovery_falls_back_to_scan_on_dangling_hint(tmp_path): run_dir = tmp_path / "run" (run_dir / "checkpoints").mkdir(parents=True) latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip" latest.write_bytes(b"PK\x03\x04fake") # 悬空指针(目标文件已不在)必须回退到扫描,而不是给出一个打不开的路径 (run_dir / "resume.json").write_text( json.dumps({"model": str(run_dir / "gone.zip")}), encoding="utf-8") state = td.discover_recovery(run_dir, run_dir / "q3.zip") assert state["can_resume"] is True assert state["model"] == str(latest) assert state["source"] == "scan" def test_discover_recovery_with_nothing_on_disk(tmp_path): state = td.discover_recovery(tmp_path, tmp_path / "q3.zip") assert state["can_resume"] is False and state["model"] is None assert state["reason"] == "none" def test_command_carries_checkpoint_and_recovery_flags(): config = make_config(model="output/x/q3.zip", recovery_every=100, checkpoint_every=500, checkpoint_keep=2) joined = " ".join(td.build_command(config)) assert "--recovery-every 100" in joined assert "--checkpoint-every 500" in joined assert "--checkpoint-keep 2" in joined def test_config_rejects_bad_recovery_settings(): with pytest.raises(ValueError): make_config(recovery_every=-1) with pytest.raises(ValueError): make_config(checkpoint_every=-1) with pytest.raises(ValueError): make_config(checkpoint_keep=0) def test_pause_resume_return_false_without_live_process(stub_repo): config = make_config(model="output/hrl/ui/test/nolive/q3.zip") job = td.TrainingJob(config) assert job.pause() is False and job.resume() is False job.start() assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30) assert job.pause() is False and job.resume() is False # ---------------------------------------------------- 跨平台进程控制 def test_control_file_roundtrip_and_corruption(tmp_path): path = tmp_path / "control.json" assert pc.read_control(path) == pc.ControlState() # 缺失 = 无指令 pc.write_control(path, paused=True) assert pc.read_control(path).paused is True pc.write_control(path, stop=True) state = pc.read_control(path) assert state.stop is True and state.paused is False path.write_text("{ 半截 JSON", encoding="utf-8") assert pc.read_control(path) == pc.ControlState() # 损坏 = 无指令 def test_spawn_kwargs_isolates_child_process_group(monkeypatch): monkeypatch.setattr(pc, "IS_WINDOWS", False) assert pc.spawn_kwargs() == {"start_new_session": True} monkeypatch.setattr(pc, "IS_WINDOWS", True) kwargs = pc.spawn_kwargs() # Windows 上 start_new_session 被 CPython 静默忽略,绝不能再用它 assert "start_new_session" not in kwargs if kwargs: assert kwargs["creationflags"] == pc._CREATE_NEW_PROCESS_GROUP def test_hard_kill_on_windows_uses_taskkill(monkeypatch): commands = [] class _FakeProcess: pid = 4242 def kill(self): # pragma: no cover - Windows 分支不应走到这里 commands.append("Popen.kill") monkeypatch.setattr(pc, "IS_WINDOWS", True) monkeypatch.setattr(pc.subprocess, "run", lambda argv, **kwargs: commands.append(argv)) pc._hard_kill_tree(_FakeProcess()) assert commands and commands[0][0] == "taskkill" assert "/PID" in commands[0] and "4242" in commands[0] def test_hard_kill_without_killpg_does_not_raise(monkeypatch): """回归:Windows 上 os 没有 killpg/getpgid,旧代码会抛 AttributeError。""" killed = [] class _FakeProcess: pid = 4242 def kill(self): killed.append(True) class _PosixlessOS: """模拟 Windows:只有 getpid 之类,没有 killpg/getpgid。""" monkeypatch.setattr(pc, "IS_WINDOWS", False) monkeypatch.setattr(pc, "os", _PosixlessOS()) pc._hard_kill_tree(_FakeProcess()) assert killed == [True] 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 (("/", ""), ("/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 # 等训练进程真正退出后再点一次:应返回 False 而不是报错 assert wait_until( lambda: call(port, "GET", "/api/train/state?log_tail=5")[1]["job"]["status"] in ("stopped", "failed"), timeout=15) status, body = call(port, "POST", "/api/train/stop", {}) assert status == 200 and body["stopped"] is False def test_http_pause_resume_and_state(client, monkeypatch): monkeypatch.setenv("STUB_TRAIN_SLEEP", "0.6") _ui, port = client status, body = call(port, "POST", "/api/train/start", {"model": "output/hrl/ui/test/http-pause/q3.zip", "timesteps": 4096, "rollout_steps": 32, "batch_size": 32}) assert status == 200 def status_now(): return call(port, "GET", "/api/train/state?log_tail=20")[1]["job"]["status"] def is_running(): return status_now() == "running" assert wait_until(is_running, timeout=10) status, body = call(port, "POST", "/api/train/pause", {}) assert status == 200 and body["paused"] is True assert wait_until(lambda: status_now() == "paused", timeout=10), status_now() # 暂停中仍视为"有任务在跑":不允许再开一局 status, body = call(port, "POST", "/api/train/start", {"model": "output/hrl/ui/test/http-pause2/q3.zip"}) assert status == 409 and "已有训练任务" in body["error"] status, body = call(port, "POST", "/api/train/resume", {}) assert status == 200 and body["resumed"] is True assert wait_until(is_running, timeout=10), status_now() status, body = call(port, "POST", "/api/train/stop", {}) assert status == 200 and body["stopped"] is True assert wait_until(lambda: status_now() in ("stopped", "failed"), timeout=15) 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 上的路径断言有明确前置条件