"""动态主题色静态回归网(spec §8.1、§12.7)。""" import re from pathlib import Path STATIC = Path(__file__).resolve().parent.parent / "static" SLOTS = [ "--w-primary", "--w-primary-rgb", "--w-gradient-end", "--w-deep-text", "--w-caption-text", "--w-soft-bg", "--w-border-soft", ] # 现状全部橙色系字面量(spec §4 表格逆向) ORANGE = re.compile( r"#(f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706" r"|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7" r"|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3)\b", re.IGNORECASE, ) STRAY_RGBA = re.compile(r"rgba?\(\s*(240,\s*138,\s*49|196,\s*91,\s*25)") def _read(rel): return (STATIC / rel).read_text(encoding="utf-8") def test_theme_js_exists_and_exports(): src = _read("js/theme.js") for token in ("derivePalette", "DEFAULT_COLOR", "DEFAULT_PALETTE"): assert token in src, f"theme.js 缺少 {token}" def _extract_function(src, name): """花括号计数提取函数源码(要求函数体内字符串字面量不含大括号)。""" 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) SYNCED_FUNCTIONS = ["clampNum", "hexToRgb", "rgbToHsl", "toHex2", "hslToHex", "derivePalette"] def test_widget_and_theme_derive_identical(): # 注:归一化会抹掉字符串字面量内的空白——已接受的盲区(唯一含空格字面量是 warn 消息) norm = lambda s: re.sub(r"\s+", "", s) for name in SYNCED_FUNCTIONS: a = _extract_function(_read("js/theme.js"), name) b = _extract_function(_read("widget.js"), name) na, nb = norm(a), norm(b) if na != nb: limit = min(len(na), len(nb)) i = next((k for k in range(limit) if na[k] != nb[k]), limit) assert False, ( f"两份 {name} 漂移,首个差异位置 {i}: " f"theme.js ...{na[max(0, i - 20):i + 20]!r} vs widget.js ...{nb[max(0, i - 20):i + 20]!r}" ) def test_widget_and_theme_defaults_identical(): norm = lambda s: re.sub(r"\s+", "", s) for rel in ("js/theme.js", "widget.js"): m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read(rel), re.S) assert m, f"{rel} 缺 DEFAULT_PALETTE 块" a = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read("js/theme.js"), re.S).group(0) b = re.search(r"var DEFAULT_PALETTE = \{.*?\};", _read("widget.js"), re.S).group(0) assert norm(a) == norm(b), "两份 DEFAULT_PALETTE 漂移" ca = re.search(r'var DEFAULT_COLOR = "[^"]*"', _read("js/theme.js")).group(0) cb = re.search(r'var DEFAULT_COLOR = "[^"]*"', _read("widget.js")).group(0) assert ca == cb, "两份 DEFAULT_COLOR 漂移" def test_widget_host_injection_complete(): src = _read("widget.js") for slot in SLOTS: assert slot + ":" in src, f"widget.js :host 注入缺 {slot}" def test_style_root_defines_all_slots(): src = _read("css/style.css") for slot in SLOTS: assert re.search(re.escape(slot) + r"\s*:", src), f"style.css :root 缺 {slot}" def test_var_refs_within_known_slots(): for rel in ("widget.js", "css/style.css"): used = set(re.findall(r"var\((--w-[a-z-]+)", _read(rel))) unknown = used - set(SLOTS) assert not unknown, f"{rel} 引用未定义槽位: {unknown}" def _strip_defaults(src): m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", src, re.S) assert m, "DEFAULT_PALETTE 块不存在" src = src.replace(m.group(0), "") return re.sub(r'var DEFAULT_COLOR = "[^"]*"', "", src) def test_widget_no_stray_orange(): src = _strip_defaults(_read("widget.js")) assert not ORANGE.findall(src), "widget.js 残留橙色 hex: " + str(ORANGE.findall(src)[:5]) assert not STRAY_RGBA.findall(src), "widget.js 残留橙色 rgba: " + str(STRAY_RGBA.findall(src)[:5]) assert "c.primary" not in _read("widget.js"), "c.primary 应已替换为 var(--w-primary)" def test_style_no_stray_orange(): bad = [ ln for ln in _read("css/style.css").splitlines() if (ORANGE.search(ln) or STRAY_RGBA.search(ln)) and "--w-" not in ln ] assert not bad, "style.css 残留橙色(非 :root 槽位行): " + "; ".join(bad[:5]) src = _read("css/style.css") assert "--user-bubble" not in src, "旧变量 --user-bubble 应已迁移为 --w-primary" assert "--primary-dark" not in src, "死变量 --primary-dark 应已删除" def test_widget_avatar_src_uses_wsbase(): src = _read("widget.js") assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)" # spec 2026-10-19 §4: 默认头像同样走 basePath 前缀(Host 模式为空串零变化) assert "wsBase + basePath + DEFAULT_AVATAR_PATH" in src def test_style_root_defaults_match_theme_palette(): """:root 槽位字面量与 theme.js DEFAULT_PALETTE 一致(spec §6 层④兜底)。""" root_m = re.search(r":root \{(.*?)\}", _read("css/style.css"), re.S) assert root_m, "style.css 缺 :root 块" root = root_m.group(1) pal_m = re.search(r"var DEFAULT_PALETTE = \{(.*?)\};", _read("js/theme.js"), re.S) assert pal_m, "theme.js 缺 DEFAULT_PALETTE" palette = pal_m.group(1) mapping = { "--w-primary": "primary", "--w-primary-rgb": "primaryRgb", "--w-gradient-end": "gradientEnd", "--w-deep-text": "deepText", "--w-caption-text": "captionText", "--w-soft-bg": "softBg", "--w-border-soft": "borderSoft", } for slot, key in mapping.items(): css_m = re.search(re.escape(slot) + r"\s*:\s*([^;]+);", root) assert css_m, f":root 缺 {slot}" js_m = re.search(key + r':\s*"([^"]+)"', palette) assert js_m, f"DEFAULT_PALETTE 缺 {key}" assert re.sub(r"\s", "", css_m.group(1)) == re.sub(r"\s", "", js_m.group(1)), \ f"{slot} 与 DEFAULT_PALETTE.{key} 不一致" def test_widget_derivation_unit_banners_present(): """主题派生单元横幅存在且框住全部同步单元(防孤儿横幅误导后来者)。""" src = _read("widget.js") assert "主题派生单元:" in src, "widget.js 缺主题派生单元起始横幅" assert "主题派生单元结束" in src, "widget.js 缺主题派生单元结束横幅" start = src.index("主题派生单元:") end = src.index("主题派生单元结束") assert start < end, "主题派生单元横幅顺序颠倒" body = src[start:end] for name in SYNCED_FUNCTIONS: assert ("function " + name + "(") in body, f"横幅区间缺函数 {name}" for const in ["DEFAULT_COLOR", "DEFAULT_PALETTE"]: assert ("var " + const) in body, f"横幅区间缺少常量 {const}" def test_chat_js_sets_all_slots(): """独立页 bootstrap 必须覆盖全部 7 槽位(防槽位改名后独立页静默滞后)。""" src = _read("js/chat.js") for slot in SLOTS: assert 'setProperty("' + slot + '"' in src, f"chat.js 缺 {slot} 的 setProperty"