"""Query Expansion 模块测试 — Phase 3b + Issue #141 质量增强"""

from __future__ import annotations

import asyncio
import sqlite3
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.query import EnrichedQuery, preprocess_query
from index1.query_expand import (
    ExpandResult,
    GroundedTerm,
    _extract_keywords_from_text,
    _fts5_match_count,
    _parse_expansion_json,
    _prepare_fts5_term,
    _target_expansion_count,
    expand_query,
    needs_expansion,
    validate_cooccurrence,
    validate_expansion,
    validate_expansion_grounded,
    _cache_get,
    _cache_put,
    _expansion_cache,
)


# ── 辅助函数 ──


def _make_enriched(
    query: str = "test",
    route: str = "text_search",
    phrases: list[str] | None = None,
    short_query: bool = False,
    compressed: bool = False,
    token_query: str = '"test"',
) -> EnrichedQuery:
    """快速构造 EnrichedQuery"""
    return EnrichedQuery(
        original=query,
        normalized=query,
        phrases=phrases or [],
        token_query=token_query,
        route=route,
        short_query=short_query,
        compressed=compressed,
    )


# ── needs_expansion 门控测试 ──


class TestNeedsExpansion:
    def test_short_query_triggers(self):
        e = _make_enriched(short_query=True)
        assert needs_expansion(e) is True

    def test_symbol_lookup_skips(self):
        e = _make_enriched(route="symbol_lookup")
        assert needs_expansion(e) is False

    def test_graph_query_skips(self):
        e = _make_enriched(route="graph_query")
        assert needs_expansion(e) is False

    def test_multi_phrase_skips(self):
        e = _make_enriched(phrases=["Event Buffer", "Circuit Breaker"])
        assert needs_expansion(e) is False

    def test_single_phrase_does_not_skip(self):
        e = _make_enriched(
            phrases=["Event Buffer"],
            token_query='"event" OR "buffer"',
        )
        # 单个短语 + 2 tokens → 触发
        assert needs_expansion(e) is True

    def test_long_compressed_skips(self):
        e = _make_enriched(compressed=True)
        assert needs_expansion(e) is False

    def test_3_tokens_triggers(self):
        e = _make_enriched(token_query='"safe" OR "db" OR "write"')
        assert needs_expansion(e) is True

    def test_4_tokens_skips(self):
        e = _make_enriched(
            token_query='"safe" OR "db" OR "write" OR "function"',
        )
        assert needs_expansion(e) is False

    def test_1_token_triggers(self):
        e = _make_enriched(token_query='"search"')
        assert needs_expansion(e) is True

    def test_empty_token_query(self):
        e = _make_enriched(token_query='""')
        assert needs_expansion(e) is True

    def test_real_preprocess_short(self):
        """用真实 preprocess_query 验证短查询"""
        enriched = preprocess_query("缓存")
        assert needs_expansion(enriched) is True

    def test_real_preprocess_long(self):
        """用真实 preprocess_query 验证长查询"""
        enriched = preprocess_query("how does the search engine handle BM25 and vector fusion")
        assert needs_expansion(enriched) is False


# ── JSON 解析测试 ──


class TestParseExpansionJson:
    def test_valid_array(self):
        result = _parse_expansion_json('["rerank", "search", "vector"]')
        assert result == ["rerank", "search", "vector"]

    def test_trailing_comma(self):
        result = _parse_expansion_json('["rerank", "search",]')
        assert result == ["rerank", "search"]

    def test_with_surrounding_text(self):
        raw = 'Here are related terms:\n["embedding", "cosine"]\nHope this helps!'
        result = _parse_expansion_json(raw)
        assert result == ["embedding", "cosine"]

    def test_invalid_json(self):
        result = _parse_expansion_json("not json at all")
        assert result is None

    def test_empty_string(self):
        result = _parse_expansion_json("")
        assert result is None

    def test_none_input(self):
        result = _parse_expansion_json(None)
        assert result is None

    def test_non_array(self):
        result = _parse_expansion_json('{"key": "value"}')
        assert result is None

    def test_mixed_types(self):
        """数组中有非字符串元素"""
        result = _parse_expansion_json('["valid", 123, null, "also_valid"]')
        # 非空元素转为字符串
        assert "valid" in result
        assert "also_valid" in result

    def test_empty_array(self):
        result = _parse_expansion_json("[]")
        assert result == []

    def test_nested_brackets(self):
        """LLM 输出中有额外文本和 markdown"""
        raw = '```json\n["term1", "term2"]\n```'
        result = _parse_expansion_json(raw)
        assert result == ["term1", "term2"]


