Files
ai-kefu-admin/tests/test_theme_static.py
浅念 0baa9ab2ed feat(widget): 脚本地址自推导 basePath(路径模式端点前缀)
spec 2026-10-19 §4: 路径模式(/t/<slug>/static/widget.js)下从 script.src
剥尾推导 basePath,brand/avatar 三分支/WS 四处端点构造统一加前缀;
Host 模式 indexOf 命中在 0 → basePath="" 端点零变化。
test_theme_static.py 头像锚定守卫随构造更新(wsBase + basePath + …,
语义不变:绝对锚定 + 禁相对路径)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 18:13:49 +08:00

174 lines
7.1 KiB
Python

"""动态主题色静态回归网(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"