Ctrl K
ComunidadGuías…

tests/test_failures_speak.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""退回 fallback 的時候,要留下痕跡。

XKB 有四十一處會吞掉例外,多數是對的:掃九百個檔案時單一檔案讀不到就跳過、
可有可無的加值失敗、寫遙測失敗——這些都不該中斷對話。

危險的不是「安靜」,是「一模一樣」。「語意後端掛了」跟「這個主題我們沒有
知識」都回空陣列,於是 2026-05-04 那次召回故障持續了十二週,系統每天禮貌地
回答它什麼都不知道。

這裡驗的是:那幾個會把故障說成「查無資料」的地方,現在會在 stderr 留一行。
行為不變(還是回空的,對話不會斷),但兩種情況分得出來了。
"""
from __future__ import annotations

import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))

import xkb_failures


class FailuresSpeakTest(unittest.TestCase):
    def setUp(self) -> None:
        xkb_failures.reset()
        self.addCleanup(xkb_failures.reset)

    def test_it_says_what_failed(self) -> None:
        err = io.BlockingIOError("index unreadable")
        buf = io.StringIO()
        with redirect_stderr(buf):
            xkb_failures.note("search index", err, detail="/tmp/search_index.json")
        out = buf.getvalue()
        self.assertIn("search index", out)
        self.assertIn("BlockingIOError", out)
        self.assertIn("/tmp/search_index.json", out)

    def test_the_same_failure_is_reported_once(self) -> None:
        """掃九百個檔案時,同一個原因印九百次會把有用的訊息淹掉。"""
        buf = io.StringIO()
        with redirect_stderr(buf):
            for _ in range(5):
                xkb_failures.note("search index", ValueError("bad json"))
        self.assertEqual(buf.getvalue().count("search index"), 1)

    def test_reporting_never_raises(self) -> None:
        """會報告失敗的東西,自己不能失敗。"""

        class Nasty(Exception):
            def __str__(self) -> str:
                raise RuntimeError("這個例外連自己都印不出來")

        with redirect_stderr(io.StringIO()):
            xkb_failures.note("somewhere", Nasty())  # 不該拋

    def test_a_broken_semantic_backend_is_not_silence(self) -> None:
        """語意召回的後端掛掉,要留下痕跡,但不能中斷呼叫端。"""
        import xbrain_recall

        buf = io.StringIO()
        # 只換 run。整個 subprocess 換成 Mock 的話,
        # subprocess.TimeoutExpired 也會變成 Mock,except 子句就再也攔不到東西。
        with mock.patch.object(xbrain_recall.subprocess, "run",
                               side_effect=OSError("gbrain not installed")):
            with redirect_stderr(buf):
                result = xbrain_recall.xbrain_query("測試查詢", limit=1)

        self.assertEqual(result, [], "行為不變:還是回空的,對話不會斷")
        self.assertIn("semantic recall", buf.getvalue(), "但要說出來")


if __name__ == "__main__":
    unittest.main()