#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：test_smart_locate_semantic.py
模块描述：智能定位语义化生成规则纯函数单测（阶段6语义化用例生成器）

覆盖：
- _extract_semantic_text：语义键文本提取（【】优先 / 剔除动作动词 / 截断动态值）
- _extract_contains：步骤名"xxx：值"锚点值（CTX 化）
- _extract_nth：第 N 个提取
- _ctxify / _ctf_param：动态数据 CTX 化
- _infer_element_type：semantic.type 推断
- _build_candidate_semantic：候选 semantic 构造（语义解析优先路径）
- _infer_semantic_from_element：反向生成（keyword 定位成功后）

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

from typing import Any, Dict, Optional

import pytest

from app.services.smart_locate_service import SmartLocateService


# ==================== Fixtures ====================

@pytest.fixture
def svc():
    """智能定位服务实例（不启动浏览器）"""
    return SmartLocateService()


class _FakeAttr:
    """模拟 Playwright 元素的 tag/class/role/type 读取"""

    def __init__(self, attrs: Dict[str, str], container: Optional[Dict[str, Any]] = None):
        self._attrs = attrs
        self._container = container or {}

    def get_attribute(self, name: str):
        return self._attrs.get(name)

    def evaluate(self, js: str):
        if 'el.tagName' in js:
            return self._attrs.get('tagName')
        if 'parentElement' in js:
            return self._container
        return None


def _with_scope(svc: SmartLocateService, scope_id: str, scope_name: str) -> None:
    """缓存语义化开关并注入假页面识别服务（_get_page_scope 走 _page_url_service）"""
    svc._semantic_enabled()  # 缓存 _semantic_enabled_flag，避免再次懒加载覆盖注入

    class _FakeUrlSvc:
        def recognize_current_page(self, page):
            return {"id": scope_id, "name": scope_name}

    svc._page_url_service = _FakeUrlSvc()


