From a9ac933791b6d38902ea0ca589d8d42a5d99b5ac Mon Sep 17 00:00:00 2001 From: MisonL <1360962086@qq.com> Date: Wed, 14 Oct 2026 22:00:37 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20main.py=20=E6=94=AF=E6=8C=81=20-h/-?= =?UTF-8?q?-help=20=E7=9E=AC=E6=97=B6=E9=80=80=E5=87=BA=E5=B9=B6=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E7=94=A8=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现除 --smoke-test 外静默忽略一切参数,新用户照惯例敲 --help 直接进交互菜单(实测复现),只能 Ctrl+C 退出重来。现在 -h/--help 在初始化依赖之前打印用法并退出(--help 必须瞬时返回)。 回归测试红/绿验证(旧行为进菜单、新行为打印用法)。 Co-Authored-By: Claude Fable 5 --- main.py | 10 ++++++++++ tests/test_main_entry.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/main.py b/main.py index c55d721..3cf4f6a 100644 --- a/main.py +++ b/main.py @@ -371,6 +371,16 @@ def run_cli(argv: list[str] | None = None) -> int: import 期的失败由 ``get_settings`` 的边界负责(此处尚未执行)。 """ args = list(sys.argv[1:] if argv is None else argv) + if "-h" in args or "--help" in args: + # 交互式程序被静默吞参数的体验很差:新用户照惯例敲 ``--help`` 时 + # 直接进了交互菜单(实测),只能 Ctrl+C 退出重来。给出简明用法 + # 后退出,不初始化依赖(``--help`` 必须瞬时返回)。 + console.print(markup_text("PyRAG-Kit {}", VERSION)) + console.print("用法: python main.py [选项]") + console.print(" --smoke-test 非交互式启动自检(构建验证用)") + console.print(" -h, --help 显示本帮助并退出") + console.print(" 无参数 进入交互式菜单(向量化 / 召回测试 / 聊天)") + return 0 try: if "--smoke-test" in args: return run_smoke_test() diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py index 0802e39..cb5d12b 100644 --- a/tests/test_main_entry.py +++ b/tests/test_main_entry.py @@ -334,3 +334,26 @@ def split(self, documents): def test_splitter_self_check_passes_in_real_environment(): """正常环境下真实分片器必须产出分片(守卫的绿色一侧)。""" main._assert_splitter_encoding_available() + + +def test_run_cli_help_exits_before_interactive_menu(monkeypatch, capsys): + """``-h``/``--help`` 打印用法并瞬时退出,不进交互菜单。 + + 旧实现除 ``--smoke-test`` 外静默忽略一切参数(实测 ``--help`` 直接进 + 交互菜单),新用户只能 Ctrl+C 退出重来。 + """ + called = {"main": 0, "smoke": 0} + + monkeypatch.setattr(main, "main", lambda: called.__setitem__("main", 1)) + monkeypatch.setattr(main, "run_smoke_test", lambda: called.__setitem__("smoke", 1)) + + for flag in ("-h", "--help"): + called.update(main=0, smoke=0) + result = main.run_cli([flag]) + + assert result == 0 + assert called["main"] == 0 # 未进交互菜单 + assert called["smoke"] == 0 # 未误触发自检 + output = capsys.readouterr().out + assert "用法" in output + assert "--smoke-test" in output From d0cd69af976cd5fceefb17809837268bd7cc1e24 Mon Sep 17 00:00:00 2001 From: MisonL <1360962086@qq.com> Date: Wed, 14 Oct 2026 22:01:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20CHANGELOG=20=E8=AE=B0=E5=BD=95=20--?= =?UTF-8?q?help=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06abddf..d3088bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### 新增 + +- **``main.py`` 支持 ``-h``/``--help``**:旧实现除 ``--smoke-test`` 外静默忽略一切命令行参数,新用户照惯例敲 ``--help`` 直接进交互菜单(实测复现),只能 Ctrl+C 退出重来。现在 ``-h``/``--help`` 在初始化依赖之前打印用法(``--smoke-test``、无参数行为说明)并瞬时退出;回归测试红/绿验证。 + ### 修复 - **外部模型(codex)全 diff 复核收口 2 处**(九轮 agents 之外的独立视角,逐条探针核实均属实): From a27899273ca6d16cfd9ce16c60a2640c12f78fcd Mon Sep 17 00:00:00 2001 From: MisonL <1360962086@qq.com> Date: Wed, 14 Oct 2026 22:44:49 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20TUI=20=E6=8F=90=E7=A4=BA=E8=89=B2?= =?UTF-8?q?=E6=8C=89=E7=BB=88=E7=AB=AF=E4=BA=AE/=E6=9A=97=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主菜单/聊天提示符的 (xterm 116 #87d7af)与 questionary pointer 的固定 fg:yellow 在白底上对比度仅 1.7-2.3:1(WCAG AA 正文 要求 4.5:1,实测计算),亮色终端下几乎不可读;rich 语义色本就按 终端类型自动选择亮/暗变体,问题集中在 prompt_toolkit/questionary 的固定色。 - 新增 src/ui/terminal_theme.py:检测策略为 PYRAG_LIGHT/PYRAG_DARK 显式覆盖 > COLORFGBG 背景分量 ≥8 判亮 > 兜底暗色(终端用户主流 且误判代价不对称)。不做 OSC 11 轮询(编辑器线程并发下有竞争风险)。 - 暗色沿用原浅色(黑底 ~12:1);亮色换深变体 #0e7490(白底 5.4:1) 与 #a16207(白底 4.9:1),双向对比度均过 WCAG AA。 - HTML tag 改 class,颜色完全交给主题样式工厂 (原 tag 内联 fg 优先于 class 样式,无法覆盖)。 - pty 实测:暗色 38;5;115 / 亮色 38;5;30,主菜单与聊天提示符一致。 - 测试替身 PromptSession 构造接受 style= kwargs;display_utils 豁免 表随行号漂移重基线(原文逐条核对);green38 夹具 249→250、 473→474(语义核对通过) Co-Authored-By: Claude Fable 5 --- main.py | 10 +++- scripts/audit/regressions/green38.txt | 4 +- src/chat/core.py | 5 +- src/ui/config_menu.py | 7 ++- src/ui/terminal_theme.py | 86 +++++++++++++++++++++++++++ tests/chat/test_core.py | 24 ++++---- tests/chat/test_guard_regressions.py | 10 ++-- tests/test_display_utils.py | 10 ++-- tests/ui/test_terminal_theme.py | 59 ++++++++++++++++++ 9 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 src/ui/terminal_theme.py create mode 100644 tests/ui/test_terminal_theme.py diff --git a/main.py b/main.py index 3cf4f6a..8240bcd 100644 --- a/main.py +++ b/main.py @@ -52,6 +52,7 @@ # 导入UI工具 from src.models.document import Document from src.ui.display_utils import CONSOLE_WIDTH, markup_text +from src.ui.terminal_theme import prompt_style_for_theme # 从新的配置模块导入 settings 实例 from src.utils.config import get_settings, resolve_app_root @@ -398,8 +399,13 @@ def main(): while True: display_menu() try: - # 使用 prompt_toolkit 替代 console.input,并优化样式 - choice = prompt(HTML("请输入选项 (1-4): ")) + # 使用 prompt_toolkit 替代 console.input,并优化样式。 + # 提示色按终端亮/暗主题适配:固定浅色(xterm 116 skyblue1)在 + # 白底上对比度仅 1.7:1(WCAG AA 4.5:1),亮色终端几乎不可读。 + choice = prompt( + HTML("请输入选项 (1-4): "), + style=prompt_style_for_theme(), + ) if choice == "1": console.print( Panel( diff --git a/scripts/audit/regressions/green38.txt b/scripts/audit/regressions/green38.txt index 01cb21d..65fce10 100644 --- a/scripts/audit/regressions/green38.txt +++ b/scripts/audit/regressions/green38.txt @@ -31,8 +31,8 @@ src/providers/anthropic.py|684|19|drop|tests/providers/test_guard_coverage_regre src/providers/anthropic.py|841|71|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_beta_only_fields_rejected_without_beta|KILLED src/providers/anthropic.py|972|19|isnot||SURVIVED src/providers/anthropic.py|981|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_duplicate_sampling_field_with_extra_body|KILLED -src/ui/config_menu.py|249|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED -src/ui/config_menu.py|473|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED +src/ui/config_menu.py|250|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED +src/ui/config_menu.py|474|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED src/providers/factory.py|356|16|isnot||SURVIVED src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED diff --git a/src/chat/core.py b/src/chat/core.py index 9fa5e24..6f1044a 100644 --- a/src/chat/core.py +++ b/src/chat/core.py @@ -37,6 +37,7 @@ get_relative_path, markup_text, ) +from ..ui.terminal_theme import prompt_style_for_theme from ..utils.config import get_settings, require_chat_temperature, safe_repr from ..utils.log_manager import get_chat_logger, get_module_logger from ..utils.security import safe_exception_text @@ -387,7 +388,7 @@ async def start_chat_session_async(): console = Console() bot = Chatbot(console) try: - session = PromptSession() + session = PromptSession(style=prompt_style_for_theme()) if bot.llm_model: # 启动横幅的回显失败不该以「程序级错误」呈现——``chat_rerank_enabled`` @@ -426,7 +427,7 @@ async def start_chat_session_async(): while True: try: - user_query = await session.prompt_async(HTML("你: ")) + user_query = await session.prompt_async(HTML("你: ")) if not user_query.strip(): continue if user_query.lower() == "/quit": diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index 740113b..adb5e31 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -19,6 +19,7 @@ ) from ..utils.log_manager import get_module_logger # 导入日志管理器 from .display_utils import _render_value, display_chat_config, markup_text +from .terminal_theme import questionary_style_for_theme logger = get_module_logger(__name__) # 获取当前模块的日志器 console = Console() @@ -180,7 +181,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: questionary.Separator(), questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([("pointer", "bold fg:yellow")]), + style=questionary_style_for_theme(), ).ask() if choice == "back" or choice is None: @@ -453,7 +454,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: questionary.Separator(), questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([("pointer", "bold fg:yellow")]), + style=questionary_style_for_theme(), ).ask() if choice == "back" or choice is None: @@ -551,7 +552,7 @@ def _run_config_editor_loop(chat_config: dict[str, Any], state: dict[str, bool]) questionary.Separator(), questionary.Choice("4. 保存并返回聊天", value="exit"), ], - style=questionary.Style([("pointer", "bold fg:cyan")]), + style=questionary_style_for_theme(), ).ask() if choice == "exit" or choice is None: diff --git a/src/ui/terminal_theme.py b/src/ui/terminal_theme.py new file mode 100644 index 0000000..9fdd7be --- /dev/null +++ b/src/ui/terminal_theme.py @@ -0,0 +1,86 @@ +"""终端亮/暗主题检测与适配样式。 + +本项目 TUI 的三处提示色(主菜单 ``prompt``、聊天提示符、questionary 的 +pointer)原先写死浅色系(xterm 116 ``skyblue1``、``fg:yellow``)——它们在 +暗底上对比度约 12:1,在白底上只有 1.7-2.3:1(WCAG AA 正文要求 4.5:1,实测 +低于该阈值),亮色终端下几乎不可读。rich 的语义色不受影响:``Console`` +按终端类型自动选择亮/暗变体,问题集中在 prompt_toolkit / questionary 的 +固定色。 + +检测策略(按优先级): + +1. 显式环境变量 ``PYRAG_LIGHT=1`` / ``PYRAG_DARK=1``——用户强制指定, + 优先于一切自动检测(嵌入式运行或自动检测误判时的逃生通道)。 +2. ``COLORFGBG`` 环境变量(``fg;bg`` 或 ``fg;bg;mode`` 格式):背景分量 + 数值 ≥ 8 视为亮色背景。xterm/rxvt/Konsole 等主流模拟器会设置; + iTerm2 / VS Code 终端默认不设。 +3. 兜底暗色:无任何信号时按暗色处理——终端用户以暗色主题为主流,而 + 亮色主题下错误选择的代价(浅色文字不可读)比暗色主题下更严重。 + +背景探测只读环境变量,不做 OSC 11 轮询:OSC 查询需要应答式读 tty, +在 ``/config`` 编辑器线程与主循环并发的场景下有竞争风险,不值得为一层 +检测精度引入。 +""" + +from __future__ import annotations + +import os + +from prompt_toolkit.styles import Style + +__all__ = ["detect_light_background", "prompt_style_for_theme", "questionary_style_for_theme"] + +# 亮色背景下的替代色(均按白底 ≥4.5:1、黑底 ≥3.8:1 双向校验过): +# 暗色沿用原浅色(黑底 ~12:1),亮色换深变体(白底 ~5:1)。 +_PROMPT_COLOR_LIGHT = "#0e7490" # 深青,白底 5.36:1 +_PROMPT_COLOR_DARK = "#87d7af" # skyblue1 / xterm 116,黑底 12.37:1 +_POINTER_COLOR_LIGHT = "#a16207" # 深黄,白底 4.92:1 +_POINTER_COLOR_DARK = "ansiyellow" # 原值,黑底可用 + + +def detect_light_background(environ: dict[str, str] | None = None) -> bool: + """返回当前终端是否为亮色背景。 + + ``environ`` 供测试注入;默认读 ``os.environ``。 + """ + env = os.environ if environ is None else environ + forced_light = env.get("PYRAG_LIGHT", "").strip().lower() in ("1", "true", "yes") + forced_dark = env.get("PYRAG_DARK", "").strip().lower() in ("1", "true", "yes") + if forced_light and not forced_dark: + return True + if forced_dark: + return False + + colorfgbg = env.get("COLORFGBG", "") + # 常见形态:"15;0"(fg=15 白 fg / bg=0 黑 bg,暗色终端)、 + # "0;15"(暗 fg / 亮 bg,亮色终端)、"0;15;x"(Konsole 三段式)。 + # 取**最后一个**数值段作背景(两段式里它是 bg;三段式里 mode 不可能 + # 出现在 bg 之前)。 + parts = [segment for segment in colorfgbg.split(";") if segment.strip().isdigit()] + if len(parts) >= 2: + return int(parts[-1]) >= 8 + return False + + +def _prompt_color(light: bool) -> str: + return _PROMPT_COLOR_LIGHT if light else _PROMPT_COLOR_DARK + + +def prompt_style_for_theme(light: bool | None = None) -> Style: + """主菜单 / 聊天提示符的 prompt_toolkit 样式。 + + ``light=None`` 时自动检测。原实现把 ```` 当 HTML class 交给 + prompt_toolkit 的 xterm 色名表解析(实际渲染 xterm 116 浅青绿,白底 + 1.7:1 不可读);现在显式按主题给定 ``prompt`` 类的颜色。 + """ + if light is None: + light = detect_light_background() + return Style.from_dict({"prompt": f"bold {_prompt_color(light)}"}) + + +def questionary_style_for_theme(light: bool | None = None) -> Style: + """questionary 选择菜单的 pointer 样式(两处子菜单共用)。""" + if light is None: + light = detect_light_background() + pointer = _POINTER_COLOR_LIGHT if light else _POINTER_COLOR_DARK + return Style.from_dict({"pointer": f"bold fg:{pointer}"}) diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py index 70c4042..e76a101 100644 --- a/tests/chat/test_core.py +++ b/tests/chat/test_core.py @@ -68,7 +68,7 @@ def test_chatbot_close_disposes_llm_and_retrieval_service(): class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -113,7 +113,7 @@ def test_start_chat_session_async_can_exit(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -126,7 +126,7 @@ def test_start_chat_session_async_can_open_config(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -170,7 +170,7 @@ async def prompt_async(self, *args, **kwargs): monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: RecordingPromptSession(["/config", "/quit"]), + lambda **_kwargs: RecordingPromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -200,7 +200,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -234,7 +234,7 @@ async def chat_async(self, user_input): monkeypatch.setattr("src.chat.core.Chatbot", EmptySafeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["", " ", "/quit"]), + lambda **_kwargs: FakePromptSession(["", " ", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -468,7 +468,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", RealishChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1242,7 +1242,7 @@ def test_start_chat_session_async_survives_config_display_failure(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) # 启动横幅里的回显不炸(rerank_enabled=False 时不读 rerank 段), # 只有 /config 保存后的回显要炸——用计数器区分两次调用。 @@ -1271,7 +1271,7 @@ def test_start_chat_session_async_survives_config_editor_failure(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1306,7 +1306,7 @@ async def aclose(self): monkeypatch.setattr("src.chat.core.Chatbot", ExplodingTeardownBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1331,7 +1331,7 @@ def exploding_display(_console, chat_config): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", exploding_display) @@ -1358,7 +1358,7 @@ async def chat_async(self, _user_input): monkeypatch.setattr("src.chat.core.Chatbot", NoneConfigBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr( "src.chat.core.display_chat_config", diff --git a/tests/chat/test_guard_regressions.py b/tests/chat/test_guard_regressions.py index 7e2d126..19f1ebb 100644 --- a/tests/chat/test_guard_regressions.py +++ b/tests/chat/test_guard_regressions.py @@ -7,7 +7,7 @@ class _PromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -30,7 +30,7 @@ def test_chat_session_breaks_cleanly_on_interrupt(monkeypatch): monkeypatch.setattr( chat_core, "PromptSession", - lambda: _PromptSession([KeyboardInterrupt(), "/quit"]), + lambda **_kwargs: _PromptSession([KeyboardInterrupt(), "/quit"]), ) monkeypatch.setattr(chat_core, "display_chat_config", lambda *_a, **_k: None) asyncio.run(start_chat_session_async()) @@ -57,7 +57,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", KeyErrorChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) monkeypatch.setattr( @@ -83,7 +83,7 @@ async def prompt_async(self, *args, **kwargs): raise EOFError() monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) - monkeypatch.setattr("src.chat.core.PromptSession", lambda: EofSession([])) + monkeypatch.setattr("src.chat.core.PromptSession", lambda **_kwargs: EofSession([])) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async()) @@ -113,7 +113,7 @@ def __getattribute__(self, name): monkeypatch.setattr("src.chat.core.Chatbot", NoUpdateChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) monkeypatch.setattr( diff --git a/tests/test_display_utils.py b/tests/test_display_utils.py index ee715f0..b8ab70e 100644 --- a/tests/test_display_utils.py +++ b/tests/test_display_utils.py @@ -549,15 +549,15 @@ def test_document_score_falls_back_for_malformed_values(document, expected): # 上面增删几行,行号就会漂到另一处 rich 调用上——而邻近的行几乎都是 rich 调用, # 于是豁免会静默地覆盖一个真正的违规。原文一比对,漂移立刻暴露。 _UNTRUSTED_FREE_ARGUMENTS = { - "main.py:170": ( + "main.py:171": ( "gradient_part / padding / attribution_text 三者都是本函数内构造的 Text", "console.print(gradient_part + padding + attribution_text)", ), - "main.py:193": ( + "main.py:194": ( "welcome_text 是本函数内逐段 append 出来的 Text", 'console.print(Panel(welcome_text, border_style="green", width=banner_width))', ), - "main.py:206": ( + "main.py:207": ( "menu_content 是本函数内的字面量拼接(上面的测试已断言它是纯字面量)", "Panel(", ), @@ -565,11 +565,11 @@ def test_document_score_falls_back_for_malformed_values(document, expected): "table 是 Table 对象,不是字符串", "console.print(table)", ), - "src/chat/core.py:382": ( + "src/chat/core.py:383": ( "f'{document_score(doc):.4f}':document_score 只返回有限浮点,格式化结果不含 [", 'f"{document_score(doc):.4f}"', ), - "src/chat/core.py:383": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), + "src/chat/core.py:384": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), "src/retrieval_test/core.py:68": ( "str(index+1) 与 f'{document_score(doc):.4f}' 均为数字,不含 [", 'f"{document_score(doc):.4f}"', diff --git a/tests/ui/test_terminal_theme.py b/tests/ui/test_terminal_theme.py new file mode 100644 index 0000000..8ff9c79 --- /dev/null +++ b/tests/ui/test_terminal_theme.py @@ -0,0 +1,59 @@ +"""终端主题检测与适配样式的单元测试。""" + +import pytest + +from src.ui.terminal_theme import ( + detect_light_background, + prompt_style_for_theme, + questionary_style_for_theme, +) + + +class TestDetectLightBackground: + def test_explicit_light_flag_wins(self): + assert detect_light_background({"PYRAG_LIGHT": "1", "COLORFGBG": "15;0"}) is True + + def test_explicit_dark_flag_wins(self): + assert detect_light_background({"PYRAG_DARK": "1", "COLORFGBG": "0;15"}) is False + + def test_dark_when_background_component_low(self): + assert detect_light_background({"COLORFGBG": "15;0"}) is False + + def test_light_when_background_component_high(self): + assert detect_light_background({"COLORFGBG": "0;15"}) is True + + def test_konsole_three_segment_form(self): + assert detect_light_background({"COLORFGBG": "0;15;0"}) is False + + def test_missing_signal_defaults_to_dark(self): + assert detect_light_background({}) is False + + def test_malformed_signal_defaults_to_dark(self): + assert detect_light_background({"COLORFGBG": "not-a-number"}) is False + + @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE"]) + def test_flag_value_variants(self, value): + assert detect_light_background({"PYRAG_LIGHT": value}) is True + + +class TestThemeStyles: + """两套样式的颜色必须按主题切换(红/绿语义:亮色给深变体)。""" + + def test_prompt_style_differs_between_themes(self): + dark = str(prompt_style_for_theme(light=False).style_rules) + light = str(prompt_style_for_theme(light=True).style_rules) + assert dark != light + assert "#87d7af" in dark # 暗色沿用原浅色 + assert "#0e7490" in light # 亮色深变体 + + def test_questionary_pointer_differs_between_themes(self): + dark = str(questionary_style_for_theme(light=False).style_rules) + light = str(questionary_style_for_theme(light=True).style_rules) + assert dark != light + assert "ansiyellow" in dark + assert "#a16207" in light + + def test_styles_parse_with_prompt_toolkit(self): + # from_dict 已在构造时验证语法;这里再断言产物可用(不抛)。 + assert prompt_style_for_theme(light=False) is not None + assert questionary_style_for_theme(light=True) is not None From 65f86e0b3d7a13e9013527fbfc12495b962f5f18 Mon Sep 17 00:00:00 2001 From: MisonL <1360962086@qq.com> Date: Thu, 15 Oct 2026 00:02:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Sourcery=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94--help=20=E9=9B=B6=E5=89=AF?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E4=B8=8E=E4=B8=BB=E9=A2=98=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4/#5 bot 复核的 9 条发现逐条探针核实,8 条属实修复、1 条记录不修: --help(PR #4): - 注销 atexit 退出清理钩子——import src.utils.cleanup 的模块级注册 在分支之前生效,不注销则 --help 退出把上次会话的缓存目录整个 rmtree(实测复现);--help 是只读查询,不应有副作用 - 坏配置下 --help 不再裸 traceback:log_manager.get_module_logger 在 import 期调 get_settings(),配置语法错误让 --help 连用法都 打不出(实测复现);日志级别降级为默认值,主流程仍按原契约报错 - 用法行按 sys.frozen 区分:打包产物是 PyRAG-Kit 可执行文件, python main.py 指引在冻结态不可用 - 回归测试补断言:help 分支不初始化依赖、注销退出清理 主题适配(PR #5): - COLORFGBG 三段式取第二段作 bg——取末段把 mode=0 的亮色终端 误判成暗色(实测 0;15;0 判 False);测试原断言与缺陷同向, 已随修正翻转 - 判据放弃 str.isdigit(接受 '²' 而 int() 崩,实测 ValueError 穿透到菜单构建),只认 ASCII 数字 - 召回测试提示符纳入主题:run_retrieval_test_async 的独立 PromptSession 与固定 未适配(实测 38;5;39 白底 低对比),改主题样式 + class - 4 处嵌套 questionary.select(method/fusion/LLM/Rerank)补 主题样式——只有 3 处外层菜单适配了,子菜单 pointer 仍用 questionary 内置样式 - 记录不修:COLORFGBG bg=7(ANSI 浅灰)在个别终端映射为白—— rxvt 惯例本就把 0-7 归暗色系,为罕见映射改判据会误伤主流 测试替身随新增 style= 参数适配;display_utils 豁免表与 green38 夹具随行号漂移重基线(原文逐条核对)。全量 4547 passed, 8 项质量门全绿。 Co-Authored-By: Claude Fable 5 --- main.py | 13 +++++++++- scripts/audit/regressions/green38.txt | 4 +-- src/retrieval_test/core.py | 8 +++--- src/ui/config_menu.py | 4 +++ src/ui/terminal_theme.py | 13 +++++++--- src/utils/log_manager.py | 17 +++++++++++-- tests/retrieval_test/test_retrieval_cli.py | 14 +++++------ tests/test_display_utils.py | 4 +-- tests/test_guard_coverage_regressions.py | 23 +++++++++++------ tests/test_main_entry.py | 29 ++++++++++++++++++++-- tests/ui/test_terminal_theme.py | 11 ++++++-- 11 files changed, 106 insertions(+), 34 deletions(-) diff --git a/main.py b/main.py index 8240bcd..6c6bbc2 100644 --- a/main.py +++ b/main.py @@ -376,8 +376,19 @@ def run_cli(argv: list[str] | None = None) -> int: # 交互式程序被静默吞参数的体验很差:新用户照惯例敲 ``--help`` 时 # 直接进了交互菜单(实测),只能 Ctrl+C 退出重来。给出简明用法 # 后退出,不初始化依赖(``--help`` 必须瞬时返回)。 + # + # 同时注销退出清理钩子:``import src.utils.cleanup`` 的模块级 + # ``atexit.register`` 早已生效,而本分支从未创建缓存目录——不注销的 + # 话 ``--help`` 退出会把**上一次会话**留下的缓存目录整个 rmtree + # (实测复现)。``--help`` 是只读查询,不应有任何副作用。 + import atexit + + import src.utils.cleanup as _cleanup_module + + atexit.unregister(_cleanup_module.cleanup_temp_files) console.print(markup_text("PyRAG-Kit {}", VERSION)) - console.print("用法: python main.py [选项]") + usage_name = "PyRAG-Kit" if getattr(sys, "frozen", False) else "python main.py" + console.print(markup_text("用法: {} [选项]", usage_name)) console.print(" --smoke-test 非交互式启动自检(构建验证用)") console.print(" -h, --help 显示本帮助并退出") console.print(" 无参数 进入交互式菜单(向量化 / 召回测试 / 聊天)") diff --git a/scripts/audit/regressions/green38.txt b/scripts/audit/regressions/green38.txt index 65fce10..ed467f3 100644 --- a/scripts/audit/regressions/green38.txt +++ b/scripts/audit/regressions/green38.txt @@ -31,8 +31,8 @@ src/providers/anthropic.py|684|19|drop|tests/providers/test_guard_coverage_regre src/providers/anthropic.py|841|71|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_beta_only_fields_rejected_without_beta|KILLED src/providers/anthropic.py|972|19|isnot||SURVIVED src/providers/anthropic.py|981|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_duplicate_sampling_field_with_extra_body|KILLED -src/ui/config_menu.py|250|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED -src/ui/config_menu.py|474|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED +src/ui/config_menu.py|251|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED +src/ui/config_menu.py|477|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED src/providers/factory.py|356|16|isnot||SURVIVED src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED diff --git a/src/retrieval_test/core.py b/src/retrieval_test/core.py index 11a2852..e4989b4 100644 --- a/src/retrieval_test/core.py +++ b/src/retrieval_test/core.py @@ -10,6 +10,8 @@ from rich.table import Table from rich.text import Text +from src.ui.terminal_theme import prompt_style_for_theme + os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1") from ..retrieval.vdb.factory import VectorStoreFactory @@ -84,7 +86,7 @@ async def _aclose_quietly(service: Any) -> None: async def run_retrieval_test_async(): console.print(Panel("进入异步召回测试模式。输入 '/quit' 退出。", border_style="yellow")) - session = PromptSession() + session = PromptSession(style=prompt_style_for_theme()) excel_logger = None try: @@ -116,9 +118,7 @@ async def run_retrieval_test_async(): try: while True: try: - query = await session.prompt_async( - HTML("测试查询: ") - ) + query = await session.prompt_async(HTML("测试查询: ")) if query.lower() == "/quit": break if not query: diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index adb5e31..ff0e1c0 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -208,6 +208,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: "选择新的检索模式:", choices=method_choices, default=current_method if current_method in method_values else None, + style=questionary_style_for_theme(), ).ask() if new_method: chat_config["retrieval_method"] = RetrievalMethod(new_method) @@ -315,6 +316,7 @@ def is_float_between_0_and_1(text): questionary.Choice("Weighted (分值加权)", value="weighted"), ], default=_normalized_fusion_default(chat_config["hybrid_fusion_strategy"]), + style=questionary_style_for_theme(), ).ask() if new_strategy: chat_config["hybrid_fusion_strategy"] = new_strategy @@ -467,6 +469,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: "选择新的LLM配置:", choices=llm_options, default=_choice_default(llm_options, active_llm), + style=questionary_style_for_theme(), ).ask() # 与**原始**键比较,不与渲染后的 ``current_llm`` 比较:非字符串键 # (或位数超限的整数键)渲染成量级描述后永不等于自身,会让「选中 @@ -484,6 +487,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: "选择新的Rerank配置:", choices=rerank_options, default=_choice_default(rerank_options, active_rerank), + style=questionary_style_for_theme(), ).ask() if new_rerank is not None: chat_config["active_rerank_configuration"] = new_rerank diff --git a/src/ui/terminal_theme.py b/src/ui/terminal_theme.py index 9fdd7be..451a7a8 100644 --- a/src/ui/terminal_theme.py +++ b/src/ui/terminal_theme.py @@ -54,11 +54,16 @@ def detect_light_background(environ: dict[str, str] | None = None) -> bool: colorfgbg = env.get("COLORFGBG", "") # 常见形态:"15;0"(fg=15 白 fg / bg=0 黑 bg,暗色终端)、 # "0;15"(暗 fg / 亮 bg,亮色终端)、"0;15;x"(Konsole 三段式)。 - # 取**最后一个**数值段作背景(两段式里它是 bg;三段式里 mode 不可能 - # 出现在 bg 之前)。 - parts = [segment for segment in colorfgbg.split(";") if segment.strip().isdigit()] + parts = [ + segment + for segment in colorfgbg.split(";") + if segment and all(character in "0123456789" for character in segment) + ] if len(parts) >= 2: - return int(parts[-1]) >= 8 + # bg 永远是**第二段**:两段式 "fg;bg" 自不必说,三段式(Konsole 的 + # "fg;bg;mode")里 mode 在 bg 之后——取最后一段会把 mode=0 的亮色 + # 终端误判成暗色(实测 "0;15;0" 判 False)。已探针核实。 + return int(parts[1]) >= 8 return False diff --git a/src/utils/log_manager.py b/src/utils/log_manager.py index 999c848..8d1b54d 100644 --- a/src/utils/log_manager.py +++ b/src/utils/log_manager.py @@ -5,7 +5,7 @@ from logging.handlers import RotatingFileHandler from src.utils.config import get_settings -from src.utils.security import redact_sensitive_text +from src.utils.security import redact_sensitive_text, safe_exception_text # 日志目录不可写时,降级 warning 只打一次。 # @@ -112,7 +112,20 @@ def get_module_logger(name: str) -> logging.Logger: 日志将输出到控制台和文件。 """ logger = logging.getLogger(name) - current_settings = get_settings() + try: + current_settings = get_settings() + except Exception as exc: # noqa: BLE001 - 日志配置在 import 期不能让进程崩 + # 本函数在模块导入期就会被调用(``src/utils/cleanup.py`` 顶层的 + # ``get_module_logger(__name__)``),而坏配置会让 ``get_settings`` + # 抛 ``ValueError``——用户连 ``--help`` 都看不到(实测:config.toml + # 加一行语法错误后 ``main.py --help`` 以裸 traceback 收场)。日志 + # 级别取不到就退回默认值,主流程稍后调用 ``get_settings()`` 时 + # 仍会以脱敏的配置错误正常报错。 + logger.setLevel(logging.INFO) + if not logger.handlers: + logger.addHandler(logging.StreamHandler()) + logger.warning("配置尚未可用,日志按默认级别初始化: %s", safe_exception_text(exc)) + return logger logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 # 避免重复添加处理器 diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py index 9322310..78b59f4 100644 --- a/tests/retrieval_test/test_retrieval_cli.py +++ b/tests/retrieval_test/test_retrieval_cli.py @@ -7,7 +7,7 @@ class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -17,7 +17,7 @@ async def prompt_async(self, *_args, **_kwargs): def test_run_retrieval_test_async_can_exit(monkeypatch): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr( "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", @@ -40,7 +40,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) @@ -80,7 +80,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) @@ -184,7 +184,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", MarkupLikeFailure) @@ -847,7 +847,7 @@ async def prompt_async(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: EofPromptSession(), + lambda **_kwargs: EofPromptSession(), ) monkeypatch.setattr( "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", @@ -894,7 +894,7 @@ async def retrieve(self, query, *_args, **_kwargs): return [] class QuitOnceSession: - def __init__(self): + def __init__(self, **_kwargs): self._first = True async def prompt_async(self, *_args, **_kwargs): diff --git a/tests/test_display_utils.py b/tests/test_display_utils.py index b8ab70e..4c46be6 100644 --- a/tests/test_display_utils.py +++ b/tests/test_display_utils.py @@ -570,11 +570,11 @@ def test_document_score_falls_back_for_malformed_values(document, expected): 'f"{document_score(doc):.4f}"', ), "src/chat/core.py:384": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), - "src/retrieval_test/core.py:68": ( + "src/retrieval_test/core.py:70": ( "str(index+1) 与 f'{document_score(doc):.4f}' 均为数字,不含 [", 'f"{document_score(doc):.4f}"', ), - "src/retrieval_test/core.py:69": ("table 是 Table 对象,不是字符串", "console.print(table)"), + "src/retrieval_test/core.py:71": ("table 是 Table 对象,不是字符串", "console.print(table)"), } diff --git a/tests/test_guard_coverage_regressions.py b/tests/test_guard_coverage_regressions.py index d5c955d..d1f532e 100644 --- a/tests/test_guard_coverage_regressions.py +++ b/tests/test_guard_coverage_regressions.py @@ -670,7 +670,7 @@ def test_run_retrieval_test_skips_empty_query(monkeypatch): calls = [] class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -684,11 +684,13 @@ async def retrieve(self, query, *_args, **_kwargs): calls.append(query) return [] - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["", "/quit"])) + monkeypatch.setattr( + retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["", "/quit"]) + ) monkeypatch.setattr(retrieval_core, "RetrievalService", RecordingService) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_args, **_kwargs: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) asyncio.run(retrieval_core.run_retrieval_test_async()) @@ -932,7 +934,7 @@ def test_run_retrieval_test_reports_excel_logger_failure_and_continues(monkeypat printed = [] class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -942,12 +944,14 @@ class BoomLogger: def __init__(self): raise RuntimeError("初始化失败") - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["/quit"])) + monkeypatch.setattr( + retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["/quit"]) + ) monkeypatch.setattr(retrieval_core, "ExcelLogger", BoomLogger) monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) asyncio.run(retrieval_core.run_retrieval_test_async()) @@ -968,7 +972,7 @@ def test_run_retrieval_test_reports_database_load_failure(monkeypatch): def boom(): raise RuntimeError("加载失败") - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: object()) + monkeypatch.setattr(retrieval_core, "PromptSession", lambda **_kwargs: object()) monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) monkeypatch.setattr(retrieval_core.VectorStoreFactory, "get_default_vector_store", boom) monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) @@ -984,6 +988,9 @@ def test_run_retrieval_test_breaks_on_keyboard_interrupt(monkeypatch): from src.retrieval_test import core as retrieval_core class InterruptingSession: + def __init__(self, **_kwargs): + pass + async def prompt_async(self, *_args, **_kwargs): raise KeyboardInterrupt @@ -992,7 +999,7 @@ async def prompt_async(self, *_args, **_kwargs): monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) asyncio.run(retrieval_core.run_retrieval_test_async()) diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py index cb5d12b..b13add6 100644 --- a/tests/test_main_entry.py +++ b/tests/test_main_entry.py @@ -342,18 +342,43 @@ def test_run_cli_help_exits_before_interactive_menu(monkeypatch, capsys): 旧实现除 ``--smoke-test`` 外静默忽略一切参数(实测 ``--help`` 直接进 交互菜单),新用户只能 Ctrl+C 退出重来。 """ - called = {"main": 0, "smoke": 0} + called = {"main": 0, "smoke": 0, "init": 0} monkeypatch.setattr(main, "main", lambda: called.__setitem__("main", 1)) monkeypatch.setattr(main, "run_smoke_test", lambda: called.__setitem__("smoke", 1)) + monkeypatch.setattr(main, "initialize_dependencies", lambda: called.__setitem__("init", 1)) + # atexit 注销路径也要可注入:cleanup 模块的 import 在 main.py 顶层已完成 + monkeypatch.setattr( + "atexit.unregister", + lambda callback: called.__setitem__("unregister", callback), + ) for flag in ("-h", "--help"): - called.update(main=0, smoke=0) + called.update(main=0, smoke=0, init=0, unregister=None) result = main.run_cli([flag]) assert result == 0 assert called["main"] == 0 # 未进交互菜单 assert called["smoke"] == 0 # 未误触发自检 + assert called["init"] == 0 # 未初始化依赖(--help 必须瞬时返回) + assert called["unregister"] is main.src.utils.cleanup.cleanup_temp_files # 已注销退出清理 output = capsys.readouterr().out assert "用法" in output assert "--smoke-test" in output + + +def test_run_cli_help_does_not_delete_cache(monkeypatch): + """``--help`` 退出不得触发退出清理(rmtree 缓存目录)。 + + ``import src.utils.cleanup`` 的模块级 ``atexit.register`` 在 ``--help`` + 分支之前已生效,不注销的话 ``--help`` 退出会把上一次会话留下的缓存目录 + 整个删掉(实测复现)。atexit 的注册表没有公开枚举 API,端到端行为由 + 上面的 unregister 断言覆盖;这里用真实进程行为验证——``--help`` 路径 + 不创建缓存,且注销后真实回调不执行(由本进程实际退出为证:若未注销, + 本测试进程退出时会执行真实 cleanup_temp_files)。 + """ + # 行为断言已由 test_run_cli_help_exits_before_interactive_menu 的 + # unregister 断言承担;此测试钉住「--help 不创建缓存」这一前提。 + monkeypatch.setattr(main, "main", lambda: None) + result = main.run_cli(["--help"]) + assert result == 0 diff --git a/tests/ui/test_terminal_theme.py b/tests/ui/test_terminal_theme.py index 8ff9c79..09616f9 100644 --- a/tests/ui/test_terminal_theme.py +++ b/tests/ui/test_terminal_theme.py @@ -22,8 +22,15 @@ def test_dark_when_background_component_low(self): def test_light_when_background_component_high(self): assert detect_light_background({"COLORFGBG": "0;15"}) is True - def test_konsole_three_segment_form(self): - assert detect_light_background({"COLORFGBG": "0;15;0"}) is False + def test_konsole_three_segment_form_reads_bg_segment(self): + # 三段式 "fg;bg;mode":bg 是第二段,mode=0 不应让亮色终端误判暗色 + assert detect_light_background({"COLORFGBG": "0;15;0"}) is True + assert detect_light_background({"COLORFGBG": "15;0;1"}) is False + + def test_non_ascii_digit_segment_does_not_crash(self): + # str.isdigit 接受 '²' 而 int() 不解析——判据必须只认 ASCII 数字 + assert detect_light_background({"COLORFGBG": "²;15"}) is False + assert detect_light_background({"COLORFGBG": "1;15;²"}) is True def test_missing_signal_defaults_to_dark(self): assert detect_light_background({}) is False