mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: support runtime multi-time scheduling * fix(review-feedback-1731): 修复 and add the missing workers value here, likely None to preserve the * fix: refine scheduler settings UI * fix: compact scheduler time inputs * fix(review-feedback-1731): Refresh scheduler status after saving changes * fix(review-feedback-1731): Refresh scheduler status after env imports * fix(review-feedback-1731): Do not mark swallowed analysis failures as success * fix: align serve scheduler ownership * fix: preserve runtime scheduler startup semantics * fix: ignore immediate run when cli owns schedule * fix(review-feedback-1731): main.py + api/app.py:--serve-only 下虽然 runtime schedule requested 被置为 * fix(review-feedback-1731): apps/dsa-desktop/main.js / main.py / api/app.py / src/services/runtime * fix: preserve runtime scheduler ownership semantics * fix(review-feedback-1731): 修正 Desktop 启动会自动分析/调度,以及 Web 调度开关在 runtime 状态与持久配置不一致时无法触发 reconcile 的问题 * fix: remove duplicate scheduler save diff variable * fix: allow empty schedule times config * fix: reject busy scheduler run-now requests * fix(review-feedback-1731): 确认并修正 runtime scheduler 重建时可能重复注册 AGENT EVENT MONITOR ENABLED 后台任务的问题 * fix: keep runtime event monitor idempotent * fix: preserve runtime scheduler CLI flags * fix(review-feedback-1731): preserve schedule CLI flags in runtime runs * fix(review-feedback-1731): 修正 --serve --schedule / --serve + SCHEDULE ENABLED=true 下 API * fix(review-feedback-1731): 修复当前 diff 引入的运行时并发风险和 Uvicorn 兼容风险
230 lines
8.2 KiB
Python
230 lines
8.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for Scheduler background task support."""
|
|
|
|
from datetime import datetime
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
class _FakeJob:
|
|
def __init__(self, schedule_module):
|
|
self._schedule_module = schedule_module
|
|
self.next_run = datetime(2026, 1, 1, 18, 0, 0)
|
|
self.at_time = None
|
|
|
|
@property
|
|
def day(self):
|
|
return self
|
|
|
|
def at(self, value):
|
|
self.at_time = value
|
|
hour, minute = [int(part) for part in value.split(":")]
|
|
self.next_run = datetime(2026, 1, 1, hour, minute, 0)
|
|
return self
|
|
|
|
def do(self, fn):
|
|
self.job_func = fn
|
|
self._schedule_module.jobs.append(self)
|
|
return self
|
|
|
|
|
|
class _FakeScheduleModule:
|
|
def __init__(self):
|
|
self.jobs = []
|
|
|
|
def every(self):
|
|
return _FakeJob(self)
|
|
|
|
def get_jobs(self):
|
|
return list(self.jobs)
|
|
|
|
def run_pending(self):
|
|
return None
|
|
|
|
def cancel_job(self, job):
|
|
self.jobs.remove(job)
|
|
|
|
|
|
class SchedulerBackgroundTaskTestCase(unittest.TestCase):
|
|
def test_background_task_runs_when_interval_elapsed(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(schedule_time="18:00")
|
|
calls = []
|
|
fake_thread = MagicMock()
|
|
fake_thread.is_alive.return_value = False
|
|
|
|
def _make_thread(target=None, **kwargs):
|
|
fake_thread.start.side_effect = target
|
|
return fake_thread
|
|
|
|
with patch("src.scheduler.threading.Thread", side_effect=_make_thread):
|
|
scheduler.add_background_task(lambda: calls.append("ran"), interval_seconds=1, run_immediately=True, name="test")
|
|
|
|
self.assertEqual(calls, ["ran"])
|
|
|
|
def test_background_task_waits_for_interval(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(schedule_time="18:00")
|
|
calls = []
|
|
scheduler.add_background_task(lambda: calls.append("ran"), interval_seconds=60, run_immediately=False, name="test")
|
|
|
|
with patch("src.scheduler.time.time", return_value=scheduler._background_tasks[0]["last_run"] + 10):
|
|
scheduler._run_background_tasks()
|
|
|
|
self.assertEqual(calls, [])
|
|
|
|
def test_run_with_schedule_registers_background_tasks_before_immediate_daily_task(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src import scheduler as scheduler_module
|
|
|
|
order = []
|
|
|
|
class FakeScheduler:
|
|
def __init__(self, schedule_time="18:00", schedule_time_provider=None):
|
|
order.append(("init", schedule_time))
|
|
order.append(("provider", callable(schedule_time_provider)))
|
|
|
|
def add_background_task(self, **kwargs):
|
|
order.append(("background", kwargs["name"]))
|
|
|
|
def set_daily_task(self, task, run_immediately=True):
|
|
order.append(("daily", run_immediately))
|
|
|
|
def run(self):
|
|
order.append(("run", None))
|
|
|
|
with patch.object(scheduler_module, "Scheduler", FakeScheduler):
|
|
scheduler_module.run_with_schedule(
|
|
task=lambda: None,
|
|
run_immediately=True,
|
|
background_tasks=[{
|
|
"task": lambda: None,
|
|
"interval_seconds": 60,
|
|
"run_immediately": True,
|
|
"name": "event_monitor",
|
|
}],
|
|
)
|
|
|
|
self.assertEqual(order[:4], [("init", "18:00"), ("provider", False), ("background", "event_monitor"), ("daily", True)])
|
|
|
|
def test_scheduler_reloads_daily_job_when_schedule_time_changes(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(
|
|
schedule_time="18:00",
|
|
schedule_time_provider=lambda: "09:30",
|
|
)
|
|
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
|
|
|
self.assertEqual(len(fake_schedule.jobs), 1)
|
|
self.assertEqual(fake_schedule.jobs[0].at_time, "18:00")
|
|
|
|
scheduler._refresh_daily_schedule_if_needed()
|
|
|
|
self.assertEqual(len(fake_schedule.jobs), 1)
|
|
self.assertEqual(fake_schedule.jobs[0].at_time, "09:30")
|
|
self.assertEqual(scheduler.schedule_time, "09:30")
|
|
|
|
def test_scheduler_registers_multiple_daily_jobs(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(
|
|
schedule_time="18:00",
|
|
schedule_times=["15:10", "09:20", "15:10"],
|
|
)
|
|
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
|
|
|
self.assertEqual([job.at_time for job in fake_schedule.jobs], ["09:20", "15:10"])
|
|
self.assertEqual(scheduler.schedule_times, ["09:20", "15:10"])
|
|
|
|
def test_scheduler_stop_cancels_registered_daily_jobs(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(
|
|
schedule_time="18:00",
|
|
schedule_times=["09:20", "15:10"],
|
|
)
|
|
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
|
self.assertEqual(len(fake_schedule.jobs), 2)
|
|
|
|
scheduler.stop()
|
|
|
|
self.assertEqual(fake_schedule.jobs, [])
|
|
self.assertEqual(scheduler._daily_jobs, [])
|
|
self.assertIsNone(scheduler._daily_job)
|
|
|
|
def test_scheduler_keeps_existing_daily_job_when_schedule_time_invalid(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(
|
|
schedule_time="18:00",
|
|
schedule_time_provider=lambda: "25:99",
|
|
)
|
|
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
|
|
|
scheduler._refresh_daily_schedule_if_needed()
|
|
|
|
self.assertEqual(len(fake_schedule.jobs), 1)
|
|
self.assertEqual(fake_schedule.jobs[0].at_time, "18:00")
|
|
self.assertEqual(scheduler.schedule_time, "18:00")
|
|
|
|
def test_scheduler_keeps_current_daily_job_when_schedule_time_provider_fails(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
provider_calls = {"count": 0}
|
|
|
|
def provider():
|
|
provider_calls["count"] += 1
|
|
if provider_calls["count"] == 1:
|
|
return "09:30"
|
|
raise RuntimeError("boom")
|
|
|
|
scheduler = Scheduler(
|
|
schedule_time="18:00",
|
|
schedule_time_provider=provider,
|
|
)
|
|
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
|
|
|
scheduler._refresh_daily_schedule_if_needed()
|
|
scheduler._refresh_daily_schedule_if_needed()
|
|
|
|
self.assertEqual(len(fake_schedule.jobs), 1)
|
|
self.assertEqual(fake_schedule.jobs[0].at_time, "09:30")
|
|
self.assertEqual(scheduler.schedule_time, "09:30")
|
|
|
|
def test_scheduler_rejects_invalid_initial_schedule_time(self):
|
|
fake_schedule = _FakeScheduleModule()
|
|
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
|
from src.scheduler import Scheduler
|
|
|
|
scheduler = Scheduler(schedule_time="25:99")
|
|
calls = []
|
|
|
|
with self.assertRaisesRegex(ValueError, "25:99"):
|
|
scheduler.set_daily_task(lambda: calls.append("ran"), run_immediately=True)
|
|
|
|
self.assertEqual(calls, [])
|
|
self.assertEqual(fake_schedule.jobs, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|