def _mk_step(name: str, action: str = "click", params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    return {"order": 1, "name": name, "action": action, "params": params or {}}


# ==================== _extract_semantic_text ====================

class TestExtractSemanticText:
    def test_bracket_preferred(self, svc):
        """【】显式语义键优先"""
        assert svc._extract_semantic_text("点击【确定创建】按钮") == "确定创建"

    def test_strip_leading_verb(self, svc):
        assert svc._extract_semantic_text("点击确定创建") == "确定创建"
        assert svc._extract_semantic_text("输入会议名称") == "会议名称"
        assert svc._extract_semantic_text("打开会议预约") == "会议预约"

    def test_switch_verb_ordering(self, svc):
        """切换到优先于切换，避免剥成'到xxx'"""
        assert svc._extract_semantic_text("切换到会议预约") == "会议预约"

    def test_truncate_dynamic_value(self, svc):
        """'xxx：值' 动态值截断，仅保留锚点部分（剔除动作动词）"""
        assert svc._extract_semantic_text("会议室选择：北京展厅会议室") == "会议室选择"
        assert svc._extract_semantic_text("输入会议名称：自动会议X") == "会议名称"

    def test_empty(self, svc):
        assert svc._extract_semantic_text("") is None
        assert svc._extract_semantic_text("点击") is None


# ==================== _extract_contains ====================

class TestExtractContains:
    def test_basic(self, svc):
        assert svc._extract_contains("会议室选择：北京展厅会议室") == "{__CTX:param1__}"
        assert svc.collected_parameters == {"param1": "北京展厅会议室"}

    def test_no_colon(self, svc):
        assert svc._extract_contains("点击确定创建") is None


# ==================== _extract_nth ====================

class TestExtractNth:
    def test_arabic(self, svc):
        assert svc._extract_nth("点击第 2 个选项") == 2
        assert svc._extract_nth("选择第3个") == 3

    def test_cjk(self, svc):
        assert svc._extract_nth("点击第 三 个") == 3

    def test_none(self, svc):
        assert svc._extract_nth("点击确定创建") is None


# ==================== _ctxify / _ctf_param ====================

class TestCtxify:
    def test_same_value_reuses_key(self, svc):
        svc._ctxify("会议A")
        svc._ctxify("会议A")
        assert len(svc.collected_parameters) == 1
        assert svc.collected_parameters == {"param1": "会议A"}

    def test_ctf_param_dynamic(self, svc):
        params = {"value": "自动会议X"}
        svc._ctf_param(params)
        assert params["value"] == "{__CTX:param1__}"
        assert svc.collected_parameters == {"param1": "自动会议X"}

    def test_ctf_param_static_short(self, svc):
        """短值（<4）视为静态值保留原样"""
        params = {"value": "是"}
        svc._ctf_param(params)
        assert params["value"] == "是"

    def test_ctf_param_static_long_chinese(self, svc):
        """长度 ≥6 的中文视为动态"""
        params = {"value": "北京展厅会议室"}
        svc._ctf_param(params)
        assert params["value"] == "{__CTX:param1__}"


# ==================== _infer_element_type ====================

class TestInferElementType:
    def test_fill_input(self, svc):
        assert svc._infer_element_type("fill", None) == "input"

    def test_fill_textarea(self, svc):
        el = _FakeAttr({"tagName": "TEXTAREA"})
        assert svc._infer_element_type("fill", el) == "textarea"

    def test_button(self, svc):
        el = _FakeAttr({"tagName": "BUTTON"})
        assert svc._infer_element_type("click", el) == "button"

    def test_checkbox_in_table(self, svc):
        el = _FakeAttr({"tagName": "INPUT", "class": "el-checkbox__original", "type": "checkbox"},
                       container={"type": "table", "selector": ".el-table"})
        assert svc._infer_element_type("click", el) == "row_checkbox"

    def test_tab(self, svc):
        el = _FakeAttr({"tagName": "DIV", "class": "el-tabs__item"}, )
        assert svc._infer_element_type("click", el) == "tab"

    def test_click_no_element_defaults_button(self, svc):
        assert svc._infer_element_type("click", None) == "button"


# ==================== _build_candidate_semantic ====================

class TestBuildCandidateSemantic:
    def test_static_click(self, svc):
        semantic = svc._build_candidate_semantic(_mk_step("点击确定创建"), "create_meeting")
        assert semantic == {"type": "button", "text": "确定创建", "scope": "create_meeting"}

    def test_static_fill(self, svc):
        semantic = svc._build_candidate_semantic(_mk_step("输入会议名称", "fill"), None)
        assert semantic == {"type": "input", "text": "会议名称"}

    def test_nth_extracted(self, svc):
        semantic = svc._build_candidate_semantic(_mk_step("点击第 2 个选项"), "create_meeting")
        assert semantic["nth"] == 2

    def test_empty_returns_none(self, svc):
        assert svc._build_candidate_semantic(_mk_step("点击"), None) is None


# ==================== _infer_semantic_from_element ====================

class TestInferSemanticFromElement:
    def test_reverse_generation_dynamic_click(self, svc):
        """反向生成：表格行复选框点击（checkbox + table 容器 → row_checkbox）"""
        _with_scope(svc, "meeting_list", "会议列表")
        el = _FakeAttr({"tagName": "INPUT", "class": "el-checkbox__original", "type": "checkbox"},
                       container={"type": "table", "selector": ".el-table"})

        semantic = svc._infer_semantic_from_element(
            _mk_step("会议室选择：北京展厅会议室"), el, object()
        )
        assert semantic["type"] == "row_checkbox"
        assert semantic["scope"] == "meeting_list"
        assert semantic["contains"] == "{__CTX:param1__}"
        assert semantic["container"] == {"type": "table", "selector": ".el-table"}
        assert svc.collected_parameters.get("param1") == "北京展厅会议室"

    def test_reverse_generation_row(self, svc):
        """反向生成：表格行本身（el-table__row → row 类型）"""
        _with_scope(svc, "meeting_list", "会议列表")
        el = _FakeAttr({"tagName": "LI", "class": "el-table__row"},
                       container={"type": "table", "selector": ".el-table"})
        semantic = svc._infer_semantic_from_element(
            _mk_step("查看详情：会议A"), el, object()
        )
        assert semantic["type"] == "row"
        assert semantic["contains"] == "{__CTX:param1__}"

    def test_reverse_generation_fill(self, svc):
        """反向生成：fill 步骤不生成 contains（值走 _ctf_param）"""
        _with_scope(svc, "create_meeting", "创建会议")
        el = _FakeAttr({"tagName": "INPUT"})

        semantic = svc._infer_semantic_from_element(
            _mk_step("输入会议名称：自动会议X", "fill"), el, object()
        )
        assert semantic["type"] == "input"
        assert "contains" not in semantic
        assert semantic["scope"] == "create_meeting"

    def test_reverse_generation_scope_none(self, svc):
        """scope 识别不到时 semantic.scope 留空（执行期降级安全）"""
        svc._semantic_enabled()

        class _NoScopeSvc:
            def recognize_current_page(self, page):
                return None

        svc._page_url_service = _NoScopeSvc()
        el = _FakeAttr({"tagName": "BUTTON"})
        semantic = svc._infer_semantic_from_element(
            _mk_step("点击确定创建"), el, None
        )
        assert semantic["type"] == "button"
        assert "scope" not in semantic


# ==================== _extract_pattern_value / _ctxify_if_dynamic ====================

class TestExtractPatternValue:
    def test_colon_value(self, svc):
        assert svc._extract_pattern_value("时间切换为：预定会议") == "预定会议"
        assert svc._extract_pattern_value("会议室选择：北京展厅会议室") == "北京展厅会议室"

    def test_no_colon(self, svc):
        assert svc._extract_pattern_value("点击确定创建") is None
        assert svc._extract_pattern_value("") is None


class TestCtxifyIfDynamic:
    def test_static_short_chinese_kept(self, svc):
        """静态短中文（如 tab 名 预定会议）不 CTX 化，避免参数污染"""
        assert svc._ctxify_if_dynamic("预定会议") == "预定会议"

    def test_dynamic_ctxfied(self, svc):
        """明显动态值（长中文/含字母数字）CTX 化并写入参数初值"""
        out = svc._ctxify_if_dynamic("北京展厅会议室")
        assert out == "{__CTX:param1__}"
        assert svc.collected_parameters["param1"] == "北京展厅会议室"

    def test_alnum_ctxfied(self, svc):
        assert svc._ctxify_if_dynamic("admin@xty12") == "{__CTX:param1__}"


# ==================== 模式步骤 semantic 生成 + 校验降级 ====================

class _FakeResolver:
    """按 semantic.type 返回不同选择器链的假解析器"""

    def __init__(self, chains: Dict[str, list]):
        self._chains = chains

    def resolve_step(self, page, step, scope, ctx):
        t = step["semantic"].get("type", "button")
        return list(self._chains.get(t, []))


class _FakePage:
    """query_selector 按命中集返回非空，否则 None"""

    def __init__(self, hits):
        self._hits = set(hits)

    def query_selector(self, sel):
        return object() if sel in self._hits else None


def _pattern_step(name: str) -> Dict[str, Any]:
    return {"order": 1, "name": name, "action": "click", "params": {}}


class TestInferSemanticFromPattern:
    def test_tab_value_as_text(self, svc):
        """tab 模式：text=锚点值（非标签），静态值不 CTX"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver(
            {"tab": ['[role="tab"]:has-text("预定会议")']})
        sem = svc._infer_semantic_from_pattern(
            _pattern_step("时间切换为：预定会议"),
            {"type": "tab_item"}, _FakePage(['[role="tab"]:has-text("预定会议")']))
        assert sem["type"] == "tab"
        assert sem["text"] == "预定会议"
        assert sem["contains"] == "预定会议"
        assert sem["scope"] == "create_meeting"

    def test_row_checkbox_downgrade_to_row(self, svc):
        """row_checkbox 校验失败（普通 <tr> 表格行）→ 降级 row（tr:has-text 命中）"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver({
            "row_checkbox": ['.el-table__row:has-text("北京展厅会议室") .el-checkbox'],
            "row": ['tr:has-text("北京展厅会议室")'],
        })
        sem = svc._infer_semantic_from_pattern(
            _pattern_step("会议室选择：北京展厅会议室"),
            {"type": "list_checkbox"},
            _FakePage(['tr:has-text("北京展厅会议室")']))
        assert sem["type"] == "row"
        assert sem["text"] == "{__CTX:param1__}"

    def test_both_fail_returns_none(self, svc):
        """row_checkbox 与降级 row 均无法命中 → 放弃生成 semantic（回退 params.selector）"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver({
            "row_checkbox": ['.el-table__row:has-text("房间A") .el-checkbox'],
            "row": ['tr:has-text("房间A")'],
        })
        assert svc._infer_semantic_from_pattern(
            _pattern_step("会议室选择：房间A"),
            {"type": "list_checkbox"}, _FakePage([])) is None

    def test_validate_resolves_any_chain_hit(self, svc):
        """校验接受链中任一条命中（执行期 _do_click 遍历全链）"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver({
            "row_checkbox": [
                '.el-table__row:has-text("北京展厅会议室") .el-checkbox',
                'tr:has-text("北京展厅会议室")',
            ]})
        # 链首未命中但第二条 tr:has-text 命中 → 校验通过，无需降级
        sem = svc._infer_semantic_from_pattern(
            _pattern_step("会议室选择：北京展厅会议室"),
            {"type": "list_checkbox"},
            _FakePage(['tr:has-text("北京展厅会议室")']))
        assert sem["type"] == "row_checkbox"


