mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: support KR stock autocomplete suggestions * fix(review-feedback-1723): 补充自动补全 JP/KR 建议渲染截图、前后对比或无法截图说明 * fix(review-feedback-1723): 补充 Web 自动补全 JP/KR 建议渲染的截图或无法截图说明 * fix(review-feedback-1723): docs/review-evidence-kr-autocomplete-ui.md 是针对本 PR * fix(review-feedback-1723): 修正 PR 描述与当前 head 的实际改动、验证范围不一致问题 * fix(review-feedback-1723): 修正 PR 描述与当前 diff 的实质性不一致,避免合入记录误导后续维护者 * fix(review-feedback-1723): 收敛 PR 描述与实际 diff 的契约不一致,避免用户误以为裸 005930 已会解析为 005930.KS * fix(review-feedback-1723): 把描述、验证证据和兼容性说明收敛到当前 6 个改动文件真实范围,或补齐描述中声称的后端/历史链路实现与测试 * fix(review-feedback-1723): 收敛 PR 描述与实际 diff,避免把未实现的 bare-code/历史展示能力作为本 PR 已交付内容合入 * fix(review-feedback-1723): 补上相应真实代码与测试后再审 * fix(review-feedback-1723): 补齐所声称的后端实现与测试后再评审 * fix(review-feedback-1723): PR description 与当前完整 diff 不一致:描述声称新增 bare JP/KR code * fix(review-feedback-1723): PR 描述与当前完整 diff 不一致 * fix(review-feedback-1723): 补充 Refs 1718,方便后续追踪 * fix(review-feedback-1723): 收敛描述与验证证据 * fix: resolve JP KR bare stock codes * fix: backfill JP KR daily analysis context * fix(review-feedback-1723): preserve persisted phase timing * fix: align JP KR history lookup keys * fix(review-feedback-1723): preserve raw code for snapshot lookups * fix(review-feedback-1723): 收敛
238 lines
8.8 KiB
Python
238 lines
8.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for best-effort remote stock-index caching."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from src.services import stock_index_remote_service as service
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_remote_stock_index_state() -> None:
|
|
service._reset_remote_stock_index_state_for_tests()
|
|
yield
|
|
service._reset_remote_stock_index_state_for_tests()
|
|
|
|
|
|
def _stock_index_payload(size: int = 100, *, name: str = "平安银行") -> list[list[object]]:
|
|
return [
|
|
[
|
|
f"{index:06d}.SZ",
|
|
f"{index:06d}",
|
|
name,
|
|
"pinganyinhang",
|
|
"payh",
|
|
[],
|
|
"CN",
|
|
"stock",
|
|
True,
|
|
100,
|
|
]
|
|
for index in range(size)
|
|
]
|
|
|
|
|
|
def _bse_stock_index_payload(size: int = 100) -> list[list[object]]:
|
|
payload = _stock_index_payload(size=size)
|
|
payload[0][0] = "920964.BJ"
|
|
payload[0][1] = "920964"
|
|
payload[0][2] = "润农节水"
|
|
payload[0][6] = "BSE"
|
|
return payload
|
|
|
|
|
|
def _response(payload: object) -> Mock:
|
|
response = Mock()
|
|
response.content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
response.encoding = "utf-8"
|
|
response.raise_for_status.return_value = None
|
|
return response
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_writes_valid_payload(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path)
|
|
|
|
with patch.object(service.requests, "get", return_value=_response(_stock_index_payload())) as get:
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.refreshed is True
|
|
assert result.cache_path == cache_path
|
|
assert json.loads(cache_path.read_text(encoding="utf-8"))[0][2] == "平安银行"
|
|
get.assert_called_once_with(service.DEFAULT_STOCK_INDEX_REMOTE_URL, timeout=10)
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_decodes_remote_payload_as_utf8(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path)
|
|
response = _response(_stock_index_payload())
|
|
response.encoding = "ascii"
|
|
|
|
with patch.object(service.requests, "get", return_value=response):
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.refreshed is True
|
|
assert json.loads(cache_path.read_text(encoding="utf-8"))[0][2] == "平安银行"
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_clears_backend_loader_cache(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path)
|
|
|
|
with patch.object(service.requests, "get", return_value=_response(_stock_index_payload())), \
|
|
patch("src.data.stock_index_loader.clear_stock_index_cache") as clear_cache:
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.refreshed is True
|
|
clear_cache.assert_called_once_with()
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_skips_fresh_cache(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
cache_path.write_text(json.dumps(_stock_index_payload(), ensure_ascii=False), encoding="utf-8")
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path, ttl_hours=48)
|
|
|
|
with patch.object(service.requests, "get") as get:
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.skipped is True
|
|
assert result.cache_path == cache_path
|
|
get.assert_not_called()
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_keeps_old_cache_on_download_failure(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
cache_path.write_text(json.dumps(_stock_index_payload(name="旧缓存"), ensure_ascii=False), encoding="utf-8")
|
|
old_mtime = time.time() - 100 * 3600
|
|
os.utime(cache_path, (old_mtime, old_mtime))
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path, ttl_hours=48)
|
|
|
|
with patch.object(service.requests, "get", side_effect=TimeoutError("timeout")):
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.cache_path == cache_path
|
|
assert result.error == "timeout"
|
|
assert json.loads(cache_path.read_text(encoding="utf-8"))[0][2] == "旧缓存"
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_suppresses_after_repeated_failures(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path, ttl_hours=48)
|
|
|
|
with patch.object(service.requests, "get", side_effect=TimeoutError("timeout")) as get:
|
|
results = [service.refresh_remote_stock_index_cache(settings) for _ in range(6)]
|
|
|
|
assert get.call_count == service.DEFAULT_STOCK_INDEX_REMOTE_MAX_FAILURES
|
|
assert results[-1].skipped is True
|
|
assert results[-1].error == "remote update temporarily suppressed after repeated failures"
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_retries_after_failure_window(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path, ttl_hours=1)
|
|
|
|
with patch.object(service.time, "time", return_value=1_000.0), \
|
|
patch.object(service.requests, "get", side_effect=TimeoutError("timeout")):
|
|
for _ in range(service.DEFAULT_STOCK_INDEX_REMOTE_MAX_FAILURES):
|
|
service.refresh_remote_stock_index_cache(settings)
|
|
|
|
with patch.object(service.time, "time", return_value=4_601.0), \
|
|
patch.object(service.requests, "get", return_value=_response(_stock_index_payload())) as get:
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.refreshed is True
|
|
get.assert_called_once()
|
|
|
|
|
|
def test_refresh_remote_stock_index_cache_rejects_invalid_remote_payload(tmp_path: Path) -> None:
|
|
cache_path = tmp_path / "stocks.index.json"
|
|
cache_path.write_text(json.dumps(_stock_index_payload(name="旧缓存"), ensure_ascii=False), encoding="utf-8")
|
|
settings = service.RemoteStockIndexSettings(cache_path=cache_path, ttl_hours=0)
|
|
|
|
with patch.object(service.requests, "get", return_value=_response([["000001.SZ"]])):
|
|
result = service.refresh_remote_stock_index_cache(settings)
|
|
|
|
assert result.cache_path == cache_path
|
|
assert result.error
|
|
assert json.loads(cache_path.read_text(encoding="utf-8"))[0][2] == "旧缓存"
|
|
|
|
|
|
def test_validate_stock_index_payload_accepts_bse_market() -> None:
|
|
payload = _bse_stock_index_payload()
|
|
|
|
assert service.validate_stock_index_payload(payload) is payload
|
|
|
|
|
|
def test_validate_stock_index_payload_accepts_jp_and_kr_markets() -> None:
|
|
payload = _stock_index_payload()
|
|
payload[0][0] = "7203.T"
|
|
payload[0][1] = "7203.T"
|
|
payload[0][6] = "JP"
|
|
payload[1][0] = "000660.KS"
|
|
payload[1][1] = "000660.KS"
|
|
payload[1][6] = "KR"
|
|
|
|
assert service.validate_stock_index_payload(payload) is payload
|
|
|
|
|
|
@pytest.mark.parametrize("popularity", [None, "100", True, float("nan"), float("inf")])
|
|
def test_validate_stock_index_payload_rejects_invalid_popularity(popularity: object) -> None:
|
|
payload = _stock_index_payload()
|
|
payload[0][9] = popularity
|
|
|
|
with pytest.raises(ValueError, match="popularity"):
|
|
service.validate_stock_index_payload(payload)
|
|
|
|
|
|
def test_validate_stock_index_payload_rejects_missing_popularity() -> None:
|
|
payload = _stock_index_payload()
|
|
payload[0] = payload[0][:9]
|
|
|
|
with pytest.raises(ValueError):
|
|
service.validate_stock_index_payload(payload)
|
|
|
|
|
|
def test_missing_remote_stock_index_cache_is_silent(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
cache_path = tmp_path / "missing" / "stocks.index.json"
|
|
|
|
with caplog.at_level(logging.WARNING, logger="src.services.stock_index_remote_service"):
|
|
assert service.is_valid_remote_stock_index_file(cache_path) is False
|
|
|
|
assert not any("cached remote index is invalid" in record.getMessage() for record in caplog.records)
|
|
|
|
|
|
def test_settings_from_config_uses_internal_remote_url_only() -> None:
|
|
config = SimpleNamespace(
|
|
stock_index_remote_update_enabled=False,
|
|
stock_index_remote_url="https://example.invalid/override.json",
|
|
)
|
|
|
|
settings = service.settings_from_config(config)
|
|
|
|
assert settings.enabled is False
|
|
assert settings.url == service.DEFAULT_STOCK_INDEX_REMOTE_URL
|
|
assert settings.ttl_hours == service.DEFAULT_STOCK_INDEX_REMOTE_TTL_HOURS
|
|
assert settings.timeout_seconds == service.DEFAULT_STOCK_INDEX_REMOTE_TIMEOUT_SECONDS
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{},
|
|
[["000001.SZ"]],
|
|
_stock_index_payload(size=99),
|
|
],
|
|
)
|
|
def test_validate_stock_index_payload_rejects_bad_payloads(payload: object) -> None:
|
|
with pytest.raises(ValueError):
|
|
service.validate_stock_index_payload(payload)
|