Files
cnfinancialscraper/tests/test_regression_bugs.py

203 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""v4.7 阶段 A 修复的回归测试。
覆盖:
- search_engine.py 缺少 import random(致命 NameError)
- crawl_backtester.py typing 缺 Tuple
- name_scraper.py 招商银行 URL 错误映射
- regulatory_scraper.py 废弃域名 cbirc → nfra
- cninfo_scraper.py column/orgId 硬编码参数化
- exchange_scraper.py CATALOGID/TABKEY 提取为常量
- web_parser.py if False 死代码分支清理
- mcp_server.py 导入前缀一致性与死代码清理
"""
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
sys.path.insert(0, str(ROOT_DIR))
class TestSearchEngineRandomImport(unittest.TestCase):
"""A1: search_engine.py 必须有 import random,否则 search() 崩溃。"""
def test_module_has_random_import(self):
src = (SCRIPTS_DIR / "search_engine.py").read_text(encoding="utf-8")
import re
self.assertTrue(
re.search(r"^import random$|^import random\s*$", src, re.MULTILINE),
"search_engine.py 缺少 import random",
)
def test_search_no_name_error(self):
"""mock urlopen 后调用 DDG search(),不应抛 NameError。"""
from unittest import mock
from search_engine import DuckDuckGoHTML
fake_html = (
'<html><body><div class="result">'
'<a class="result__a" href="http://example.com">测试标题</a>'
'<div class="result__snippet">摘要</div></div></body></html>'
)
class FakeResp:
def __init__(self, content):
self._content = content
def read(self):
return self._content.encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *a):
return False
def getheader(self, n, d=None):
return "text/html"
with mock.patch("urllib.request.urlopen", return_value=FakeResp(fake_html)):
s = DuckDuckGoHTML()
try:
results = s.search("测试", limit=5)
# 不抛 NameError 即算通过
self.assertIsInstance(results, list)
except NameError as e:
self.fail(f"search() 触发 NameError: {e}")
class TestBacktesterTupleImport(unittest.TestCase):
"""A10: crawl_backtester.py 的 typing 需导入 Tuple。"""
def test_tuple_in_typing_import(self):
src = (SCRIPTS_DIR / "crawl_backtester.py").read_text(encoding="utf-8")
self.assertIn("Tuple", src.split("\n")[0:60][-1] if False else src.splitlines()[38] if len(src.splitlines()) > 38 else src)
# 更稳:检查 typing import 行包含 Tuple
typing_line = [l for l in src.splitlines() if "from typing import" in l][0]
self.assertIn("Tuple", typing_line)
def test_module_imports(self):
import crawl_backtester
self.assertTrue(hasattr(crawl_backtester, "CrawlBacktester"))
class TestNameScraperURLs(unittest.TestCase):
"""A5: 招商银行应指向 cmbchina.com,民生银行指向 cmbc.com.cn。"""
def test_correct_bank_urls(self):
from name_scraper import URL_PATTERNS
self.assertEqual(URL_PATTERNS.get("招商银行"), "https://www.cmbchina.com",
"招商银行官网应为 cmbchina.com(原错误指向民生银行)")
self.assertEqual(URL_PATTERNS.get("民生银行"), "https://www.cmbc.com.cn")
def test_urls_valid_format(self):
from name_scraper import URL_PATTERNS
for name, url in URL_PATTERNS.items():
self.assertTrue(url.startswith("https://") or url.startswith("http://"),
f"{name} 的 URL 格式错误: {url}")
class TestRegulatoryDomain(unittest.TestCase):
"""A6: regulatory_scraper.py 数据源应为商业源(东财/新浪/巨潮),不含 gov.cn。"""
def test_no_gov_domain(self):
src = (SCRIPTS_DIR / "regulatory_scraper.py").read_text(encoding="utf-8")
self.assertNotIn("gov.cn", src, "regulatory_scraper 不应包含 gov.cn 域名")
self.assertNotIn("cbirc.gov.cn", src, "不应引用已废弃的 cbirc 域名")
self.assertNotIn("nfra.gov.cn", src, "不应引用 nfra 域名")
def test_commercial_sources(self):
"""应使用商业数据源(东方财富/新浪/巨潮)。"""
src = (SCRIPTS_DIR / "regulatory_scraper.py").read_text(encoding="utf-8")
self.assertIn("eastmoney", src, "应使用东方财富数据源")
self.assertIn("cninfo", src, "应使用巨潮资讯数据源")
def test_api_shape_kept(self):
"""对外 API 形状应保留(get_regulatory_updates 等)。"""
src = (SCRIPTS_DIR / "regulatory_scraper.py").read_text(encoding="utf-8")
self.assertIn("def get_regulatory_updates", src,
"对外 API get_regulatory_updates 应保留")
class TestCninfoParametrized(unittest.TestCase):
"""A7: cninfo_scraper.py 的 column/orgId 应参数化。"""
def test_market_param_in_signature(self):
src = (SCRIPTS_DIR / "cninfo_scraper.py").read_text(encoding="utf-8")
self.assertIn("market: str = \"szse\"", src,
"search_announcements 应支持 market 参数")
def test_no_hardcoded_orgid(self):
"""硬编码 orgId 不应再作为 stock 参数的实际取值。
注释中提及旧魔数属正常文档,故仅校验取值逻辑(org_prefix 推导)。
"""
src = (SCRIPTS_DIR / "cninfo_scraper.py").read_text(encoding="utf-8")
self.assertIn('org_prefix = {"szse": "gssz", "sse": "gssh", "bjse": "gsbj"}',
src, "应按市场推导 orgId")
self.assertIn("stock_param = f\"{stock_code},orgId,{org_prefix}{zcode}\"",
src, "stock 参数应使用推导的 orgId")
# 不再以 gssz0000858 作为字面量拼接进 stock 参数
self.assertNotIn("\"stock\": f\"{stock_code},orgId,gssz0000858\"", src,
"硬编码的五粮液 orgId 不应再作为取值")
class TestExchangeConstants(unittest.TestCase):
"""A8: exchange_scraper.py 的 CATALOGID/TABKEY 应提取为常量。"""
def test_constants_defined(self):
src = (SCRIPTS_DIR / "exchange_scraper.py").read_text(encoding="utf-8")
for const in ("SZSE_CATALOG_IPO", "SZSE_CATALOG_LISTED",
"SZSE_CATALOG_ANNOUNCEMENT", "SZSE_TABKEY"):
self.assertIn(f"{const} =", src, f"缺少常量 {const}")
def test_no_literal_catalogid(self):
src = (SCRIPTS_DIR / "exchange_scraper.py").read_text(encoding="utf-8")
# 请求体里不应再有字面 "1110" CATALOGID(常量里可以有)
self.assertIn('"CATALOGID": SZSE_CATALOG', src,
"请求参数应引用常量")
class TestWebParserNoDeadCode(unittest.TestCase):
"""A9: web_parser.py 的 if False 死代码分支应被清理。"""
def test_no_if_false_branches(self):
src = (SCRIPTS_DIR / "web_parser.py").read_text(encoding="utf-8")
self.assertNotIn("if False else", src,
"web_parser.py 仍有 if False 死代码")
class TestMcpServerImports(unittest.TestCase):
"""A4: mcp_server.py 导入前缀一致且无死代码。"""
def test_no_scripts_prefix_import(self):
src = (ROOT_DIR / "mcp_server.py").read_text(encoding="utf-8")
self.assertNotIn("from scripts.fullpage_archiver", src,
"应使用无前缀导入 from fullpage_archiver")
self.assertNotIn("from scripts.search_engine", src,
"应使用无前缀导入 from search_engine")
self.assertIn("from fullpage_archiver import", src)
self.assertIn("from search_engine import", src)
def test_dead_imports_removed(self):
src = (ROOT_DIR / "mcp_server.py").read_text(encoding="utf-8")
for dead in ("get_stock_brief", "cls_articles", "get_stock_list",
"create_scheduled_task", "package_crawl_results",
"compress_multiple", "MultiFormatParser",
"get_template_outline", "generate_report_from_raw",
"quick_report"):
self.assertNotIn(f"import {dead}", src, f"死代码导入 {dead} 应被删除")
self.assertNotIn(f"import {dead} as", src, f"死代码导入 {dead} 应被删除")
class TestSearchEngineNameError(unittest.TestCase):
"""search_engine.py 修复后 import 不应失败,且多引擎可实例化。"""
def test_multi_engine_init(self):
from search_engine import MultiEngineSearch
s = MultiEngineSearch(engines=["duckduckgo"])
self.assertEqual(s.engines[0].name, "duckduckgo")
if __name__ == "__main__":
unittest.main()