# ── 纯文本 fallback 测试 ──


class TestExtractKeywords:
    def test_quoted_words(self):
        raw = 'Related terms: "rerank", "embedding", "fusion"'
        result = _extract_keywords_from_text(raw)
        assert "rerank" in result
        assert "embedding" in result

    def test_backtick_words(self):
        raw = "You should search for `reranker` and `cross_encoder`"
        result = _extract_keywords_from_text(raw)
        assert "reranker" in result
        assert "cross_encoder" in result

    def test_identifiers(self):
        raw = "Try searching for BM25 and cosine similarity"
        result = _extract_keywords_from_text(raw)
        assert "BM25" in result
        assert "cosine" in result
        assert "similarity" in result

    def test_empty(self):
        assert _extract_keywords_from_text("") == []

    def test_none(self):
        assert _extract_keywords_from_text(None) == []

    def test_filters_stopwords(self):
        raw = '"the" "a" "is" "rerank" "with" "embedding"'
        result = _extract_keywords_from_text(raw)
        assert "the" not in result
        assert "rerank" in result

    def test_max_5(self):
        raw = '"a1" "b2" "c3" "d4" "e5" "f6" "g7"'
        result = _extract_keywords_from_text(raw)
        assert len(result) <= 5


# ── Validation 测试 ──


class TestValidateExpansion:
    def test_basic(self):
        result = validate_expansion(["rerank", "embedding", "search"])
        assert result == ["rerank", "embedding", "search"]

    def test_dedup(self):
        result = validate_expansion(["Rerank", "rerank", "RERANK"])
        assert len(result) == 1

    def test_filter_short(self):
        result = validate_expansion(["a", "b", "rerank"])
        assert result == ["rerank"]

    def test_filter_numeric(self):
        result = validate_expansion(["123", "456", "rerank"])
        assert result == ["rerank"]

    def test_filter_long(self):
        long_term = "x" * 51
        result = validate_expansion([long_term, "rerank"])
        assert result == ["rerank"]

    def test_max_5(self):
        terms = [f"term_{i}" for i in range(10)]
        result = validate_expansion(terms)
        assert len(result) == 5

    def test_empty_input(self):
        assert validate_expansion([]) == []

    def test_non_string_items(self):
        result = validate_expansion([123, None, "rerank"])
        assert result == ["rerank"]

    def test_strip_whitespace(self):
        result = validate_expansion(["  rerank  ", " embedding "])
        assert "rerank" in result
        assert "embedding" in result


# ── Prompt 语言自适应测试 ──


class TestPromptSelection:
    def test_cjk_uses_chinese(self):
        """CJK 查询使用中文 prompt"""
        from index1.query_expand import _ZH_PROMPT, _CJK_RE

        query = "搜索引擎优化"
        chars = query.replace(" ", "")
        cjk_count = len(_CJK_RE.findall(chars))
        ratio = cjk_count / len(chars) if chars else 0.0
        assert ratio > 0.5

    def test_english_uses_english(self):
        """英文查询使用英文 prompt"""
        from index1.query_expand import _CJK_RE

        query = "search engine optimization"
        chars = query.replace(" ", "")
        cjk_count = len(_CJK_RE.findall(chars))
        ratio = cjk_count / len(chars) if chars else 0.0
        assert ratio <= 0.5


# ── 缓存测试 ──


