现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取

This commit is contained in:
ljz committed 2026-09-13 20:01:56 +08:00
1 parent b6c22a54f8
commit 6fc9afe41d
119 files changed
+260114 -351

No files matched your search

+262 -96
View File
@@ -1,125 +1,291 @@
from __future__ import annotations
import asyncio
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from threading import Event
import time
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from app.main import _app_lifespan, app
from app.simulation.warmup import (
SimulationWarmupReport,
_reset_simulation_warmup_for_tests,
warm_up_simulation_runtime,
)
from tests.test_native_result_transport import AsgiClient
from app.simulation.native_codegen import build, self_test
from app.simulation.warmup import SimulationRuntimeCheck
@contextmanager
def runtime_client():
# Reuse the repository's ASGI client; no optional httpx dependency is needed.
with asyncio.Runner() as runner:
lifespan = _app_lifespan(app)
runner.run(lifespan.__aenter__())
try:
yield AsgiClient(app)
finally:
runner.run(lifespan.__aexit__(None, None, None))
class SimulationWarmupTests(unittest.TestCase):
def setUp(self) -> None:
_reset_simulation_warmup_for_tests()
self.engine_environment = patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native"})
self.engine_environment.start()
self.addCleanup(self.engine_environment.stop)
def setUp(self):
self.environment = patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native", "SIMULATIONAPP_WARMUP": "on"})
self.environment.start()
self.addCleanup(self.environment.stop)
self.retry_delay = patch("app.simulation.warmup.RETRY_DELAY_SECONDS", 0.01)
self.retry_delay.start()
self.addCleanup(self.retry_delay.stop)
def tearDown(self) -> None:
_reset_simulation_warmup_for_tests()
def finish(self, check, timeout=8):
deadline = time.monotonic() + timeout
while check.snapshot()["status"] in ("pending", "running", "retrying"):
if time.monotonic() > deadline:
self.fail("Runtime check did not finish")
time.sleep(0.01)
return check.snapshot()
def test_warmup_runs_only_once_per_process(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_native_warmup") as run_warmup,
):
first = warm_up_simulation_runtime()
second = warm_up_simulation_runtime()
def test_editor_and_status_are_available_while_check_is_blocked(self):
entered, release = Event(), Event()
def pending(progress, stop):
progress(stage="compiling", message="正在编译最小自检程序")
entered.set()
self.assertTrue(release.wait(5))
with patch("app.simulation.warmup.check_native_runtime", side_effect=pending) as probe:
try:
with runtime_client() as client:
self.assertTrue(entered.wait(2))
check = app.state.simulation_runtime_check
check.start() # Duplicate calls must not launch duplicate compilers.
response = client.get("/api/simulation/runtime-check")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertEqual(response.json()["status"], "running")
self.assertEqual(response.json()["stage"], "compiling")
self.assertEqual(client.get("/api/components/catalog").status_code, 200)
self.assertEqual(client.get("/").status_code, 200)
release.set()
self.assertEqual(self.finish(check)["status"], "completed")
finally:
release.set()
probe.assert_called_once()
self.assertIs(second, first)
self.assertEqual(first.status, "completed")
run_warmup.assert_called_once_with()
def test_failure_reports_details_and_keeps_editor_available(self):
def failed(progress, stop):
progress(stage="linking", message="正在链接", compiler="test-gcc")
raise self_test.CheckFailure("链接失败", command=["test-gcc", "model.o", "-o", "model"],
cwd=Path.cwd(), exit_code=7, stderr="missing library",
error_type="CalledProcessError")
with patch("app.simulation.warmup.check_native_runtime", side_effect=failed) as probe, self.assertLogs("app.simulation.warmup", level="ERROR") as logs:
with runtime_client() as client:
report = self.finish(app.state.simulation_runtime_check)
self.assertEqual(report["status"], "failed")
self.assertEqual(report["stage"], "linking")
self.assertEqual(report["failure"]["exitCode"], 7)
self.assertEqual(report["failure"]["stderr"], "missing library")
self.assertEqual(report["attempt"], 6)
self.assertEqual(report["maxRetries"], 5)
self.assertEqual(len(report["attemptHistory"]), 6)
self.assertEqual([r["attempt"] for r in report["attemptHistory"]], list(range(1, 7)))
self.assertIn("首次检测及 5 轮重试均失败", report["message"])
self.assertEqual(client.get("/api/simulation/runtime-check").json(), report)
self.assertEqual(client.get("/api/components/catalog").status_code, 200)
self.assertEqual(probe.call_count, 6)
self.assertEqual(len(logs.records), 1, "Detailed errors must only be logged after retries are exhausted")
def test_disabled_warmup_does_not_touch_numerical_runtime(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}),
patch("app.simulation.warmup._run_native_warmup") as run_warmup,
):
report = warm_up_simulation_runtime()
def test_success_including_last_retry_stops_immediately_and_keeps_first_evidence(self):
for success_attempt in (1, 2, 6):
with self.subTest(success_attempt=success_attempt):
check = SimulationRuntimeCheck()
self.addCleanup(check.close)
calls = []
def probe(progress, stop):
calls.append(len(calls) + 1)
progress(stage="preprocessing", message="正在预处理")
self.assertIsNone(check.snapshot()["error"])
self.assertIsNone(check.snapshot()["failure"])
self.assertNotIn("attemptHistory", check.snapshot())
if len(calls) < success_attempt:
raise self_test.CheckFailure(f"first evidence {len(calls)}", exit_code=1,
stderr="gcc.exe: error: CreateProcess: No such file or directory")
with patch("app.simulation.warmup.check_native_runtime", side_effect=probe), patch("app.simulation.warmup.LOGGER") as logger:
check.start()
report = self.finish(check)
self.assertEqual(report["status"], "completed")
self.assertIn("仿真环境可用", report["message"])
self.assertEqual(report["attempt"], success_attempt)
self.assertEqual(len(calls), success_attempt)
self.assertIsNone(report["error"])
self.assertIsNone(report["failure"])
self.assertEqual(len(report["attemptHistory"]), success_attempt)
self.assertEqual(logger.warning.call_count, success_attempt - 1)
logger.error.assert_not_called()
if success_attempt > 1:
self.assertEqual(report["attemptHistory"][0]["error"], "first evidence 1")
self.assertEqual(report.status, "disabled")
run_warmup.assert_not_called()
def test_retry_wait_is_readable_nonblocking_and_cancelled_without_another_attempt(self):
def failed(progress, stop):
progress(stage="preprocessing", message="正在预处理")
raise self_test.CheckFailure("private failure", command=["gcc"], exit_code=1, stderr="private stderr")
with patch("app.simulation.warmup.RETRY_DELAY_SECONDS", 2), patch("app.simulation.warmup.check_native_runtime", side_effect=failed) as probe, patch("app.simulation.warmup.LOGGER") as logger:
with runtime_client() as client:
check = app.state.simulation_runtime_check
deadline = time.monotonic() + 2
while check.snapshot()["status"] != "retrying" and time.monotonic() < deadline:
time.sleep(0.01)
report = client.get("/api/simulation/runtime-check").json()
self.assertEqual(report["status"], "retrying")
self.assertEqual(report["attempt"], 1)
self.assertIn("第 1/5 轮重试", report["message"])
self.assertIsNone(report["error"])
self.assertIsNone(report["failure"])
self.assertNotIn("attemptHistory", report)
self.assertEqual(client.get("/api/components/catalog").status_code, 200)
started = time.monotonic()
check.close()
self.assertLess(time.monotonic() - started, 0.8)
self.assertEqual(check.snapshot()["status"], "cancelled")
self.assertEqual(probe.call_count, 1)
self.assertEqual(logger.warning.call_count, 1)
logger.error.assert_not_called()
def test_regular_failure_is_reported_without_blocking_startup(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_native_warmup",
side_effect=RuntimeError("broken warmup"),
),
self.assertLogs("app.simulation.warmup", level="ERROR"),
):
report = warm_up_simulation_runtime()
def test_disabled_check_does_not_invoke_compiler(self):
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}), patch("app.simulation.warmup.check_native_runtime") as probe:
check = SimulationRuntimeCheck()
check.start()
self.addCleanup(check.close)
self.assertEqual(self.finish(check)["status"], "disabled")
probe.assert_not_called()
self.assertEqual(report.status, "failed")
self.assertIn("broken warmup", report.error or "")
def test_restart_has_new_check_id_and_does_not_reuse_failed_status(self):
with patch("app.simulation.warmup.check_native_runtime", side_effect=RuntimeError("compiler missing")), self.assertLogs("app.simulation.warmup", level="ERROR"):
failed = SimulationRuntimeCheck()
failed.start()
self.addCleanup(failed.close)
old = self.finish(failed)
with patch("app.simulation.warmup.check_native_runtime"):
recovered = SimulationRuntimeCheck()
recovered.start()
self.addCleanup(recovered.close)
new = self.finish(recovered)
self.assertEqual(old["status"], "failed")
self.assertEqual(new["status"], "completed")
self.assertNotEqual(old["checkId"], new["checkId"])
def test_memory_error_remains_fatal(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_native_warmup",
side_effect=MemoryError("out of memory"),
),
self.assertRaises(MemoryError),
):
warm_up_simulation_runtime()
def test_invalid_configuration_is_visible_without_blocking_editor(self):
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "invalid"}), self.assertLogs("app.simulation.warmup", level="ERROR"):
with runtime_client() as client:
report = self.finish(app.state.simulation_runtime_check)
self.assertEqual(report["status"], "failed")
self.assertIn("SIMULATIONAPP_WARMUP", report["error"])
self.assertEqual(client.get("/api/components/catalog").status_code, 200)
def test_lifespan_stores_warmup_report_before_serving(self) -> None:
report = SimulationWarmupReport(status="completed", duration_ms=12.5)
def test_real_compiler_preprocesses_compiles_links_and_runs(self):
try:
build.toolchain()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
if os.environ.get("SIMULATION_NATIVE_REQUIRE_TOOLCHAIN") == "1":
raise
self.skipTest(str(exc))
check = SimulationRuntimeCheck()
check.start()
self.addCleanup(check.close)
report = self.finish(check, timeout=90)
self.assertEqual(report["status"], "completed", report)
self.assertIsNone(report["failure"])
self.assertTrue(report["compiler"])
self.assertGreater(report["durationMs"], 0)
async def enter_lifespan() -> None:
with patch(
"app.simulation.warmup.warm_up_simulation_runtime",
return_value=report,
) as warmup:
async with _app_lifespan(app):
self.assertEqual(app.state.simulation_numeric_engine, "native")
self.assertEqual(
app.state.simulation_warmup,
report.as_dict(),
)
warmup.assert_called_once_with()
asyncio.run(enter_lifespan())
class NativeSelfCheckTests(unittest.TestCase):
def test_each_process_failure_preserves_stage_and_command(self):
for failed_stage, selector in [
("preprocessing", lambda c: "-E" in c),
("compiling", lambda c: "-c" in c),
("linking", lambda c: "-lm" in c),
("running", lambda c: len(c) == 1),
]:
with self.subTest(stage=failed_stage), tempfile.TemporaryDirectory() as directory:
state = {}
base = Path(directory)
def command(args, cwd, stop, timeout=30):
if selector(args):
raise self_test.CheckFailure("failure", command=args, cwd=cwd,
exit_code=9, stderr="diagnostic")
return self_test.OUTPUT_MARKER
with (
patch.object(self_test, "CACHE", base / "builds"),
patch.object(self_test, "toolchain", return_value=("gcc", base, "test gcc")),
patch.object(self_test, "platform_build_inputs", return_value=([], [], [], "model")),
patch.object(self_test, "_command", side_effect=command),
):
with self.assertRaises(self_test.CheckFailure) as captured:
self_test.check_native_runtime(lambda **v: state.update(v), Event())
self.assertEqual(state["stage"], failed_stage)
self.assertEqual(captured.exception.details["exitCode"], 9)
self.assertEqual(captured.exception.details["stderr"], "diagnostic")
self.assertTrue(captured.exception.details["command"])
self.assertEqual(list((base / "native-runtime-checks").iterdir()), [])
def test_default_startup_checks_native_without_python_warmup(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_native_warmup") as native,
):
os.environ.pop("SIMULATION_NUMERIC_ENGINE", None)
def test_command_not_found_exit_code_and_timeout(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
cases = [
([str(base / "missing-compiler")], 3, "FileNotFoundError", None),
([sys.executable, "-c", "import sys; print('bad link',file=sys.stderr); sys.exit(7)"], 3, "CalledProcessError", 7),
([sys.executable, "-c", "import time; time.sleep(10)"], 0.1, "TimeoutExpired", None),
]
for args, timeout, error_type, exit_code in cases:
started = time.monotonic()
with self.subTest(error=error_type), self.assertRaises(self_test.CheckFailure) as captured:
self_test._command(args, base, Event(), timeout=timeout)
details = captured.exception.details
self.assertEqual(details["command"], args)
self.assertEqual(details["errorType"], error_type)
if exit_code is not None:
self.assertEqual(details["exitCode"], exit_code)
self.assertIn("bad link", details["stderr"])
if error_type == "TimeoutExpired":
self.assertLess(time.monotonic() - started, 5, "Timeout must stop child processes too")
async def enter_lifespan() -> None:
async with _app_lifespan(app):
self.assertEqual(app.state.simulation_numeric_engine, "native")
self.assertEqual(app.state.simulation_warmup["status"], "completed")
def test_cancelled_check_never_launches_a_process(self):
stop = Event()
stop.set()
with patch.object(self_test.subprocess, "Popen") as process:
with self.assertRaises(self_test.CheckCancelled):
self_test._command(["unused"], Path.cwd(), stop)
process.assert_not_called()
asyncio.run(enter_lifespan())
native.assert_called_once_with()
def test_native_failure_does_not_fall_back_to_python(self) -> None:
with (
patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native", "SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_native_warmup", side_effect=RuntimeError("C compiler unavailable")),
self.assertLogs("app.simulation.warmup", level="ERROR"),
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "failed")
self.assertIn("C compiler unavailable", report.error or "")
def test_real_numerical_warmup_completes(self) -> None:
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "completed", report.error)
self.assertGreater(report.duration_ms, 0.0)
def test_no_gcc_does_not_attempt_to_compile(self):
with patch.dict(os.environ, {"SIMULATION_NATIVE_CC": ""}), patch.object(build.shutil, "which", return_value=None):
with patch.object(self_test, "_command") as command:
state = {}
with self.assertRaisesRegex(RuntimeError, "C compiler not found"):
self_test.check_native_runtime(lambda **v: state.update(v), Event())
self.assertEqual(state["stage"], "toolchain")
command.assert_not_called()
def test_both_platforms_keep_existing_flags_libraries_and_link_order(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
(base / "lib").mkdir()
for name in build.LIBRARIES:
(base / "lib" / f"libsundials_{name}.a").touch()
for platform in ("linux", "win32"):
windows = platform == "win32"
with self.subTest(platform=platform), patch.object(build, "os", SimpleNamespace(name="nt" if windows else "posix")), patch.object(build, "sys", SimpleNamespace(platform=platform)):
flags, libraries, dlls, executable = build.platform_build_inputs(base)
self.assertEqual(executable, "model.exe" if windows else "model")
self.assertEqual(len(libraries), len(build.LIBRARIES))
self.assertEqual("-static-libgcc" in flags, windows)
self.assertEqual("-D_POSIX_C_SOURCE=200809L" in flags, not windows)
self.assertEqual(bool(dlls), windows)
arguments = build.link_library_arguments(libraries)
self.assertEqual("-Wl,--start-group" in arguments, not windows)
self.assertTrue(all(p.suffix == (".lib" if windows else ".a") for p in libraries))
if __name__ == "__main__":