"""邀请卡静态回归网:不变量"未展示 ⇒ 零占位、零拦截"。 背景(2026-09-01 bug):点 X 写入 permanent 后刷新,startInviteTimer 在 storage 守卫早退 ⇒ showInvite 永不执行 ⇒ invitePanel 引用恒为 null ⇒ hideInvite 提前返回 ⇒ .invite-panel 停留"display:block + opacity:0"的 隐形占位态,.invite-container(z-index:99998、无 pointer-events:none) 拦截页面右下角所有点击。修复 = 出厂即折叠(A)+ 容器点击穿透(C)。 """ import re from pathlib import Path STATIC = Path(__file__).resolve().parent.parent / "static" def _read(rel): return (STATIC / rel).read_text(encoding="utf-8") def _extract_function(src, name): """花括号计数提取函数源码(与 test_theme_static.py 同款)。""" start = src.index("function " + name + "(") i = src.index("{", start) depth = 0 for j in range(i, len(src)): if src[j] == "{": depth += 1 elif src[j] == "}": depth -= 1 if depth == 0: return src[start:j + 1] raise AssertionError("unbalanced braces in " + name) def test_invite_panel_ships_hidden(): """A:面板出厂即带 hide —— 任何绕过 showInvite 的路径都零占位。""" src = _read("widget.js") assert '
' in src, \ "invite 模板必须出厂带 hide(未展示 ⇒ 无布局占位)" def test_invite_container_click_through(): """C:容器 pointer-events:none —— 卡片未展示时整棵子树点击穿透。""" # 行首引号锚定原始定义行——位置联动规则(.widget-container.pos-* .invite-container) # 同样匹配宽松正则,须排除(2026-09-30 邀请卡联动修复时的提取器缺陷) m = re.search(r'"\.invite-container \{[^}]*\}', _read("widget.js")) assert m, "widget.js 缺 .invite-container 规则" assert "pointer-events:none" in m.group(0), \ ".invite-container 必须 pointer-events:none(防隐形拦截点击)" def test_show_invite_reflow_between_unhide_and_show(): """入场动画:remove(hide) 与 add(show) 之间必须强制回流。 出厂带 hide 后,若 remove 与 add 同帧执行,display:none→block 与 opacity 变化合并进同一次样式重算,0.3s 淡入退化为瞬现。 """ fn = _extract_function(_read("widget.js"), "showInvite") for token in ('remove("hide")', "offsetWidth", 'add("show")'): assert token in fn, f"showInvite 缺 {token}" assert fn.index('remove("hide")') < fn.index("offsetWidth") < fn.index('add("show")'), \ "showInvite 须 remove(hide) → offsetWidth 回流 → add(show) 顺序执行" def test_autoinvite_specialcase_removed_and_guard_kept(): """A 落地后 !autoInvite 预折叠特判删除;startInviteTimer 的配置守卫保留。""" src = _read("widget.js") assert "disabledInvitePanel" not in src, \ "!autoInvite 预折叠特判应删除(出厂 hide 统一覆盖该不变量)" fn = _extract_function(src, "startInviteTimer") assert "if (!autoInvite) return;" in fn, \ "startInviteTimer 必须保留 data-auto-invite=false 永不自动弹的守卫" def test_gdpr_privacy_notice_present(): """GDPR 三入口结构锁(Task 9 质量审查): 邀请卡/聊天面板/独立页的 privacy 提示是纯模板字符串,无此断言则重构时会无声丢失。 等价迁移(Task 10): 隐私链接原锚 widget.js/chat.html 硬编码 hesupipe.com URL——品牌值已迁 seed_profiles/*/tenant.json 的 brand.privacy_url,经 /api/public/brand 下发后由 JS 填 href(为空隐藏)。 断言语义等价: 仍锁"三入口结构 + 隐私链接出自 brand.privacy_url"机制, 值锚定改由 test_widget_namespacing.test_brand_values_live_in_seed_profiles 承担(jiancai privacy_url = hesupipe.com/privacy-policy)。""" wjs = _read("widget.js") assert "invite-privacy" in wjs and "chat-footer" in wjs assert "privacy_url" in wjs # 挂件隐私链接读 brand(Task 10 迁移点) assert 'target="_blank" rel="noopener"' in wjs chat = _read("chat.html") assert "privacy policy" in chat # 三入口文案仍在出厂模板 cjs = _read("js/chat.js") assert "privacy_url" in cjs # 独立页 href 由 brand 填(原锚 chat.html 硬编码 URL) def test_position_class_panel_alignment(): """位置联动(2026-09-30 线上bug): 面板对齐必须随 data-position 翻转。 根因: .chat-panel 硬编码 right:0(为 bottom-right 设计), positionStyles 只应用到按钮容器——bottom-left 时 400px 面板右缘贴 196px 容器右缘, 向左溢出屏幕 184px(线上实测 x=-183.78); top-* 模式面板 bottom:80px 同理向上溢出。修法: 容器挂 pos- class + CSS 联动规则。""" src = _read("widget.js") # 1) 无效 position 归一化(防 "pos-垃圾" class 与查表错位) assert "if (!positionStyles[position])" in src, "position 需归一化为合法值" # 2) 容器挂载位置 class assert '"widget-container pos-" + position' in src, "container 需挂 pos- class" # 3) CSS 联动规则: 左侧模式面板左对齐, 顶部模式面板从顶部向下弹 # ⚠️ chat-panel 与 invite-container(邀请卡)是同款 absolute+right:0 定位, # 必须一起联动(2026-09-30 二次线上反馈: 邀请卡漏修同样溢出) for rule_sel, rule_body in [ (".widget-container.pos-bottom-left .chat-panel,", None), (".widget-container.pos-bottom-left .invite-container { left:0; right:auto; }", None), (".widget-container.pos-top-right .chat-panel,", None), (".widget-container.pos-top-right .invite-container { top:0; bottom:auto; }", None), (".widget-container.pos-top-left .chat-panel,", None), (".widget-container.pos-top-left .invite-container { left:0; right:auto; top:0; bottom:auto; }", None), ]: assert rule_sel in src, f"缺联动规则: {rule_sel}" # 4) pos 规则须在 mobile-host 规则之后注入(同特异性靠顺序决胜, 不得被 bottom:84px 覆盖打断) mobile_rule = ".widget-container.mobile-host .chat-panel { bottom:84px; }" pos_rule = ".widget-container.pos-bottom-left .chat-panel" assert src.index(mobile_rule) < src.index(pos_rule), "pos 规则须在 mobile 规则之后"