diff --git a/tests/test_cli.py b/tests/test_cli.py index c36ef9c..f4ef469 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -452,8 +452,9 @@ def test_snapshot_seed_no_source_is_non_success(): def test_defer_snapshot_seed_schedules_ten_minutes(): - """调度辅助:skip 记非成功,next_run = finished + 600。""" + """调度辅助:skip 记非成功,next_run = finished + 600;落盘 task:state=deferred。""" from reaction_board.logging_json import JsonLogger + from reaction_board.task_state import STATUS_DEFERRED, load_state with tempfile.TemporaryDirectory() as tmp: path = write_config(tmp, cookie="", csrf="") @@ -472,9 +473,64 @@ def test_defer_snapshot_seed_schedules_ten_minutes(): ) assert ctrl.ok is False assert ctrl.error == "snapshot_running" - assert abs(ctrl.next_run - (finished + cli._SEED_RETRY_SECONDS)) < 0.01 + expected_due = finished + cli._SEED_RETRY_SECONDS + assert abs(ctrl.next_run - expected_due) < 0.01 last = json.loads(repo.kv_get("task:last:snapshot_seed")) assert last["skipped"] is True and last["ok"] is False + state = load_state(repo, "snapshot_seed") + assert state is not None + assert state.status == STATUS_DEFERRED + assert abs(state.due_at - expected_due) < 0.01 + assert state.due_at > finished + assert state.error == "snapshot_running" + finally: + db.close() + + +def test_quiet_window_defer_persists_deferred_state(): + """H8: 静默窗 defer 落盘 status=deferred + due_at=窗后 01:00,不得记成功。""" + from datetime import datetime + from unittest import mock + + from reaction_board.logging_json import JsonLogger + from reaction_board.task_state import STATUS_DEFERRED, load_state + from reaction_board.timeutil import ( + SHANGHAI_TZ, + next_after_snapshot_quiet_window, + ) + + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, cookie="", csrf="") + code, _ = run_cli(["init", "--config", str(path)]) + assert code == 0 + db = Database(Path(tmp) / "app.db") + db.initialize() + try: + repo = Repository(db) + logger = JsonLogger() + # 静默窗内:2026-11-03 23:30 上海 + quiet = datetime(2026, 11, 3, 23, 30, tzinfo=SHANGHAI_TZ).timestamp() + expected_due = next_after_snapshot_quiet_window(quiet) + assert expected_due > quiet + with mock.patch("reaction_board.cli.time.time", return_value=quiet): + ctrl = cli._quiet_window_defer( + repo, logger, "discovery", started_at=int(quiet) - 1, + ) + assert ctrl.ok is False + assert ctrl.error == cli._QUIET_SKIP_REASON + assert abs(ctrl.next_run - expected_due) < 0.01 + last = json.loads(repo.kv_get("task:last:discovery")) + assert last["ok"] is False and last["skipped"] is True + assert last["error"] == cli._QUIET_SKIP_REASON + # 不得推成成功完成类 kv + assert repo.kv_get("snapshot:last_completed") is None + assert repo.kv_get("discovery:last_completed") is None + state = load_state(repo, "discovery") + assert state is not None + assert state.status == STATUS_DEFERRED + assert abs(state.due_at - expected_due) < 0.01 + assert state.due_at > quiet + assert state.error == cli._QUIET_SKIP_REASON finally: db.close() @@ -571,6 +627,8 @@ def test_candidate_cleanup_failure_retries_soon(): Repository.delete_expired_candidates = original_delete assert code == 0, output + from reaction_board.task_state import STATUS_FAILED, load_state + db = Database(Path(tmp) / "app.db") db.initialize() try: @@ -578,6 +636,13 @@ def test_candidate_cleanup_failure_retries_soon(): last = json.loads(repo.kv_get("task:last:candidate_cleanup")) assert last["ok"] is False assert "cleanup db boom" in last["error"] + state = load_state(repo, "candidate_cleanup") + assert state is not None + assert state.status == STATUS_FAILED + assert state.due_at > 0 + delta_state = state.due_at - __import__("time").time() + assert 250 <= delta_state <= 360, delta_state + assert "cleanup db boom" in (state.error or "") finally: db.close() scheduler = captured["scheduler"] @@ -759,6 +824,7 @@ def main_test(): test_snapshot_seed_no_source_is_non_success() test_snapshot_seed_same_day_completed_succeeds() test_defer_snapshot_seed_schedules_ten_minutes() + test_quiet_window_defer_persists_deferred_state() test_candidate_cleanup_failure_retries_soon() test_initial_daily_next_run_uses_wall_clock_not_zero() test_reached_daily_slot_gates_force_seed() diff --git a/tests/test_task_state.py b/tests/test_task_state.py index 28fa799..3ca8b52 100644 --- a/tests/test_task_state.py +++ b/tests/test_task_state.py @@ -15,12 +15,14 @@ from reaction_board.database import Database # noqa: E402 from reaction_board.repository import Repository # noqa: E402 from reaction_board.scheduler import ScheduledTask, Scheduler # noqa: E402 from reaction_board.task_state import ( # noqa: E402 + STATUS_DEFERRED, STATUS_DONE, STATUS_FAILED, STATUS_PENDING, STATUS_RUNNING, ensure_pending, load_state, + mark_deferred, mark_done, mark_failed, mark_running, @@ -65,22 +67,26 @@ def test_restart_mid_run_resumes_same_stat_date(): def test_seeded_before_snapshot_at_does_not_force_run(): - """seed 后挂 pending due_at=当晚;白天重启 next_run 未到,不跑。""" + """seed 后挂 pending;白天重启认落盘 due_at(刻意 ≠ 墙钟)且未到不跑。""" with tempfile.TemporaryDirectory() as tmp: db, repo = _repo(tmp) try: now = datetime(2026, 11, 3, 10, 30, tzinfo=SHANGHAI_TZ).timestamp() - slot = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp() + # 刻意偏离墙钟 23:15,证明认落盘而非 fallback + slot = datetime(2026, 11, 3, 22, 0, tzinfo=SHANGHAI_TZ).timestamp() + wall = next_daily_run(now, "23:15") + assert slot != wall ensure_pending( repo, "snapshot", due_at=slot, stat_date="2026-11-03", now=now, ) repo.kv_set("snapshot:seeded:2026-11-03", "1") nxt = startup_next_run( repo, "snapshot", - wall_due_at=next_daily_run(now, "23:15"), + wall_due_at=wall, now=now, ) assert nxt == slot + assert nxt != wall assert nxt > now calls = [] @@ -199,6 +205,52 @@ def test_no_state_falls_back_to_wall_clock(): db.close() + +def test_fail_or_defer_preserves_progress(): + """H7: mark_failed / mark_deferred 不传 progress 时保留已有 progress 与 stat_date。""" + with tempfile.TemporaryDirectory() as tmp: + db, repo = _repo(tmp) + try: + now = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp() + progress = {"done": 42, "total": 100, "cursor": "aid:99"} + mark_running( + repo, "snapshot", stat_date="2026-11-03", now=now, + progress=progress, + ) + before = load_state(repo, "snapshot") + assert before is not None + assert before.progress == progress + assert before.stat_date == "2026-11-03" + + due_fail = now + 300 + mark_failed( + repo, "snapshot", due_at=due_fail, now=now + 1, + progress=None, error="boom", + ) + failed = load_state(repo, "snapshot") + assert failed is not None + assert failed.status == STATUS_FAILED + assert failed.due_at == due_fail + assert failed.progress == progress + assert failed.stat_date == "2026-11-03" + assert failed.error == "boom" + + due_defer = now + 600 + mark_deferred( + repo, "snapshot", due_at=due_defer, now=now + 2, + progress=None, error="snapshot_quiet_window", + ) + deferred = load_state(repo, "snapshot") + assert deferred is not None + assert deferred.status == STATUS_DEFERRED + assert deferred.due_at == due_defer + assert deferred.progress == progress + assert deferred.stat_date == "2026-11-03" + assert deferred.error == "snapshot_quiet_window" + finally: + db.close() + + def main_test(): test_restart_mid_run_resumes_same_stat_date() test_seeded_before_snapshot_at_does_not_force_run() @@ -206,6 +258,7 @@ def main_test(): test_due_at_passed_runs_including_failed_retry() test_done_writes_next_due_and_blocks_until_then() test_no_state_falls_back_to_wall_clock() + test_fail_or_defer_preserves_progress() print("OK: task_state persistence / resume / due_at gates")