173 lines
7.0 KiB
Python
173 lines
7.0 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)"
|
||
|
|
assert "wsBase + 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"
|