152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""公式渲染端到端验证:真实会话消息 → 真实前端管线 → DOM 断言
|
||
|
||
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/verify_math_render.py
|
||
|
||
铁律:不污染真实 DB —— 先把 data/chat_history.db 复制到临时文件,再指向副本。
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
import shutil
|
||
import tempfile
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||
|
||
_REAL_DB = os.path.join(os.path.dirname(__file__), "..", "data", "chat_history.db")
|
||
_TMP_DB = os.path.join(tempfile.gettempdir(), f"haocode_verify_math_{os.getpid()}.db")
|
||
shutil.copyfile(_REAL_DB, _TMP_DB)
|
||
|
||
import core.db_manager as _dbm # noqa: E402
|
||
_dbm._DEFAULT_DB = _TMP_DB
|
||
|
||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||
from PyQt6.QtCore import QTimer # noqa: E402
|
||
from ui.views.main_window import MainWindow # noqa: E402
|
||
|
||
app = QApplication(sys.argv)
|
||
results = []
|
||
win = {"w": None, "sid": None}
|
||
|
||
JS_QUERY = r"""(function(){
|
||
var scope = document.getElementById('chat-container') || document.body;
|
||
var q = function(s){ return scope.querySelectorAll(s).length; };
|
||
// 只取助手消息(user 消息按设计是纯文本,本来就不渲染公式)
|
||
var asst = scope.querySelectorAll('.message-wrapper.assistant');
|
||
var asstScope = asst.length ? asst[asst.length - 1] : scope;
|
||
// 可见文本:剔除 katex-mathml(CSS 视觉隐藏的 MathML 层,内部保留原始 tex 属正常)
|
||
var clone = asstScope.cloneNode(true);
|
||
clone.querySelectorAll('.katex-mathml').forEach(function(e){ e.parentNode.removeChild(e); });
|
||
var visTxt = clone.textContent || '';
|
||
var codeTxt = '';
|
||
asstScope.querySelectorAll('pre, code').forEach(function(e){ codeTxt += e.textContent + '\n'; });
|
||
return {
|
||
katex: q('.katex'),
|
||
display: q('.katex-display'),
|
||
mathml: q('.katex-mathml'),
|
||
vislayer: q('.katex-html'),
|
||
leakedPlaceholder: visTxt.indexOf('@@K') !== -1,
|
||
rawBackslash: visTxt.indexOf('\\operatorname') !== -1,
|
||
bareBracketFormula: visTxt.indexOf('P_4=\\operatorname') !== -1,
|
||
codeHasDollar: codeTxt.indexOf('$x + y$') !== -1,
|
||
codeHasArr: codeTxt.indexOf('arr[0]') !== -1
|
||
};
|
||
})()"""
|
||
|
||
|
||
def check(name, cond, extra=""):
|
||
print((" PASS " if cond else " FAIL ") + name + ((" | " + str(extra)) if extra else ""))
|
||
results.append(bool(cond))
|
||
|
||
|
||
def run_js(js, timeout_s=15):
|
||
r = {"v": None, "d": False}
|
||
|
||
def cb(val):
|
||
if isinstance(val, str):
|
||
try:
|
||
val = json.loads(val)
|
||
except Exception:
|
||
pass
|
||
r["v"] = val
|
||
r["d"] = True
|
||
|
||
b = win["w"].browser
|
||
if hasattr(b, "execute_js_async"):
|
||
b.execute_js_async(js, cb)
|
||
else:
|
||
b.page().runJavaScript(js, cb)
|
||
t0 = time.time()
|
||
while not r["d"] and time.time() - t0 < timeout_s:
|
||
app.processEvents()
|
||
time.sleep(0.05)
|
||
return r["v"]
|
||
|
||
|
||
def boot():
|
||
w = MainWindow()
|
||
win["w"] = w
|
||
sid = None
|
||
for s in w.db.get_all_sessions():
|
||
if s.get("title") == "公式渲染验收":
|
||
sid = s["id"]
|
||
break
|
||
if not sid:
|
||
print("FAIL 未找到「公式渲染验收」会话(先跑 tests/inject_math_demo.py)")
|
||
app.quit()
|
||
return
|
||
win["sid"] = sid
|
||
print(f"会话: {sid}")
|
||
w.load_messages_to_web(sid)
|
||
QTimer.singleShot(9000, phase_check)
|
||
|
||
|
||
def phase_check():
|
||
for _ in range(40):
|
||
if run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
|
||
break
|
||
time.sleep(0.5)
|
||
|
||
r = run_js(JS_QUERY)
|
||
if not isinstance(r, dict):
|
||
print(f"FAIL DOM 查询失败: {r!r}")
|
||
app.quit()
|
||
return
|
||
print("\n DOM 统计: " + json.dumps(r, ensure_ascii=False))
|
||
check("KaTeX 渲染出公式(.katex > 0)", r["katex"] > 0, f"katex={r['katex']}")
|
||
check("块公式 7 个(.katex-display == 7)", r["display"] == 7, f"display={r['display']}")
|
||
check("可视层存在(.katex-html > 0)", r["vislayer"] > 0, f"vislayer={r['vislayer']}")
|
||
check("无占位符泄漏", not r["leakedPlaceholder"])
|
||
check("可见文本无原始 tex 残留", not r["rawBackslash"])
|
||
check("无裸括号公式残留", not r["bareBracketFormula"])
|
||
check("代码块内 $x + y$ 保持原样", r["codeHasDollar"])
|
||
check("代码块内 arr[0] 保持原样", r["codeHasArr"])
|
||
|
||
try:
|
||
shot_path = os.path.join(os.path.dirname(__file__), "_tmp_math_render.png")
|
||
win["w"].browser.grab().save(shot_path)
|
||
print(f" 截图: {shot_path}")
|
||
except Exception as e:
|
||
print(f" 截图失败: {e}")
|
||
|
||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
|
||
try:
|
||
win["w"].close()
|
||
except Exception:
|
||
pass
|
||
app.quit()
|
||
|
||
|
||
QTimer.singleShot(400, boot)
|
||
QTimer.singleShot(120000, app.quit)
|
||
app.exec()
|
||
try:
|
||
os.remove(_TMP_DB)
|
||
except Exception:
|
||
pass
|
||
sys.exit(0 if all(results) else 1)
|