# ==================== _is_login_step（auto_login 登录步骤跳过） ====================

class TestIsLoginStep:
    def test_navigate_http(self, svc):
        """navigate 到 http 地址 → 登录步骤"""
        step = {"name": "访问系统登陆页面", "action": "navigate",
                "params": {"selector": "https://192.168.5.44"}}
        assert svc._is_login_step(step)

    def test_fill_username(self, svc):
        assert svc._is_login_step(
            {"name": "输入用户名：admin@xty", "action": "fill", "params": {}})

    def test_fill_password(self, svc):
        assert svc._is_login_step(
            {"name": "输入密码", "action": "fill", "params": {}})

    def test_captcha(self, svc):
        assert svc._is_login_step(
            {"name": "输入验证码：csba", "action": "fill", "params": {}})

    def test_click_login(self, svc):
        assert svc._is_login_step(
            {"name": "点击登录", "action": "click", "params": {}})

    def test_nav_menu_not_login(self, svc):
        """功能中心/首页等菜单导航不算登录步骤"""
        assert not svc._is_login_step(
            {"name": "点击【功能中心】展开", "action": "click", "params": {}})
        assert not svc._is_login_step(
            {"name": "进入首页仪表盘", "action": "click", "params": {}})

    def test_normal_form_step_not_login(self, svc):
        assert not svc._is_login_step(
            {"name": "时间切换为：预定会议", "action": "click", "params": {}})
        assert not svc._is_login_step(
            {"name": "填写会议名称", "action": "fill", "params": {}})


