feat(data): T2 corpus_download.py daily lane+断点+锁+36测全绿 [nas]
CI/CD / test (push) Successful in 26s
CI/CD / nas-deploy (push) Failing after 14m50s
CI/CD / nas-verify (push) Has been skipped

spec §18.7 T2(corpus 栈唯一新脚本,单脚本红线):
- daily 四段: 公告增量(巨潮 T-1~T 两天窗自愈)/新闻增量(search-api 带摘要,
  (art_code,stock_code) 复合键=同稿多股 meta 多行)/新闻全文(昨日新 art_code,
  art_code 全局一条)/当日 PDF(五类服务端码 category 码+业绩快报标题谓词——
  实测 yjkb 码不存在,无效码被服务端静默忽略返回全量,故走客户端标题过滤)
- 断点: unit marker tmp+rename;--until 墙钟=完成当前 unit 后 rc=3;--limit 冒烟
  绝不落 marker(5m 09-17 教训)
- 单实例锁: fcntl.flock(state/corpus.lock)——跨容器语义(bind mount 同 inode,
  内核持有/进程死自动释放);5m 的 pid 活性检测在 docker run --rm 各自独立
  PID namespace 下失效,corpus 换 flock(让路契约同款,锁忙 rc=3)
- id 去重双层: IdLedger 全局账本(state/ids_*.parquet int64 hash,满足 §18.5
  announcement_id 全局无重)+RecentIndex 近30日分区索引(兜账本flush周期崩溃窗)
- 失败语义: 传输错 unit 不标done;真空=合法done;4xx确定性跳过不烧重试;
  域名连续错×10或429冷却它域继续;rc 0完成/1致命/2限流/3墙钟或锁
- 股票池: 巨潮 szse_stock.json(实测含退市000003+北交920001,6247条)A前缀过滤,
  非NAS副本库——orgId 为按股查询硬前提且退市覆盖更全(spec 偏差T6回写)