class TestExpansionCache:
    def setup_method(self):
        _expansion_cache.clear()

    def test_cache_miss(self):
        assert _cache_get("miss", None) is None

    def test_cache_hit(self):
        _cache_put("test_q", "coll", ["term1", "term2"])
        result = _cache_get("test_q", "coll")
        assert result == ["term1", "term2"]

    def test_cache_different_collection(self):
        _cache_put("test_q", "coll_a", ["a"])
        assert _cache_get("test_q", "coll_b") is None

    def test_cache_expiry(self):
        """模拟过期"""
        from index1.query_expand import _cache_key
        key = _cache_key("expired", None)
        _expansion_cache[key] = (["old"], time.monotonic() - 1.0)
        assert _cache_get("expired", None) is None


# ── expand_query 端到端测试 ──


class TestExpandQuery:
    def setup_method(self):
        _expansion_cache.clear()

    @pytest.mark.asyncio
    async def test_cache_hit_skips_provider(self):
        """缓存命中时不调用 provider"""
        _cache_put("cached_q", None, ["cached_term"])
        enriched = _make_enriched(query="cached_q")
        config = MagicMock()
        config.expansion_enabled = True

        result = await expand_query("cached_q", enriched, config)
        assert isinstance(result, ExpandResult)
        assert result.terms == ["cached_term"]
        assert result.cache_hit is True

    @pytest.mark.asyncio
    async def test_disabled_returns_empty(self):
        """expansion_enabled=False 时返回空"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = False

        result = await expand_query("test", enriched, config)
        assert isinstance(result, ExpandResult)
        assert result.terms == []
        assert result.triggered is False

    @pytest.mark.asyncio
    async def test_no_providers_returns_empty(self):
        """无可用 provider 时返回空"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[],
        ):
            result = await expand_query("test", enriched, config)
            assert isinstance(result, ExpandResult)
            assert result.terms == []
            assert result.failure_reason == "no providers available"

    @pytest.mark.asyncio
    async def test_provider_json_success(self):
        """provider 返回有效 JSON"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "test_provider"
        mock_provider.generate = AsyncMock(
            return_value='["embedding", "vector", "cosine"]'
        )

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert isinstance(result, ExpandResult)
            assert "embedding" in result.terms
            assert "vector" in result.terms
            assert result.provider_name == "test_provider"
            assert result.parsed_method == "json"

    @pytest.mark.asyncio
    async def test_provider_text_fallback(self):
        """provider 返回非 JSON，text fallback 提取"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "test_provider"
        mock_provider.generate = AsyncMock(
            return_value='Related terms: "embedding", "vector_search", "cosine"'
        )

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert isinstance(result, ExpandResult)
            assert len(result.terms) > 0
            assert result.parsed_method == "text_fallback"

    @pytest.mark.asyncio
    async def test_provider_failure_returns_empty(self):
        """provider 异常时返回空"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "failing_provider"
        mock_provider.generate = AsyncMock(side_effect=RuntimeError("API down"))

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert isinstance(result, ExpandResult)
            assert result.terms == []
            assert result.failure_reason == "all providers failed"

    @pytest.mark.asyncio
    async def test_provider_none_response(self):
        """provider 返回 None → 尝试下一个"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        provider1 = MagicMock()
        provider1.available = True
        provider1.name = "p1"
        provider1.generate = AsyncMock(return_value=None)

        provider2 = MagicMock()
        provider2.available = True
        provider2.name = "p2"
        provider2.generate = AsyncMock(return_value='["fallback_term"]')

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[provider1, provider2],
        ):
            result = await expand_query("search", enriched, config)
            assert isinstance(result, ExpandResult)
            assert result.terms == ["fallback_term"]
            assert result.provider_name == "p2"

    @pytest.mark.asyncio
    async def test_unavailable_provider_skipped(self):
        """不可用 provider 被跳过"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        unavailable = MagicMock()
        unavailable.available = False
        unavailable.name = "unavailable"

        available = MagicMock()
        available.available = True
        available.name = "available"
        available.generate = AsyncMock(return_value='["term"]')

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[unavailable, available],
        ):
            result = await expand_query("search", enriched, config)
            assert isinstance(result, ExpandResult)
            assert result.terms == ["term"]
            unavailable.generate.assert_not_called()


# ── ExpandResult 可观测性测试 ──


class TestExpandResultObservability:
    def setup_method(self):
        _expansion_cache.clear()

    @pytest.mark.asyncio
    async def test_cache_hit_info(self):
        """缓存命中时 cache_hit=True, latency_ms=0"""
        _cache_put("obs_q", None, ["cached"])
        enriched = _make_enriched(query="obs_q")
        config = MagicMock()
        config.expansion_enabled = True

        result = await expand_query("obs_q", enriched, config)
        assert result.cache_hit is True
        assert result.latency_ms == 0
        assert result.provider_name is None
        assert result.terms == ["cached"]

    @pytest.mark.asyncio
    async def test_provider_success_info(self):
        """provider 成功时记录 provider_name 和 parsed_method"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "ollama"
        mock_provider.generate = AsyncMock(
            return_value='["rerank", "embedding"]'
        )

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert result.provider_name == "ollama"
            assert result.parsed_method == "json"
            assert result.raw_count == 2
            assert result.failure_reason is None

    @pytest.mark.asyncio
    async def test_disabled_info(self):
        """禁用时 triggered=False"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = False

        result = await expand_query("test", enriched, config)
        assert result.triggered is False
        assert result.terms == []

    @pytest.mark.asyncio
    async def test_all_failed_info(self):
        """全部失败时 failure_reason 非空"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "failing"
        mock_provider.generate = AsyncMock(side_effect=RuntimeError("down"))

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("test", enriched, config)
            assert result.failure_reason is not None
            assert result.terms == []

    @pytest.mark.asyncio
    async def test_text_fallback_method(self):
        """JSON 解析失败时 parsed_method=text_fallback"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "gemini"
        mock_provider.generate = AsyncMock(
            return_value='Related: "reranker", "cross_encoder", "retrieval"'
        )

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert result.parsed_method == "text_fallback"
            assert len(result.terms) > 0

    @pytest.mark.asyncio
    async def test_latency_positive(self):
        """provider 成功时 latency_ms > 0"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        async def slow_generate(prompt):
            import asyncio
            await asyncio.sleep(0.01)
            return '["term1"]'

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "slow_provider"
        mock_provider.generate = slow_generate

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            assert result.latency_ms > 0
            assert result.terms == ["term1"]


