#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：test_semantic_resolver.py
模块描述：语义解析器单元测试（阶段 3）

覆盖：
- 强结构类型模式选择器（tab / row_checkbox / checkbox / row）
- DOM 候选过滤（contains / container / nth）
- 歧义仲裁（唯一候选短路 / 置信度差距短路 / Claude 仲裁）
- resolve_step 主流程（semantic 优先 / scope 校验 / legacy keyword 路径开关）
- 映射表 v2 查找 hook（lookup_by_key 未实现时返回 None）
- extract_save_as（input_value / inner_text 写上下文）

作者：czj
创建日期：2026-08-18
"""

import pytest
from unittest.mock import MagicMock

from app.config import settings
from app.executors.ui_context import ExecutionContext
from app.services.semantic_resolver import SemanticResolver, get_semantic_resolver


# ==================== 模式选择器 ====================

class TestPatternSelectors:
    def test_tab_pattern(self):
        r = SemanticResolver()
        chain = r._pattern_selectors("tab", "预定会议", None)
        assert chain == [
            '.el-tabs__item:has-text("预定会议")',
            '[role="tab"]:has-text("预定会议")',
            '.tab-item:has-text("预定会议")',
            'li[role="tab"]:has-text("预定会议")',
        ]

    def test_row_checkbox_uses_contains_as_anchor(self):
        """row_checkbox 锚点优先用 contains（唯一值），不用列标签"""
        r = SemanticResolver()
        chain = r._pattern_selectors("row_checkbox", "参会人", "admin@xty12")
        assert chain[0] == '.el-table__row:has-text("admin@xty12") .el-checkbox'
        assert ".el-table__row" in chain[0]

    def test_checkbox_pattern(self):
        r = SemanticResolver()
        chain = r._pattern_selectors("checkbox", "通知", None)
        assert chain[0] == '.el-checkbox:has-text("通知")'

    def test_row_pattern(self):
        r = SemanticResolver()
        chain = r._pattern_selectors("row", "北京展厅会议室", None)
        assert chain[0] == '.el-table__row:has-text("北京展厅会议室")'

    def test_empty_anchor_returns_none(self):
        r = SemanticResolver()
        assert r._pattern_selectors("tab", "", None) is None


# ==================== 候选过滤 ====================

class TestFilterSelectors:
    def _r(self):
        return SemanticResolver()

    def test_plain_pass_through(self):
        sels = [{"value": ".a"}, {"value": ".b"}]
        chain = self._r()._filter_selectors(sels, None, {})
        assert chain == [".a", ".b"]

    def test_contains_filters_low_priority(self):
        """contains 次级过滤：候选多于 1 且选择器未提及 contains 时降权"""
        sels = [{"value": ".el-checkbox"}, {"value": '.el-checkbox:has-text("admin@xty12")'}]
        chain = self._r()._filter_selectors(sels, "admin@xty12", {})
        assert ".el-checkbox:has-text(" in chain[0]

    def test_container_composes_selector(self):
        sels = [{"value": ".edit-btn"}]
        chain = self._r()._filter_selectors(sels, None, {"container": {"selector": ".room"}})
        assert chain == [".room .edit-btn"]

    def test_nth_applies_playwright_syntax(self):
        sels = [{"value": ".el-radio"}]
        chain = self._r()._filter_selectors(sels, None, {"nth": 3})
        assert chain == [".el-radio >> nth=2"]

    def test_nth_one_is_identity(self):
        sels = [{"value": ".el-radio"}]
        chain = self._r()._filter_selectors(sels, None, {"nth": 1})
        assert chain == [".el-radio"]

    def test_dedup_and_cap(self):
        sels = [{"value": ".a"}, {"value": ".a"}, {"value": ".b"}, {"value": ".c"},
                {"value": ".d"}, {"value": ".e"}]
        chain = self._r()._filter_selectors(sels, None, {})
        assert chain == [".a", ".b", ".c", ".d"]
        assert len(chain) == 4


# ==================== 歧义仲裁 ====================

class TestArbitrate:
    def _r(self):
        r = SemanticResolver()
        # 默认 Claude 关闭，走规则路径
        r._claude = MagicMock(enabled=False)
        return r

    def test_unique_candidate_short_circuit(self):
        r = self._r()
        chain = r._arbitrate(None, "确定创建", "click", ["el"], [{"confidence": 0.9}], [".a"])
        assert chain == [".a"]
        r._claude.rank_candidates.assert_not_called()

    def test_confidence_gap_short_circuits_claude(self):
        r = self._r()
        chain = r._arbitrate(
            None, "会议名称", "fill",
            ["e1", "e2"],
            [{"confidence": 0.95}, {"confidence": 0.30}],
            [".a", ".b"],
        )
        assert chain == [".a"]
        r._claude.rank_candidates.assert_not_called()

    def test_ambiguous_calls_claude(self):
        r = self._r()
        r._claude.enabled = True
        r._claude.rank_candidates.return_value = (1, 0.8, "文本唯一", True)
        # get_candidate_details 会被调用，mock 掉
        import app.services.semantic_resolver as sr
        sr.get_candidate_details = MagicMock(return_value=[{"index": 0}, {"index": 1}])
        chain = r._arbitrate(
            MagicMock(), "保存", "click",
            ["e1", "e2"],
            [{"confidence": 0.6}, {"confidence": 0.55}],
            [".save", ".other"],
        )
        assert chain == [".other"]  # Claude 选 index=1

    def test_claude_failure_falls_back(self):
        r = self._r()
        r._claude.enabled = True
        r._claude.rank_candidates.side_effect = RuntimeError("boom")
        import app.services.semantic_resolver as sr
        sr.get_candidate_details = MagicMock(return_value=[])
        chain = r._arbitrate(
            MagicMock(), "保存", "click",
            ["e1", "e2"],
            [{"confidence": 0.6}, {"confidence": 0.55}],
            [".save", ".other"],
        )
        assert chain == [".save"]


# ==================== resolve_step 主流程 ====================

class TestResolveStep:
    def test_semantic_tab_mapping_v2_priority(self):
        """映射表 v2 有精确键时优先于模式选择器（阶段 5 契约）"""
        r = get_semantic_resolver()
        step = {
            "name": "时间切换为：预定会议",
            "action": "click",
            "semantic": {"type": "tab", "text": "预定会议", "scope": "create_meeting"},
        }
        ctx = ExecutionContext({})
        chain = r.resolve_step(MagicMock(), step, "create_meeting", ctx)
        assert chain and ".time_btn" in chain[0]

    def test_semantic_tab_returns_pattern_chain(self):
        """映射表 v2 无该键时，结构化语义目标回退到模式选择器链"""
        r = get_semantic_resolver()
        step = {
            "name": "时间切换为：我的日程",
            "action": "click",
            "semantic": {"type": "tab", "text": "我的日程", "scope": "create_meeting"},
        }
        ctx = ExecutionContext({})
        chain = r.resolve_step(MagicMock(), step, "create_meeting", ctx)
        assert chain and ".el-tabs__item:has-text(" in chain[0]

    def test_scope_mismatch_returns_none(self):
        r = get_semantic_resolver()
        step = {
            "semantic": {"type": "tab", "text": "预定会议", "scope": "create_meeting"},
        }
        chain = r.resolve_step(MagicMock(), step, "meeting_detail", ExecutionContext({}))
        assert chain is None

    def test_legacy_step_gated_by_flag(self):
        """USE_KEYWORD_MATCHER=false 时 legacy 步骤不解析（零行为变化）"""
        r = get_semantic_resolver()
        old = settings.USE_KEYWORD_MATCHER
        settings.USE_KEYWORD_MATCHER = False
        try:
            step = {"name": "点击登录按钮", "action": "click"}
            chain = r.resolve_step(MagicMock(), step, None, ExecutionContext({}))
            assert chain is None
        finally:
            settings.USE_KEYWORD_MATCHER = old

    def test_mapping_v2_hook_used(self):
        """lookup_by_key 已实现：映射表 v2 精确键直接命中（阶段 5 生效）"""
        r = get_semantic_resolver()
        chain = r._mapping_v2_lookup({"scope": "create_meeting"}, "预定会议", "tab")
        assert chain and ".time_btn" in chain[0]

    def test_button_semantic_with_rule_candidates(self):
        """button 语义目标走 keyword_matcher 规则候选 + 仲裁"""
        import app.services.semantic_resolver as sr
        original = sr.match_element_by_keywords
        sr.match_element_by_keywords = MagicMock(
            return_value=(
                ["el1"],
                [{"type": "css", "value": 'button:has-text("确定创建")',
                  "confidence": 0.9, "priority": 1}],
            )
        )
        try:
            r = get_semantic_resolver()
            step = {"semantic": {"type": "button", "text": "确定创建"}}
            chain = r.resolve_step(MagicMock(), step, None, ExecutionContext({}))
            assert chain == ['button:has-text("确定创建")']
        finally:
            sr.match_element_by_keywords = original


# ==================== save_as 提取 ====================

class TestExtractSaveAs:
    def _fake_page(self):
        """鸭子类型 page：locator.first 返回带 input_value/inner_text 的假元素"""
        page = MagicMock()
        el = MagicMock()
        el.input_value.return_value = "自动化-ABC123"
        el.inner_text.return_value = "预定会议"
        loc = MagicMock()
        loc.count.return_value = 1
        loc.first = el
        page.locator.return_value = loc
        return page, el, loc

    def test_input_save_as(self):
        r = get_semantic_resolver()
        page, el, _ = self._fake_page()
        ctx = ExecutionContext({})
        r.extract_save_as(page, {"type": "input", "text": "会议名称", "save_as": "meetingName"},
                          [".name-input"], ctx)
        assert ctx.get("meetingName") == "自动化-ABC123"

    def test_text_save_as_uses_inner_text(self):
        r = get_semantic_resolver()
        page, el, _ = self._fake_page()
        ctx = ExecutionContext({})
        r.extract_save_as(page, {"type": "text", "text": "时间", "save_as": "timeTab"},
                          [".el-tabs__item.is-active"], ctx)
        assert ctx.get("timeTab") == "预定会议"

    def test_no_save_as_noop(self):
        r = get_semantic_resolver()
        page, el, _ = self._fake_page()
        ctx = ExecutionContext({})
        r.extract_save_as(page, {"type": "button", "text": "确定创建"}, [".btn"], ctx)
        # ExecutionContext 内置 caseName/caseId 默认值，断言未新增键
        assert ctx.get("meetingName") is None
        assert set(ctx.all().keys()) == {"caseName", "caseId"}

    def test_missing_element_noop(self):
        r = get_semantic_resolver()
        page = MagicMock()
        loc = MagicMock()
        loc.count.return_value = 0
        page.locator.return_value = loc
        ctx = ExecutionContext({})
        r.extract_save_as(page, {"type": "input", "save_as": "x"}, [".nope"], ctx)
        assert ctx.get("x") is None