- 测试 36 个全 mock 零网络零写死日期;全套 253 绿
This commit is contained in:
2026-09-06 23:00:42 +08:00
parent 62a3a65419
commit e22f0e0aa8
2 changed files with 1380 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
# -*- coding: utf-8 -*-
"""TDD for corpus_download.py — NAS 基本面语料库(spec §18.7 T2)。
全 mock 零网络;日期全部相对 today(禁写死);CORPUS_ROOT 重定向 tmp_path。
契约来源: docs/fundamental_corpus_probes_20260906.md 实测 + spec §18。
"""
import datetime as dt
import fcntl
import json
import sys
from unittest.mock import MagicMock, patch
import pytest
from scripts.data_platform import corpus_download as cd
# ---------- Fixtures ----------
@pytest.fixture(autouse=True)
def _fast(monkeypatch):
"""限速归零 + 抖动归零: 测试不 sleep。"""
monkeypatch.setattr(cd, "RATE", {k: 0.0 for k in cd.RATE})
monkeypatch.setattr(cd, "JITTER", (0.0, 0.0))
@pytest.fixture
def root(tmp_path, monkeypatch):
monkeypatch.setattr(cd, "CORPUS_ROOT", tmp_path)
for sub in ("news_meta", "news_fulltext", "ann_meta", "ann_pdf", "state", "logs"):
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
return tmp_path
POOL = [("000001", "gssz0000001"), ("600519", "gssh600519")]
def _ann_raw(aid="1225275126", code="000001", title="关于xxx的公告",
ts_ms=1777563098000, **over):
row = {
"id": None, "secCode": code, "secName": "平安银行", "orgId": "gssz0000001",
"announcementId": aid, "announcementTitle": title,
"announcementTime": ts_ms, "adjunctUrl": f"finalpage/2026-04-30/{aid}.PDF",
"adjunctSize": 244, "adjunctType": "PDF", "storageTime": None,
"columnId": "09020202||250101||251302", "pageColumn": "SZZB",
"announcementType": "01010503||010112||010301", "associateAnnouncement": None,
"important": None, "batchNum": None, "announcementContent": "",
"orgName": None, "tileSecName": "平安银行", "shortTitle": title,
"announcementTypeName": None, "secNameList": None,
}
row.update(over)
return row
def _resp(json_data=None, status=200, text=""):
r = MagicMock()
r.status_code = status
r.json.return_value = json_data if json_data is not None else {}
r.text = text
r.content = b""
return r
def _cninfo_page(rows, total=None):
total = len(rows) if total is None else total
return {"announcements": rows, "totalAnnouncement": total, "hasMore": False}
# ---------- 股票池过滤 ----------
def test_pool_prefix_filter():
"""A 股前缀白名单: 00/30/60/68/43/83/87/92 过,B股(200/900)/基金(15/51/56)拒。"""
raw = [("000001", "o1"), ("300750", "o2"), ("600519", "o3"), ("688981", "o4"),
("920001", "o5"), ("430047", "o6"), ("830799", "o7"), ("871981", "o8"),
("200002", "x1"), ("900901", "x2"), ("159915", "x3"), ("510300", "x4"),
("561880", "x5"), ("110038", "x6"), ("113050", "x7"), ("161725", "x8")]
got = cd.filter_stock_pool(raw)
codes = [c for c, _ in got]
assert codes == ["000001", "300750", "600519", "688981", "920001",
"430047", "830799", "871981"]
# ---------- 归一化 ----------
def test_norm_ann_row_epoch_ms_to_naive_ts():
row = cd.norm_ann_row(_ann_raw(ts_ms=1777563098000), first_seen="2026-09-07")
# 1777563098s UTC+8 = 2026-04-30 (Asia/Shanghai, 无时区后缀)
assert row["ann_time"].startswith("2026-04-30")
assert row["announcement_id"] == "1225275126"
assert row["sec_code"] == "000001"
assert row["first_seen_date"] == "2026-09-07"
assert row["ann_type"] == "01010503||010112||010301"
assert row["ann_type_name"] is None
def test_norm_ann_row_strips_em_and_keeps_nulls():
row = cd.norm_ann_row(_ann_raw(title="2022年度<em>业绩快报</em>"),
first_seen="2026-09-07")
assert row["title"] == "2022年度业绩快报"
assert row["important"] is None
assert row["batch_num"] is None
def test_norm_news_search_row():
raw = {"code": "art123", "date": "2026-09-06 10:11:51",
"title": "贵州茅台(<em>600519</em>.SH)发布", "content": "正文摘要 有 全角",
"mediaName": "界面新闻", "image": ""}
row = cd.norm_news_search_row(raw, stock_code="600519")
assert row["art_code"] == "art123"
assert row["stock_code"] == "600519"
assert row["title"] == "贵州茅台(600519.SH)发布"
assert "<em>" not in row["summary"] and " " not in row["summary"]
assert row["media_name"] == "界面新闻"
assert row["url"] == "http://finance.eastmoney.com/a/art123.html"
assert row["show_time"] == "2026-09-06 10:11:51"
def test_norm_news_np_row_summary_null():
"""np-listapi 回补段: summary/media_name 物理不存在 → None 如实(spec §18.2)。"""
raw = {"Art_Code": "art9", "Art_ShowTime": "2023-09-01 08:00:00",
"Art_Title": "旧新闻", "Art_Url": "http://finance.eastmoney.com/a/art9.html",
"Np_dst": "CMS", "Art_SortStart": 1}
row = cd.norm_news_np_row(raw, stock_code="600519")
assert row["summary"] is None
assert row["media_name"] is None
assert row["art_code"] == "art9"
def test_html_to_text():
html = ("<html><head><script>var x=1;</script><style>.a{}</style></head>"
"<body><div> 第一段 </div><p>第二段&amp;nbsp;</p></body></html>")
text = cd.html_to_text(html)
assert "var x" not in text and ".a{}" not in text
assert "第一段" in text and "第二段" in text
# ---------- IdLedger(全局 id 去重账本, int64 hash) ----------
def test_ledger_roundtrip_and_dup_detect(root):
led = cd.IdLedger("ann_meta")
led.add(["a1", "a2", "a3"])
assert led.flush() == 3
led2 = cd.IdLedger("ann_meta")
assert led2.has_any(["a2", "zzz"]) == [True, False]
def test_ledger_empty_init(root):
led = cd.IdLedger("ann_meta")
assert led.has_any(["anything"]) == [False]
# ---------- RecentIndex(近 N 日分区 id 索引) ----------
def test_recent_index_reads_partitions(root):
today = dt.date.today()
d = root / "ann_meta" / f"dt={today.isoformat()}"
d.mkdir(parents=True)
import pandas as pd
pd.DataFrame({"announcement_id": ["old1", "old2"]}).to_parquet(d / "part-0.parquet")
idx = cd.RecentIndex("ann_meta", days=7)
assert idx.has_any(["old1", "new1"]) == [True, False]
# ---------- parquet 追加(append-only + 原子) ----------
def test_append_parquet_dedup_and_atomic(root):
import pandas as pd
today = dt.date.today().isoformat()
rows1 = [{"announcement_id": "a1", "title": "x"}, {"announcement_id": "a2", "title": "y"}]
cd.append_parquet("ann_meta", rows1, id_col="announcement_id")
part = root / "ann_meta" / f"dt={today}" / "part-0.parquet"
df = pd.read_parquet(part)
assert len(df) == 2
# 同日追加含重复 id → 只进新 id
rows2 = [{"announcement_id": "a2", "title": "y2"}, {"announcement_id": "a3", "title": "z"}]
cd.append_parquet("ann_meta", rows2, id_col="announcement_id")
df = pd.read_parquet(part)
assert sorted(df["announcement_id"]) == ["a1", "a2", "a3"]
assert not list((root / "ann_meta" / f"dt={today}").glob("*.tmp")) # 无残留 tmp
def test_append_parquet_crash_leaves_no_partial(root):
"""写中途抛错 → 不留 tmp/半截文件(下次重拉幂等前提)。"""
import pandas as pd
with patch.object(pd.DataFrame, "to_parquet", side_effect=RuntimeError("disk")):
with pytest.raises(RuntimeError):
cd.append_parquet("ann_meta", [{"announcement_id": "a1"}],
id_col="announcement_id")
today = dt.date.today().isoformat()
assert not list((root / "ann_meta" / f"dt={today}").glob("*"))
# ---------- unit marker ----------
def test_marker_done_and_skip(root):
assert not cd.is_done("daily", "ann", "000001")
cd.mark_done("daily", "ann", "000001")
assert cd.is_done("daily", "ann", "000001")
assert not cd.is_done("daily", "ann", "600519")
assert not cd.is_done("backfill", "ann", "000001") # lane 隔离
# ---------- 单实例锁(flock, 跨容器语义) ----------
def test_lock_acquire_second_refused_release_ok(root):
fd1 = cd.acquire_lock()
assert fd1 is not None
fd2 = cd.acquire_lock()
assert fd2 is None # 让路
import os
os.close(fd1) # 模拟进程退出(内核释放 flock)
assert cd.acquire_lock() is not None
def test_lock_stale_never(tmp_path):
"""flock 由内核持有,进程死即释放 — 无陈旧锁文件问题(优于 pid 检测)。"""
lock = tmp_path / "corpus.lock"
lock.write_text("999999") # 内容无所谓
import os
f = open(lock, "r+")
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
f.close() # 关闭即释放
f2 = open(lock, "r+")
fcntl.flock(f2, fcntl.LOCK_EX | fcntl.LOCK_NB) # 不抛 = 无陈旧
f2.close()
# ---------- DomainClient(限速/冷却/429) ----------
def test_client_cooldown_after_10_consecutive_errors(root):
c = cd.DomainClient("cninfo")
sess = MagicMock()
sess.request = MagicMock(side_effect=cd.requests.ConnectionError("timeout"))
c.session = sess
with pytest.raises(cd.DomainCooldown):
for _ in range(11):
try:
c.request("POST", "http://x")
except cd.TransportError:
pass # 未达阈值的传输错让单元层处理, 这里只数到冷却
assert c.consecutive_errors == 10
def test_client_429_marks_rate_limited(root):
c = cd.DomainClient("cninfo")
sess = MagicMock()
sess.request = MagicMock(return_value=_resp(status=429))
c.session = sess
with pytest.raises(cd.DomainCooldown):
c.request("POST", "http://x")
assert c.rate_limited is True
def test_client_success_resets_error_counter(root):
c = cd.DomainClient("cninfo")
seq = [_resp(status=500)] * 9 + [_resp(json_data={"ok": 1})] * 3
sess = MagicMock()
sess.request = MagicMock(side_effect=seq)
c.session = sess
for r in seq:
try:
c.request("POST", "http://x")
except cd.TransportError:
pass
assert c.consecutive_errors == 0
# ---------- fetch 层(分页/两天窗) ----------
def test_fetch_cninfo_paginates_till_short_page(root):
c = cd.DomainClient("cninfo")
pages = [_resp(json_data=_cninfo_page([_ann_raw(aid=f"a{i}") for i in range(30)],
total=45)),
_resp(json_data=_cninfo_page([_ann_raw(aid="a45")], total=45))]
sess = MagicMock()
sess.request = MagicMock(side_effect=pages)
c.session = sess
rows = cd.fetch_cninfo_announcements(c, "000001", "gssz0000001",
"2026-09-05", "2026-09-06")
assert len(rows) == 31
sent = sess.request.call_args_list[0][1]["data"]
assert sent["seDate"] == "2026-09-05~2026-09-06" # 两天窗原样
assert sent["stock"] == "000001,gssz0000001" # orgId 必带
def test_fetch_cninfo_empty_is_legal(root):
c = cd.DomainClient("cninfo")
sess = MagicMock()
sess.request = MagicMock(return_value=_resp(json_data=_cninfo_page([])))
c.session = sess
rows = cd.fetch_cninfo_announcements(c, "000001", "o", "2026-09-05", "2026-09-06")
assert rows == []
def test_fetch_news_recent_parses_jsonp(root):
c = cd.DomainClient("eastmoney")
inner = [{"code": "art1", "date": "2026-09-06 01:02:03", "title": "t",
"content": "c", "mediaName": "m", "image": ""}]
text = "cb(" + json.dumps({"result": {"cmsArticleWebOld": inner}}) + ")"
sess = MagicMock()
sess.request = MagicMock(return_value=_resp(text=text))
c.session = sess
rows = cd.fetch_news_recent(c, "600519")
assert len(rows) == 1 and rows[0]["code"] == "art1"
p = sess.request.call_args_list[0][1]["params"]
assert json.loads(p["param"])["keyword"] == "600519"
def test_fetch_news_backfill_page(root):
c = cd.DomainClient("eastmoney")
lst = [{"Art_Code": "a1", "Art_ShowTime": "2024-01-01 00:00:00",
"Art_Title": "t", "Art_Url": "http://x/a1.html", "Np_dst": "CMS"}]
sess = MagicMock()
sess.request = MagicMock(return_value=_resp(
json_data={"data": {"list": lst}}))
c.session = sess
rows = cd.fetch_news_backfill_page(c, "600519", mkt_prefix="1", page=3)
assert rows[0]["Art_Code"] == "a1"
url = sess.request.call_args_list[0][0][1]
assert "mTypeAndCode=1.600519" in url and "pageIndex=3" in url
# ---------- daily lane 集成(全 mock) ----------
def _wire_daily(monkeypatch, root, ann_pages=None, news_rows=None,
article_html="<html><body>正文内容</body></html>"):
ann_pages = ann_pages or [_cninfo_page([_ann_raw(aid="a1", code="000001")])]
news_rows = news_rows if news_rows is not None else [
{"code": "art1", "date": "2026-09-06 10:00:00", "title": "新闻一",
"content": "摘要", "mediaName": "", "image": ""}]
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
MagicMock(return_value=list(ann_pages[0]["announcements"])))
monkeypatch.setattr(cd, "fetch_news_recent", MagicMock(return_value=news_rows))
monkeypatch.setattr(cd, "fetch_article_html", MagicMock(return_value=article_html))
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1024))
def test_daily_happy_path_writes_all_domains(root, monkeypatch):
import pandas as pd
_wire_daily(monkeypatch, root)
rc = cd.run_lane("daily")
assert rc == 0
today = dt.date.today().isoformat()
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
assert len(ann) == 1 and ann.iloc[0]["announcement_id"] == "a1"
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
assert len(news) == 2 # 每股 1 条 × 2 股
ft = pd.read_parquet(root / "news_fulltext" / f"dt={today}" / "part-0.parquet")
assert len(ft) == 1 and "正文内容" in ft.iloc[0]["content_text"]
# markers: ann/news 每股 done;fulltext 每 art done
assert cd.is_done("daily", "ann", "000001") and cd.is_done("daily", "ann", "600519")
assert cd.is_done("daily", "news", "600519")
assert cd.is_done("daily", "fulltext", "art1")
def test_daily_rerun_idempotent_no_dup(root, monkeypatch):
import pandas as pd
_wire_daily(monkeypatch, root)
assert cd.run_lane("daily") == 0
# markers 全 done → 第二趟零 fetch;强制清 marker 重跑同数据 → 零重复行
for code, _ in POOL:
(root / "state" / "markers" / "daily" / "ann" / f"{code}.done").unlink()
(root / "state" / "markers" / "daily" / "news" / f"{code}.done").unlink()
assert cd.run_lane("daily") == 0
today = dt.date.today().isoformat()
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
assert len(ann) == 1 and len(news) == 2 # id 去重,零重写
def test_daily_two_day_window_relative_dates(root, monkeypatch):
"""seDate = (today-1)~today 相对构造,漏日自愈契约。"""
_wire_daily(monkeypatch, root)
cd.run_lane("daily")
sedate = cd.fetch_cninfo_announcements.call_args[0][3:5]
t = dt.date.today()
assert sedate == ((t - dt.timedelta(days=1)).isoformat(), t.isoformat())
def test_daily_two_day_window_after_missed_day(root, monkeypatch):
"""停跑一天 → 次日单次执行补齐: fetch 窗含昨天,昨日公告照入。"""
import pandas as pd
_wire_daily(monkeypatch, root)
cd.run_lane("daily")
# 清 marker 模拟「另一天再来」;同 id 仍是昨天发的 → 已在账,不重
for code, _ in POOL:
(root / "state" / "markers" / "daily" / "ann" / f"{code}.done").unlink()
cd.run_lane("daily")
today = dt.date.today().isoformat()
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
assert len(ann) == 1
def test_daily_unit_fail_not_marked_and_rc1(root, monkeypatch):
"""fetch 单股失败 → 该股不标 done,它股继续,整体 rc=1 留痕。"""
import pandas as pd
_wire_daily(monkeypatch, root)
calls = {"n": 0}
def flaky(client, code, org, start, end, category=""):
calls["n"] += 1
if code == "000001":
raise cd.TransportError("boom")
return [_ann_raw(aid="a2", code=code)]
monkeypatch.setattr(cd, "fetch_cninfo_announcements", flaky)
rc = cd.run_lane("daily")
assert rc == 1
assert not cd.is_done("daily", "ann", "000001")
assert cd.is_done("daily", "ann", "600519")
today = dt.date.today().isoformat()
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
assert len(ann) == 1 # 只有好股的
def test_daily_domain_cooldown_skips_domain_but_others_run(root, monkeypatch):
"""巨潮连续错×10 → 该域冷却,东财域照跑;rc=1。"""
import pandas as pd
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
MagicMock(side_effect=cd.TransportError("down")))
monkeypatch.setattr(cd, "fetch_news_recent", MagicMock(return_value=[
{"code": "art1", "date": "2026-09-06 10:00:00", "title": "t",
"content": "c", "mediaName": "m", "image": ""}]))
monkeypatch.setattr(cd, "fetch_article_html",
MagicMock(return_value="<body>x</body>"))
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1))
rc = cd.run_lane("daily")
assert rc == 1
today = dt.date.today().isoformat()
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
assert len(news) == 2 # 东财域全量完成
assert not cd.is_done("daily", "ann", "000001") # 巨潮域整体未标
def test_daily_429_gives_rc2(root, monkeypatch):
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
MagicMock(return_value=[]))
monkeypatch.setattr(cd, "fetch_news_recent",
MagicMock(side_effect=cd.DomainCooldown("eastmoney",
rate_limited=True)))
monkeypatch.setattr(cd, "fetch_article_html", MagicMock(return_value="x"))
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1))
# 东财域 429 冷却 → rc=2 限流让路
rc = cd.run_lane("daily")
assert rc == 2
def test_daily_limit_smoke_writes_but_no_markers(root, monkeypatch):
"""--limit 冒烟: 真数据可写,绝不落 marker(5m 09-17 教训同款)。"""
_wire_daily(monkeypatch, root)
rc = cd.run_lane("daily", limit=1)
assert rc == 0
assert not any((root / "state" / "markers").rglob("*.done"))
def test_daily_express_title_routes_to_pdf(root, monkeypatch):
"""标题含「业绩快报」→ 进当日 PDF 待下清单(无服务端码,客户端谓词)。"""
todo = cd.collect_pdf_candidates(
[_ann_raw(aid="e1", title="2025年度业绩快报"),
_ann_raw(aid="n1", title="2025年年度报告")])
assert [t["announcementId"] for t in todo] == ["e1"]
def test_daily_pdf_downloads_fivecat_and_express(root, monkeypatch):
import pandas as pd
_wire_daily(monkeypatch, root)
def fake_download(client, url, dest):
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"%PDF-1.4 fake")
return len(b"%PDF-1.4 fake")
fivecat_rows = [_ann_raw(aid="r1", title="2025年年度报告")]
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
MagicMock(return_value=fivecat_rows))
monkeypatch.setattr(cd, "download_pdf", fake_download)
cd.run_lane("daily")
idx = pd.read_parquet(root / "state" / "ann_pdf_index.parquet")
assert len(idx) == 1
assert idx.iloc[0]["announcement_id"] == "r1"
assert idx.iloc[0]["report_year"] == "2025"
assert (root / "ann_pdf" / "2026" / "r1.pdf").read_bytes().startswith(b"%PDF")
# ---------- 墙钟自停 ----------
def test_wallclock_stops_after_current_unit(root, monkeypatch):
"""--until 已过 → 完成当前 unit 后退出,rc=3,已完 unit 有 marker。"""
_wire_daily(monkeypatch, root)
past = (dt.datetime.now() - dt.timedelta(minutes=1)).strftime("%H:%M")
rc = cd.run_lane("daily", until=past)
assert rc == 3
assert cd.is_done("daily", "ann", "000001") # 首 unit 完成后停
def test_wallclock_future_runs_to_completion(root, monkeypatch):
_wire_daily(monkeypatch, root)
future = dt.datetime.now() + dt.timedelta(minutes=30)
if future.date() != dt.date.today(): # 跨午夜 → 取当天最晚时刻
future = dt.datetime.combine(dt.date.today(), dt.time(23, 59))
assert cd.run_lane("daily", until=future.strftime("%H:%M")) == 0
# ---------- depth_summary(探测优先) ----------
def test_depth_summary_records_earliest(root, monkeypatch):
_wire_daily(monkeypatch, root)
cd.run_lane("daily")
j = json.loads((root / "state" / "depth_summary.json").read_text())
assert "ann" in j or "news" in j
# ---------- main/退出码 ----------
def test_main_lock_busy_yields_exit3(root, monkeypatch):
import os
fd = cd.acquire_lock()
assert fd is not None
monkeypatch.setattr(sys, "argv", ["corpus_download.py", "--lane", "daily"])
with pytest.raises(SystemExit) as e:
cd.main()
os.close(fd)
assert e.value.code == 3 # 让路(2/3 类)
def test_main_daily_exit0(root, monkeypatch):
_wire_daily(monkeypatch, root)
monkeypatch.setattr(sys, "argv", ["corpus_download.py", "--lane", "daily"])
with pytest.raises(SystemExit) as e:
cd.main()
assert e.value.code == 0