Files
tick-stock-panel/backend/tests/test_minute_gap_repair.py
shy3130 864bbc49d8 fix(minute): 完善弹窗适配、批量补齐及停牌体检
拆分同步获取和数据体检页签,固定弹窗头尾并改善窄屏布局。
复用现有同步任务定向批量补拉缺口,刷新跨页面分钟缓存。
结合日线单独统计整日零成交记录,避免停牌残留反复报缺口。

验证:独立暂存快照中后端 185 项、前端 16 项测试通过;
前端构建成功,Ruff 无新增问题,git diff --check 通过。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-31 21:29:17 +08:00

156 lines
6.4 KiB
Python

"""分钟缺口一键补拉: 定向批量、任务互斥、失败与取消后的可见性。"""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import kline
from app.services import heavy_job_limiter, pipeline_jobs, preferences
from app.services.heavy_job_limiter import HeavyJobLimiter
from app.services.pipeline_jobs import JobStore
def wait_for(predicate):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.005)
raise AssertionError("分钟补拉任务未在测试期限内完成")
@pytest.fixture
def repair_env(monkeypatch, tmp_path):
monkeypatch.setattr(preferences, "load", lambda: {})
monkeypatch.setattr(pipeline_jobs, "_CANCEL_FLAGS", {})
monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", None)
store = JobStore(store_dir=tmp_path / "jobs")
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
monkeypatch.setattr(pipeline_jobs, "job_store", store)
monkeypatch.setattr(heavy_job_limiter, "shared_heavy_job_limiter", limiter)
monkeypatch.setattr(kline, "_minute_allowed", lambda _: True)
sync = MagicMock(return_value=480)
refresh = MagicMock()
invalidate = MagicMock()
pool = MagicMock(return_value=["600519.SH"])
monkeypatch.setattr(kline.kline_sync, "sync_and_persist_minute", sync)
monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh)
monkeypatch.setattr("app.api.data.invalidate_storage_cache", invalidate)
monkeypatch.setattr("app.tickflow.pools.get_pool", pool)
app = FastAPI()
app.include_router(kline.router)
app.state.repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
resolve_asset_type=lambda symbol: {"000001.SH": "index", "510300.SH": "etf"}.get(symbol, "stock"),
get_index_symbol_set=lambda: {"000001.SH"},
)
app.state.capabilities = object()
with TestClient(app) as client:
yield SimpleNamespace(
client=client, store=store, limiter=limiter, repo=app.state.repo,
sync=sync, refresh=refresh, invalidate=invalidate, pool=pool,
)
def test_repair_deduplicates_symbols_and_forces_historical_window(repair_env):
env = repair_env
response = env.client.post("/api/kline/repair_minute_gaps", json={
"symbols": ["600000.SH", " 000001.SZ ", "600000.SH"],
})
assert response.status_code == 200, response.text
result = response.json()
wait_for(lambda: env.store.get(result["job_id"])["status"] == "succeeded")
args, kwargs = env.sync.call_args
assert args[0] == ["600000.SH", "000001.SZ"]
assert kwargs["days"] == 10
assert kwargs["force_full_days"] is True
assert kwargs["extend_backward"] is False
assert env.store.get(result["job_id"])["result"] == {"minute_rows": 480, "universe_size": 2}
env.pool.assert_not_called()
env.refresh.assert_called_once_with(env.repo, "kline_minute")
env.invalidate.assert_called()
@pytest.mark.parametrize("symbols", [None, [], "600000.SH", [None], [""], [" "], [123],
["600000.SH"] * 2001, ["000001.SH"], ["510300.SH"]])
def test_repair_rejects_invalid_scope_before_creating_job(repair_env, symbols):
env = repair_env
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": symbols})
assert response.status_code == 400
env.sync.assert_not_called()
assert env.store.active_id() is None
def test_repair_requires_usable_minute_provider(repair_env, monkeypatch):
monkeypatch.setattr(kline, "_minute_allowed", lambda _: False)
response = repair_env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 403
assert "数据源" in response.json()["detail"]
repair_env.sync.assert_not_called()
def test_repair_does_not_reuse_unrelated_pending_job(repair_env):
env = repair_env
existing_id, _ = env.store.create()
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 409
assert env.store.active_id() == existing_id
env.sync.assert_not_called()
def test_failed_repair_refreshes_partial_data_and_reports_error(repair_env):
env = repair_env
env.sync.side_effect = ValueError("分钟数据源连接中断")
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
wait_for(lambda: env.store.get(job_id)["status"] == "failed")
assert "分钟数据源连接中断" in env.store.get(job_id)["error"]
env.refresh.assert_called_once_with(env.repo, "kline_minute")
env.invalidate.assert_called()
wait_for(lambda: env.limiter.in_use == 0)
def test_zero_rows_are_reported_without_claiming_gaps_fixed(repair_env):
env = repair_env
env.sync.return_value = 0
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
wait_for(lambda: env.store.get(job_id)["status"] == "succeeded")
job = env.store.get(job_id)
assert job["result"]["minute_rows"] == 0
assert "补齐成功" not in str(job["log"])
def test_cancelled_repair_refreshes_partial_data_and_releases_capacity(repair_env):
env = repair_env
entered, finish = threading.Event(), threading.Event()
def sync(*args, **kwargs):
entered.set()
assert finish.wait(3)
kwargs["on_chunk_done"](1, 2, "最近窗口")
raise AssertionError("取消后不得继续拉取")
env.sync.side_effect = sync
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
try:
assert entered.wait(1)
env.store.terminate(job_id, "用户停止分钟补拉")
finally:
finish.set()
wait_for(lambda: env.limiter.in_use == 0)
assert env.store.get(job_id)["status"] == "failed"
assert "用户停止" in env.store.get(job_id)["error"]
assert env.store.get(job_id)["stage"] == "sync_minute"
env.refresh.assert_called_once_with(env.repo, "kline_minute")