"""P1-1: 单测 — capability_discovery 核心函数(双 Claw 共享层)。 为什么这个文件存在: 历史上 #866 部署因为端侧重写了发现逻辑,把 #764/#765 的注入改动覆盖回退。 这次把 discovery 层的关键纯函数 + 三级降级路径锁死为单测, 防止「第二个实现」再悄悄出现。 验证范围: 1. rank_by_query: 池子 ≤ top_k 原样返回, 池子 > top_k 按词元重叠排序 2. rank_by_query: 中文友好(整词 + 2-gram) 3. _env_int / _env_flag: 缺失 / 异常 / 正常 路径 4. _safe_name: 命名安全(消除 mcp__tool__ 里被注入特殊字符) 5. discover_skills: 三级降级(installed / published / empty) + 异常降级 6. discover_agents: explicit 优先 + 三级降级 7. discover_mcp_servers: explicit 走 resolve_active, auto 走 list_enabled 8. 双 Claw 标签注入(log 里有 claw=web/im) """ from __future__ import annotations import os import sys import asyncio import logging import importlib import pytest _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(_HERE, "..")) from app.core import capability_discovery as cd # noqa: E402 from app.core.capability_discovery import ( # noqa: E402 rank_by_query, _env_int, _env_flag, _safe_name, _tokens, discover_skills, discover_agents, discover_mcp_servers, build_mcp_tool_defs, ) # ===================================================================== # 1. rank_by_query: 纯函数 # ===================================================================== def test_rank_by_query_empty(): assert rank_by_query([], "query", 5) == [] def test_rank_by_query_below_limit_returns_intact(): items = [ {"name": "search", "description": "网页搜索"}, {"name": "calc", "description": "数学计算"}, ] out = rank_by_query(items, "anything", 5) assert out == items # top_k 大于池子,原样返回 def test_rank_by_query_above_limit_sorts_by_overlap(): items = [ {"name": "weather", "description": "查天气"}, {"name": "translate", "description": "翻译文档"}, {"name": "weather_alert", "description": "天气预警"}, {"name": "ocr", "description": "图片文字识别"}, ] out = rank_by_query(items, "天气", top_k=2) # "天气" 在 weather/weather_alert 描述里都出现,应排前 assert len(out) == 2 assert all("weather" in (x.get("name") or "") or "天气" in (x.get("description") or "") for x in out) def test_rank_by_query_zh_2gram_friendly(): items = [ {"name": "code_review", "description": "代码审查"}, {"name": "music", "description": "音乐生成"}, ] out = rank_by_query(items, "代码审查", top_k=1) assert out[0]["name"] == "code_review" def test_rank_by_query_top_k_zero_returns_empty(): assert rank_by_query([{"a": 1}], "x", 0) == [] def test_tokens_chinese_split(): toks = _tokens("代码审查 Code Review") assert "code" in toks assert "review" in toks assert "代码" in toks assert "码审" in toks # 2-gram # ===================================================================== # 2. 环境旋钮 # ===================================================================== def test_env_int_default_when_missing(monkeypatch): monkeypatch.delenv("CAP_SKILL_LIMIT", raising=False) assert _env_int("CAP_SKILL_LIMIT", 12) == 12 def test_env_int_uses_value(monkeypatch): monkeypatch.setenv("CAP_SKILL_LIMIT", "20") assert _env_int("CAP_SKILL_LIMIT", 12) == 20 def test_env_int_handles_garbage(monkeypatch): monkeypatch.setenv("CAP_SKILL_LIMIT", "not-a-number") assert _env_int("CAP_SKILL_LIMIT", 12) == 12 def test_env_flag_default_true(): assert _env_flag("__NONEXIST__") is True def test_env_flag_default_false(): assert _env_flag("__NONEXIST__", default=False) is False def test_env_flag_truthy(monkeypatch): for v in ("1", "true", "yes", "on", "TRUE"): monkeypatch.setenv("__TEST_FLAG__", v) assert _env_flag("__TEST_FLAG__") is True def test_env_flag_falsy(monkeypatch): for v in ("0", "false", "no", "off", "garbage"): monkeypatch.setenv("__TEST_FLAG__", v) assert _env_flag("__TEST_FLAG__") is False # ===================================================================== # 3. _safe_name — MCP 工具命名安全 # ===================================================================== def test_safe_name_basic(): assert _safe_name("hello world", 30) == "hello_world" def test_safe_name_maxlen(): assert _safe_name("abcdefghij" * 5, 10) == "abcdefghij" def test_safe_name_special_chars(): # 工具名只允许 [A-Za-z0-9_-] assert _safe_name("tool/name:v1", 30) == "tool_name_v1" def test_safe_name_empty(): assert _safe_name("", 30) == "" # ===================================================================== # 4. discover_skills: 三级降级(用 mock db 模拟 SQLAlchemy session) # ===================================================================== class FakeQuery: def __init__(self, rows): self._rows = rows def filter(self, *a, **kw): return self def all(self): return self._rows def order_by(self, *a, **kw): return self class FakeDb: """极简 db mock: 支持 .query(Class).filter(...).all() 链式调用。""" def __init__(self, app_install_rows=None, market_app_rows=None): self._app_install = app_install_rows or [] self._market_app = market_app_rows or [] def query(self, cls): name = getattr(cls, "__name__", str(cls)) if name == "AppInstallation": return FakeQuery(self._app_install) if name == "MarketApp": return FakeQuery(self._market_app) return FakeQuery([]) class FakeAppInstall: def __init__(self, app_id): self.app_id = app_id class FakeMarketApp: def __init__(self, id, type, status): self.id = id self.type = type self.status = status def test_discover_skills_installed_path(monkeypatch): """user_id 提供 + app_installations 非空 → 走 installed 路径""" db = FakeDb( app_install_rows=[FakeAppInstall("skill-1")], market_app_rows=[FakeMarketApp("skill-1", "skill", "published")], ) # stub _resolve_market_skills 返回一个标定 list monkeypatch.setattr( "app.api.chat_skill._resolve_market_skills", lambda d, ids, uid: [{"id": "skill-1", "name": "Search"}], raising=False, ) out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web") assert out and out[0]["id"] == "skill-1" def test_discover_skills_fallback_published(monkeypatch): """app_installations 为空 → 降级 published 全集""" db = FakeDb( app_install_rows=[], market_app_rows=[ FakeMarketApp("skill-1", "skill", "published"), FakeMarketApp("skill-2", "skill", "published"), ], ) monkeypatch.setattr( "app.api.chat_skill._resolve_market_skills", lambda d, ids, uid: [{"id": i} for i in ids], raising=False, ) out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web") assert len(out) == 2 ids = {x["id"] for x in out} assert ids == {"skill-1", "skill-2"} def test_discover_skills_fallback_disabled(monkeypatch): """CAP_SKILL_FALLBACK_PUBLISHED=0 → 不降级,空池""" monkeypatch.setenv("CAP_SKILL_FALLBACK_PUBLISHED", "0") db = FakeDb( app_install_rows=[], market_app_rows=[FakeMarketApp("skill-1", "skill", "published")], ) out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web") assert out == [] def test_discover_skills_handles_exception(monkeypatch, caplog): """db.query 抛异常 → 降级继续,不冒泡""" class BoomDb: def query(self, *a, **kw): raise RuntimeError("DB down") with caplog.at_level(logging.WARNING): out = discover_skills(BoomDb(), user_id="u-1", query="", limit=5, claw="web") assert out == [] assert any("安装技能发现失败" in r.message for r in caplog.records) def test_discover_skills_limit_zero(): db = FakeDb() out = discover_skills(db, user_id=None, query="", limit=0, claw="im") assert out == [] # ===================================================================== # 5. discover_agents: explicit > installed > published # ===================================================================== def test_discover_agents_explicit_priority(monkeypatch): """explicit_ids 存在 → 跳过 installed/published""" db = FakeDb() # 即便有 installed/published,也不该用 monkeypatch.setattr( "app.api.chat_skill._resolve_market_agents", lambda d, ids, uid: [{"id": "a-explicit"}], raising=False, ) out = discover_agents(db, user_id="u-1", query="", explicit_ids=["a-explicit"], limit=5, claw="im") assert out and out[0]["id"] == "a-explicit" def test_discover_agents_installed_path(monkeypatch): db = FakeDb( app_install_rows=[FakeAppInstall("a-1")], market_app_rows=[FakeMarketApp("a-1", "agent", "published")], ) monkeypatch.setattr( "app.api.chat_skill._resolve_market_agents", lambda d, ids, uid: [{"id": i} for i in ids], raising=False, ) out = discover_agents(db, user_id="u-1", query="", explicit_ids=None, limit=5, claw="web") assert out and out[0]["id"] == "a-1" # ===================================================================== # 6. discover_mcp_servers: explicit vs auto # ===================================================================== def test_discover_mcp_explicit(monkeypatch): db = FakeDb() monkeypatch.setattr( "app.api.chat_mcp._resolve_active_mcp_servers", lambda d, ids: [{"id": i, "name": i} for i in ids], raising=False, ) out = discover_mcp_servers(db, explicit_ids=["m1", "m2"], limit=5, claw="web") assert len(out) == 2 assert {x["id"] for x in out} == {"m1", "m2"} def test_discover_mcp_auto(monkeypatch): db = FakeDb() monkeypatch.setattr( "app.api.chat_mcp._list_enabled_mcp_servers", lambda d: [{"id": "m-auto", "name": "auto"}], raising=False, ) out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im") assert out and out[0]["id"] == "m-auto" def test_discover_mcp_auto_disabled(monkeypatch): monkeypatch.setenv("CAP_MCP_AUTO", "0") db = FakeDb() out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im") assert out == [] def test_discover_mcp_limit_clips(monkeypatch): db = FakeDb() monkeypatch.setattr( "app.api.chat_mcp._resolve_active_mcp_servers", lambda d, ids: [{"id": i, "name": i} for i in ids], raising=False, ) out = discover_mcp_servers(db, explicit_ids=["a", "b", "c", "d"], limit=2, claw="web") assert len(out) == 2 # ===================================================================== # 7. build_mcp_tool_defs: 命名 + 异常降级 # ===================================================================== def test_build_mcp_tool_defs_naming(): """验证命名规则 mcp__{server}__{tool}__{idx} 严格一致""" # 旁路 list_mcp_tools 直接构造 servers async def fake_list(srv): return [{"name": "list_things", "description": "List things", "inputSchema": {"type": "object"}}] import app.mcp_client orig = app.mcp_client.list_mcp_tools app.mcp_client.list_mcp_tools = fake_list try: defs, tool_map = asyncio.run(build_mcp_tool_defs( [{"id": "s1", "name": "github"}, {"id": "s2", "name": "fs"}], idx_start=0, )) finally: app.mcp_client.list_mcp_tools = orig assert len(defs) == 2 assert defs[0]["name"] == "mcp__github__list_things__0" assert defs[1]["name"] == "mcp__fs__list_things__1" assert defs[0]["name"] in tool_map assert tool_map[defs[0]["name"]]["server_name"] == "github" def test_build_mcp_tool_defs_continues_on_error(caplog): async def fake_list(srv): if srv["id"] == "bad": raise RuntimeError("mcp boom") return [{"name": "ok", "description": "ok", "inputSchema": {}}] import app.mcp_client orig = app.mcp_client.list_mcp_tools app.mcp_client.list_mcp_tools = fake_list try: with caplog.at_level(logging.WARNING): defs, tool_map = asyncio.run(build_mcp_tool_defs( [{"id": "bad", "name": "broken"}, {"id": "ok", "name": "good"}], )) finally: app.mcp_client.list_mcp_tools = orig assert len(defs) == 1 assert defs[0]["name"] == "mcp__good__ok__1" assert any("MCP 拉取工具失败" in r.message for r in caplog.records)