# ── FTS5 辅助 ──


def _create_fts5_db(content_map: dict[str, list[str]]) -> MagicMock:
    """创建含 FTS5 表的内存 SQLite 作为 mock db

    Args:
        content_map: {"chunks_fts": ["content1", ...], "facts_fts": [...]}
    """
    conn = sqlite3.connect(":memory:")
    for table, rows in content_map.items():
        conn.execute(f"CREATE VIRTUAL TABLE {table} USING fts5(content)")
        for row in rows:
            conn.execute(f"INSERT INTO {table}(content) VALUES (?)", (row,))
    conn.commit()
    mock = MagicMock()
    mock.conn = conn
    return mock


# ── _prepare_fts5_term 测试 ──


class TestPrepareFts5Term:
    def test_single_word(self):
        result = _prepare_fts5_term("search")
        assert result == "search"

    def test_phrase_with_space(self):
        result = _prepare_fts5_term("error handling")
        assert result == '"error handling"'

    def test_camel_case(self):
        result = _prepare_fts5_term("ServiceCooldown")
        assert result == '"ServiceCooldown"'

    def test_snake_case(self):
        result = _prepare_fts5_term("safe_db_write")
        assert result == '"safe_db_write"'

    def test_hyphen(self):
        result = _prepare_fts5_term("cross-encoder")
        assert result == '"cross-encoder"'

    def test_empty(self):
        assert _prepare_fts5_term("") == ""
        assert _prepare_fts5_term("  ") == ""


