Files
ai-exchange/tests/unit/test_run_until_blocked.py
T
2026-05-08 08:09:00 +08:00

237 lines
12 KiB
Python

import contextlib
import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from scripts import run_until_blocked
class RunUntilBlockedTests(unittest.TestCase):
def test_advances_to_next_stage_and_updates_anchors(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
session_path = Path(tmp) / "SESSION.md"
tasks_path = Path(tmp) / "TASKS.md"
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n- [x] `P0-1` 实时市场真值层\n", encoding="utf-8")
original_session = run_until_blocked.SESSION_PATH
original_tasks = run_until_blocked.TASKS_PATH
original_runner = run_until_blocked.run_trading_delivery_rehearsal
try:
run_until_blocked.SESSION_PATH = session_path
run_until_blocked.TASKS_PATH = tasks_path
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
"mode": "rehearsal_completed",
"passed": True,
"continuation_decision": "advance",
"promotion_decision": "promote_candidate",
"blockers": [],
}
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_MAX_ROUNDS": "1"})
session_text = session_path.read_text(encoding="utf-8")
tasks_text = tasks_path.read_text(encoding="utf-8")
finally:
run_until_blocked.SESSION_PATH = original_session
run_until_blocked.TASKS_PATH = original_tasks
run_until_blocked.run_trading_delivery_rehearsal = original_runner
self.assertEqual(summary["status"], "stopped")
self.assertEqual(summary["completed_stages"], ["P0-1", "P0-2"])
self.assertEqual(summary["current_stage"], "P0-3")
self.assertIn("### `P0-3` 盘中持续调度与运行监督", session_text)
self.assertIn("- [x] `P0-2` 真实账户与订单只读同步", tasks_text)
self.assertIn("- [ ] `P0-3` 盘中持续调度与运行监督", tasks_text)
def test_stops_when_rehearsal_reports_blocked(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
session_path = Path(tmp) / "SESSION.md"
tasks_path = Path(tmp) / "TASKS.md"
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
original_session = run_until_blocked.SESSION_PATH
original_tasks = run_until_blocked.TASKS_PATH
original_runner = run_until_blocked.run_trading_delivery_rehearsal
try:
run_until_blocked.SESSION_PATH = session_path
run_until_blocked.TASKS_PATH = tasks_path
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
"mode": "autonomous_cycle_failed",
"passed": False,
"continuation_decision": "blocked",
"promotion_decision": "hold",
"blockers": ["缺凭据:broker_readonly_api_key"],
}
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_MAX_ROUNDS": "2"})
tasks_text = tasks_path.read_text(encoding="utf-8")
session_text = session_path.read_text(encoding="utf-8")
finally:
run_until_blocked.SESSION_PATH = original_session
run_until_blocked.TASKS_PATH = original_tasks
run_until_blocked.run_trading_delivery_rehearsal = original_runner
self.assertEqual(summary["status"], "blocked")
self.assertEqual(summary["blocked_reason"], "缺凭据:broker_readonly_api_key")
self.assertEqual(summary["current_stage"], "P0-2")
self.assertIn("缺凭据:broker_readonly_api_key", tasks_text)
self.assertIn("当前自动化已阻塞:缺凭据:broker_readonly_api_key", session_text)
def test_json_output_is_machine_readable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
session_path = Path(tmp) / "SESSION.md"
tasks_path = Path(tmp) / "TASKS.md"
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
original_session = run_until_blocked.SESSION_PATH
original_tasks = run_until_blocked.TASKS_PATH
original_runner = run_until_blocked.run_trading_delivery_rehearsal
try:
run_until_blocked.SESSION_PATH = session_path
run_until_blocked.TASKS_PATH = tasks_path
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
"mode": "autonomous_cycle_failed",
"passed": False,
"continuation_decision": "blocked",
"promotion_decision": "hold",
"blockers": ["缺凭据:broker_readonly_api_key"],
}
output = io.StringIO()
with contextlib.redirect_stdout(output):
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "1"})
finally:
run_until_blocked.SESSION_PATH = original_session
run_until_blocked.TASKS_PATH = original_tasks
run_until_blocked.run_trading_delivery_rehearsal = original_runner
payload = json.loads(output.getvalue())
self.assertEqual(payload["status"], "blocked")
self.assertEqual(payload["blocked_reason"], "缺凭据:broker_readonly_api_key")
self.assertEqual(summary["status"], "blocked")
def test_run_until_blocked_writes_runner_status_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
session_path = Path(tmp) / "SESSION.md"
tasks_path = Path(tmp) / "TASKS.md"
status_path = Path(tmp) / "automation_status.json"
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
original_session = run_until_blocked.SESSION_PATH
original_tasks = run_until_blocked.TASKS_PATH
original_status = run_until_blocked.RUNNER_STATUS_PATH
original_runner = run_until_blocked.run_trading_delivery_rehearsal
try:
run_until_blocked.SESSION_PATH = session_path
run_until_blocked.TASKS_PATH = tasks_path
run_until_blocked.RUNNER_STATUS_PATH = status_path
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
"mode": "autonomous_cycle_failed",
"passed": False,
"continuation_decision": "blocked",
"promotion_decision": "hold",
"blockers": ["缺凭据:broker_readonly_api_key"],
}
run_until_blocked.run_until_blocked(env={})
payload = json.loads(status_path.read_text(encoding="utf-8"))
finally:
run_until_blocked.SESSION_PATH = original_session
run_until_blocked.TASKS_PATH = original_tasks
run_until_blocked.RUNNER_STATUS_PATH = original_status
run_until_blocked.run_trading_delivery_rehearsal = original_runner
self.assertEqual(payload["state"], "blocked")
self.assertEqual(payload["current_stage"], "P0-2")
self.assertEqual(payload["blocked_reason"], "缺凭据:broker_readonly_api_key")
def test_supervisor_pauses_on_code_fix_blocker(self) -> None:
sleeper = Mock()
original_run_until_blocked = run_until_blocked.run_until_blocked
original_fingerprint = run_until_blocked.compute_resume_fingerprint
try:
run_until_blocked.run_until_blocked = lambda **kwargs: {
"status": "blocked",
"started_ts": "2026-05-07T00:00:00+00:00",
"current_stage": "P0-2",
"completed_stages": ["P0-1"],
"blocked_reason": "autonomous_mode:pipeline_failed",
"rounds": [{"round": 1}],
}
run_until_blocked.compute_resume_fingerprint = lambda: "same"
result = run_until_blocked.run_supervised_until_stopped(
env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"},
sleeper=sleeper,
)
finally:
run_until_blocked.run_until_blocked = original_run_until_blocked
run_until_blocked.compute_resume_fingerprint = original_fingerprint
sleeper.assert_called_once_with(1)
self.assertEqual(result["status"], "blocked")
def test_supervisor_resumes_after_fingerprint_change(self) -> None:
summaries = [
{
"status": "blocked",
"started_ts": "2026-05-07T00:00:00+00:00",
"current_stage": "P0-2",
"completed_stages": ["P0-1"],
"blocked_reason": "autonomous_mode:pipeline_failed",
"rounds": [{"round": 1}],
},
{
"status": "completed",
"started_ts": "2026-05-07T00:10:00+00:00",
"current_stage": None,
"completed_stages": ["P0-1", "P0-2"],
"blocked_reason": None,
"rounds": [{"round": 1}],
},
]
fingerprints = ["a", "a", "b", "b"]
sleeper = Mock()
original_run_until_blocked = run_until_blocked.run_until_blocked
original_fingerprint = run_until_blocked.compute_resume_fingerprint
try:
run_until_blocked.run_until_blocked = lambda **kwargs: summaries.pop(0)
run_until_blocked.compute_resume_fingerprint = lambda: fingerprints.pop(0)
result = run_until_blocked.run_supervised_until_stopped(
env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"},
sleeper=sleeper,
)
finally:
run_until_blocked.run_until_blocked = original_run_until_blocked
run_until_blocked.compute_resume_fingerprint = original_fingerprint
sleeper.assert_called_once_with(1)
self.assertEqual(result["status"], "completed")
self.assertEqual(result["completed_stages"], ["P0-1", "P0-2"])
def test_main_uses_supervisor_mode_when_enabled(self) -> None:
original_run_until_blocked = run_until_blocked.run_until_blocked
original_supervisor = run_until_blocked.run_supervised_until_stopped
original_environ = dict(os.environ)
try:
os.environ["RUN_UNTIL_BLOCKED_SUPERVISOR"] = "1"
run_until_blocked.run_until_blocked = lambda **kwargs: (_ for _ in ()).throw(AssertionError("should not call one-shot runner"))
run_until_blocked.run_supervised_until_stopped = lambda **kwargs: {"status": "completed"}
exit_code = run_until_blocked.main()
finally:
os.environ.clear()
os.environ.update(original_environ)
run_until_blocked.run_until_blocked = original_run_until_blocked
run_until_blocked.run_supervised_until_stopped = original_supervisor
self.assertEqual(exit_code, 0)
if __name__ == "__main__":
unittest.main()