# ==================== _try_attach_semantic（已有选择器步骤语义化升级） ====================

class TestTryAttachSemantic:
    def _mk_result(self):
        sel = '.el-tabs__item:has-text("预定会议")'
        return {
            'success': True,
            'params': {'selector': sel},
            'selectors': {
                'primary': sel,
                'candidates': [{'type': 'css', 'value': sel, 'confidence': 0.95, 'priority': 1}],
            },
        }

    def test_pattern_tab_attaches(self, svc):
        """tab 模式步骤：已有选择器也能生成 semantic，解析链前置 + 原 selector 兜底"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver(
            {"tab": ['[role="tab"]:has-text("预定会议")', '.time_btn .btns']})
        result = self._mk_result()
        step = {"order": 12, "name": "时间切换为：预定会议", "action": "click",
                "params": {"selector": '.el-tabs__item:has-text("预定会议")'}}
        page = _FakePage(['.time_btn .btns', '[role="tab"]:has-text("预定会议")'])

        svc._try_attach_semantic(result, step, page)

        assert result['semantic']['type'] == 'tab'
        assert result['semantic']['text'] == '预定会议'
        assert result['semantic']['scope'] == 'create_meeting'
        # 解析链前置，原 selector 保留兜底
        assert result['params']['selector'] == '[role="tab"]:has-text("预定会议")'
        cands = [c['value'] for c in result['selectors']['candidates']]
        assert cands[0] == '[role="tab"]:has-text("预定会议")'
        assert '.el-tabs__item:has-text("预定会议")' in cands

    def test_static_button_attaches(self, svc):
        """非模式静态步骤：走语义解析优先路径附加 semantic"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver(
            {"button": ['button:has-text("确定创建")', 'text=确定创建']})
        result = {
            'success': True,
            'params': {'selector': 'button:has-text("确定创建")'},
            'selectors': {'primary': 'button:has-text("确定创建")',
                          'candidates': [{'type': 'css', 'value': 'button:has-text("确定创建")',
                                          'confidence': 0.95, 'priority': 1}]},
        }
        step = {"order": 20, "name": "点击确定创建", "action": "click",
                "params": {"selector": 'button:has-text("确定创建")'}}
        page = _FakePage(['button:has-text("确定创建")', 'text=确定创建'])

        svc._try_attach_semantic(result, step, page)

        assert result['semantic']['type'] == 'button'
        assert result['semantic']['text'] == '确定创建'
        assert result['params']['selector'] == 'button:has-text("确定创建")'

    def test_no_resolution_no_attach(self, svc):
        """解析链无命中 → 不附加 semantic，原 result 不变"""
        _with_scope(svc, "create_meeting", "创建会议")
        svc._semantic_resolver = _FakeResolver({"tab": []})
        result = self._mk_result()
        step = {"order": 12, "name": "时间切换为：预定会议", "action": "click",
                "params": {"selector": '.el-tabs__item:has-text("预定会议")'}}
        svc._try_attach_semantic(result, step, _FakePage([]))

        assert 'semantic' not in result
        assert result['params']['selector'] == '.el-tabs__item:has-text("预定会议")'