# ── _fts5_match_count 测试 ──


class TestFts5MatchCount:
    def test_match_found(self):
        db = _create_fts5_db({"chunks_fts": ["embedding search", "vector cosine"]})
        assert _fts5_match_count(db.conn, "chunks_fts", "embedding") > 0

    def test_no_match(self):
        db = _create_fts5_db({"chunks_fts": ["embedding search"]})
        assert _fts5_match_count(db.conn, "chunks_fts", "nonexistent") == 0

    def test_exception_returns_zero(self):
        """无效表名 → 返回 0"""
        db = _create_fts5_db({"chunks_fts": []})
        assert _fts5_match_count(db.conn, "no_such_table", "test") == 0


# ── _target_expansion_count 测试 ──


class TestTargetExpansionCount:
    def test_short_query_1_token(self):
        e = _make_enriched(token_query='"search"')
        assert _target_expansion_count(e) == 5

    def test_short_query_2_tokens(self):
        e = _make_enriched(token_query='"error" OR "handling"')
        assert _target_expansion_count(e) == 5

    def test_medium_query_3_tokens(self):
        e = _make_enriched(token_query='"safe" OR "db" OR "write"')
        assert _target_expansion_count(e) == 3

    def test_medium_query_4_tokens(self):
        e = _make_enriched(token_query='"a" OR "b" OR "c" OR "d"')
        assert _target_expansion_count(e) == 3

    def test_long_query_5_tokens(self):
        e = _make_enriched(token_query='"a" OR "b" OR "c" OR "d" OR "e"')
        assert _target_expansion_count(e) == 2


# ── validate_expansion_grounded 测试 ──


class TestValidateExpansionGrounded:
    def test_no_db_passthrough(self):
        """无 DB 时降级 — 所有词通过，weight=1.0"""
        result = validate_expansion_grounded(["term1", "term2"])
        assert len(result) == 2
        assert all(g.weight == 1.0 for g in result)
        assert all(g.match_count == 0 for g in result)

    def test_empty_terms(self):
        assert validate_expansion_grounded([]) == []

    def test_grounding_filters_nonexistent(self):
        """不在索引中的词被过滤"""
        db = _create_fts5_db({"chunks_fts": ["embedding vector search"]})
        result = validate_expansion_grounded(
            ["embedding", "nonexistent_xyz"], db=db,
        )
        assert len(result) == 1
        assert result[0].term == "embedding"
        assert result[0].weight > 0

    def test_grounding_dual_db(self):
        """同时查 corpus 和 cognition"""
        db = _create_fts5_db({"chunks_fts": ["embedding search"]})
        db_cog = _create_fts5_db({"facts_fts": ["rerank quality"]})
        result = validate_expansion_grounded(
            ["embedding", "rerank", "ghost_term"],
            db=db, db_cog=db_cog,
        )
        terms = [g.term for g in result]
        assert "embedding" in terms
        assert "rerank" in terms
        assert "ghost_term" not in terms

    def test_weight_log1p_normalization(self):
        """weight 使用 log1p 归一化"""
        # 插入多条匹配记录
        db = _create_fts5_db({
            "chunks_fts": [f"embedding doc{i}" for i in range(20)],
        })
        result = validate_expansion_grounded(["embedding"], db=db)
        assert len(result) == 1
        # 20 matches → log1p(20)/log1p(50) ≈ 0.78
        assert 0.5 < result[0].weight < 1.0

    def test_sorted_by_weight_desc(self):
        """结果按 weight 降序"""
        db = _create_fts5_db({
            "chunks_fts": [
                "common common common",
                "common another",
                "rare unique term",
            ],
        })
        result = validate_expansion_grounded(["common", "rare"], db=db)
        if len(result) == 2:
            assert result[0].weight >= result[1].weight


# ── validate_cooccurrence 测试 ──


