tests/test_health_check_sees_the_pipeline.py
Rama main · réplica de lectura del repositorio original.
"""健檢要看得見「東西進來了但沒變成知識」。
2026-09-01 到 09-09,書籤轉卡每晚都失敗、八天 0 產出,而每日健檢報的是
「索引沒有跟上新內容 / search_index last updated: 91.5h ago」。
那句話有兩個問題。一是它指錯地方:索引沒壞,是上游沒東西給它寫。二是它問錯
問題:sync_enriched_index 只在有東西要寫時才寫檔,所以「26 小時內有沒有被寫
過」實際上問的是「今天有沒有新知識進來」——安靜的一天照樣報壞,而真正的故障
它只報得出一個症狀的症狀。
這裡測的是修好之後的兩件事:
- 索引新鮮度問的是「索引有沒有落後於卡片」,不是「檔案多久沒被動過」。
- 有一項專門盯擷取管線:佇列不是空的、卻久到不合理沒生出任何新卡片。
"""
from __future__ import annotations
import os
import sys
import time
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SKILL_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SKILL_DIR / "scripts"))
import xkb_paths # noqa: E402
import health_check_pipeline as hc # noqa: E402
HOUR = 3600.0
def _touch(path: Path, *, hours_ago: float, body: str = "# card\n") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
when = time.time() - hours_ago * HOUR
os.utime(path, (when, when))
return path
class _Sandbox(unittest.TestCase):
"""把健檢指到一個臨時的知識庫,不要碰主機上真的那一份。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
root = Path(self._tmp.name)
self.cards = root / "cards"
self.bookmarks = root / "bookmarks"
self.cards.mkdir()
self.bookmarks.mkdir()
self.index = self.bookmarks / "search_index.json"
patches = [
mock.patch.object(hc, "CARDS_DIR", self.cards),
mock.patch.object(hc, "BOOKMARKS_DIR", self.bookmarks),
mock.patch.object(hc, "INDEX_FILE", self.index),
# card_files() 是「什麼算一張卡」的唯一定義,健檢也走它。
mock.patch.object(xkb_paths, "CARDS_DIR", self.cards),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
self.addCleanup(self._tmp.cleanup)
@staticmethod
def _msgs(section: dict) -> str:
return "\n".join(c["msg"] for c in section["checks"])
class CardProduction(_Sandbox):
def test_a_full_queue_with_no_new_cards_is_a_fault(self):
_touch(self.cards / "old.md", hours_ago=96)
_touch(self.bookmarks / "2096614609457488312.md", hours_ago=20)
section = hc.check_card_production()
self.assertFalse(all(c["ok"] for c in section["checks"]))
self.assertIn("沒有任何新卡片", self._msgs(section))
def test_an_empty_queue_is_not_a_fault_however_quiet_it_has_been(self):
"""沒有新書籤的時候,很久沒產出新卡片是正常的,不是故障。
舊的檢查會在這裡報壞——它其實在問「今天有沒有新東西進來」。
"""
_touch(self.cards / "old.md", hours_ago=240)
section = hc.check_card_production()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
def test_a_draining_queue_is_not_a_fault(self):
_touch(self.cards / "fresh.md", hours_ago=3)
_touch(self.bookmarks / "2096614609457488312.md", hours_ago=2)
section = hc.check_card_production()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
class IndexFreshness(_Sandbox):
def _write_index(self, *stems: str, hours_ago: float = 1, urls: dict[str, str] | None = None):
urls = urls or {}
rows = ",".join(
'{"relative_path": "memory/cards/%s.md", "source_url": "%s",'
' "summary": "s", "enriched": true}' % (s, urls.get(s, ""))
for s in stems
)
_touch(self.index, hours_ago=hours_ago, body='{"items": [%s]}' % rows)
def test_a_duplicate_card_file_is_not_a_missing_one(self):
"""同一個來源有兩個卡片檔名,索引去重後只留一列。
內容查得到,這不是故障。比檔名的版本會在這裡誤報——而誤報正是這一項
原本要修掉的毛病。
"""
url = "https://github.com/Hidicence/x-knowledge-base"
_touch(self.cards / "github_fork-x.md", hours_ago=200,
body=f'---\nsource_url: "{url}"\n---\n')
_touch(self.cards / "github_star-x.md", hours_ago=200,
body=f'---\nsource_url: "{url}"\n---\n')
# 陪襯用的卡片:讓「卡片數 vs 索引數」那一項維持在門檻內,這個測試
# 要問的是漏索引判斷,不是覆蓋率。
padding = [f"pad{i}" for i in range(20)]
for stem in padding:
_touch(self.cards / f"{stem}.md", hours_ago=200)
self._write_index("github_fork-x", *padding, hours_ago=100,
urls={"github_fork-x": url})
section = hc.check_index_freshness()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
def test_a_card_the_index_never_picked_up_is_reported(self):
_touch(self.cards / "indexed.md", hours_ago=200)
_touch(self.cards / "orphan.md", hours_ago=200)
self._write_index("indexed", hours_ago=100)
section = hc.check_index_freshness()
self.assertFalse(all(c["ok"] for c in section["checks"]))
self.assertIn("orphan.md", self._msgs(section))
def test_a_quiet_week_is_not_a_stale_index(self):
"""索引三天沒被寫過,但每張卡都在裡面——這是安靜,不是故障。
這正是 2026-09-09 那封「索引沒有跟上新內容」誤報的情況。
"""
_touch(self.cards / "old.md", hours_ago=200)
self._write_index("old", hours_ago=90)
section = hc.check_index_freshness()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
def test_a_card_still_in_flight_is_not_a_miss(self):
"""剛產出、還沒輪到同步的卡片不算落後,否則跑批次的當下必定紅燈。"""
settled = [f"old{i}" for i in range(10)]
for stem in settled:
_touch(self.cards / f"{stem}.md", hours_ago=200)
_touch(self.cards / "just_written.md", hours_ago=0.5)
self._write_index(*settled, hours_ago=1)
section = hc.check_index_freshness()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
def test_a_maintenance_rewrite_does_not_look_like_a_stale_index(self):
"""去重/鏡像/回填會動到卡片 mtime。比 mtime 的版本會在這裡誤報。"""
_touch(self.cards / "old.md", hours_ago=0.1) # 剛被維護動過,但早就在索引裡
self._write_index("old", hours_ago=40)
section = hc.check_index_freshness()
self.assertTrue(all(c["ok"] for c in section["checks"]), self._msgs(section))
if __name__ == "__main__":
unittest.main()