class TestValidateCooccurrence:
    def test_no_db_passthrough(self):
        """无 DB 时跳过验证"""
        terms = [GroundedTerm("a", 1.0, 5), GroundedTerm("b", 0.8, 3)]
        result = validate_cooccurrence(terms, "query")
        assert len(result) == 2

    def test_empty_terms(self):
        result = validate_cooccurrence([], "query", db=MagicMock())
        assert result == []

    def test_cooccurrence_filters(self):
        """只保留与查询词共现的扩展词"""
        db = _create_fts5_db({
            "chunks_fts": [
                "search embedding vector",    # search 与 embedding 共现
                "factory pattern singleton",  # factory 不与 search 共现
            ],
        })
        terms = [
            GroundedTerm("embedding", 0.8, 5),
            GroundedTerm("factory", 0.6, 3),
        ]
        result = validate_cooccurrence(terms, "search", db=db)
        result_terms = [g.term for g in result]
        assert "embedding" in result_terms
        assert "factory" not in result_terms

    def test_short_query_tokens_filtered(self):
        """单字符 token 被过滤（不参与共现）"""
        db = _create_fts5_db({"chunks_fts": ["x embedding"]})
        terms = [GroundedTerm("embedding", 0.8, 5)]
        # 查询只有单字符 "x"（被过滤），所有词保留
        result = validate_cooccurrence(terms, "x", db=db)
        assert len(result) == 1


# ── expand_query + grounding 集成测试 ──


class TestExpandQueryGrounding:
    def setup_method(self):
        _expansion_cache.clear()

    @pytest.mark.asyncio
    async def test_grounding_filters_in_expand(self):
        """expand_query 中 grounding 过滤不在索引中的词"""
        enriched = _make_enriched(query="search")
        config = MagicMock()
        config.expansion_enabled = True

        # LLM 返回 3 个词，其中只有 "embedding" 在索引中且与 "search" 共现
        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "test"
        mock_provider.generate = AsyncMock(
            return_value='["embedding", "nonexistent_abc", "ghost_xyz"]'
        )

        db = _create_fts5_db({"chunks_fts": ["search embedding vector results"]})

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query(
                "search", enriched, config, db=db,
            )
            assert "embedding" in result.terms
            assert "nonexistent_abc" not in result.terms
            assert "ghost_xyz" not in result.terms
            # pre_grounding_terms 记录原始词
            assert "nonexistent_abc" in result.pre_grounding_terms
            assert result.grounded_count <= len(result.pre_grounding_terms)

    @pytest.mark.asyncio
    async def test_no_db_passthrough_in_expand(self):
        """无 db 时 grounding 降级 — 所有词通过"""
        enriched = _make_enriched()
        config = MagicMock()
        config.expansion_enabled = True

        mock_provider = MagicMock()
        mock_provider.available = True
        mock_provider.name = "test"
        mock_provider.generate = AsyncMock(
            return_value='["term_a", "term_b"]'
        )

        with patch(
            "index1.query_expand._create_expansion_providers",
            return_value=[mock_provider],
        ):
            result = await expand_query("search", enriched, config)
            # 无 db → grounding 降级 → 所有词保留
            assert "term_a" in result.terms
            assert "term_b" in result.terms

    @pytest.mark.asyncio
    async def test_cache_hit_also_grounded(self):
        """缓存命中时仍然执行 grounding"""
        # content 同时包含查询词 "search" 和扩展词 "embedding"，确保共现验证通过
        db = _create_fts5_db({"chunks_fts": ["search embedding vector data"]})
        _cache_put("search", None, ["embedding", "ghost_nonexistent"])

        enriched = _make_enriched(query="search")
        config = MagicMock()
        config.expansion_enabled = True

        result = await expand_query("search", enriched, config, db=db)
        assert result.cache_hit is True
        # embedding 在索引中且与 search 共现，ghost 不在
        assert "embedding" in result.terms
        assert "ghost_nonexistent" not in result.terms
        assert "ghost_nonexistent" in result.pre_grounding_terms
