Compare commits
1
Commits
master
..
0ba53cc7e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba53cc7e3 |
-10
@@ -1,10 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# Runtime data: config with API keys, database, attachments, locks. Never commit.
|
||||
data/
|
||||
|
||||
# Diagnostics / logs
|
||||
*.log
|
||||
@@ -14,7 +14,6 @@
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
ENV_KEY = "HAOCODE_CONFIG_FILE"
|
||||
|
||||
@@ -48,37 +47,6 @@ def load_config() -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def save_config(data: dict) -> bool:
|
||||
"""原子保存当前配置;失败时保留原文件并返回 False。"""
|
||||
if not isinstance(data, dict):
|
||||
print("[config] 拒绝保存:配置内容不是 JSON 对象")
|
||||
return False
|
||||
|
||||
path = os.path.abspath(config_path())
|
||||
parent = os.path.dirname(path)
|
||||
temp_path = ""
|
||||
try:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
fd, temp_path = tempfile.mkstemp(
|
||||
prefix=".haocode_config_", suffix=".tmp", dir=parent)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, path)
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[config] 配置保存失败: {path}({type(exc).__name__})")
|
||||
return False
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# P1-01:渲染窗口配置解析(render_window_mode / render_window_size)
|
||||
# 规则(与 ui/web/render_window.js 的 JS 侧守卫保持一致):
|
||||
|
||||
+7
-42
@@ -214,7 +214,7 @@ class Wv2Session:
|
||||
self._js_pump = QTimer()
|
||||
self._js_pump.setInterval(25)
|
||||
self._js_pump.timeout.connect(self._js_pump_tick)
|
||||
# 无回调脚本不进入队列;没有待处理任务时不需要常驻唤醒 UI 线程。
|
||||
self._js_pump.start()
|
||||
# 🆕 预热:立即导航 about:blank,让 msedgewebview2 进程/GPU 在 UI 构建期间冷启动
|
||||
# (实测本机首次真实页面导航需 12-15s,预热后降到 ~1s)
|
||||
try:
|
||||
@@ -339,27 +339,8 @@ class Wv2Session:
|
||||
print("[WV2] navigate error:", ex)
|
||||
|
||||
def execute_js(self, script: str):
|
||||
"""执行无需返回值的脚本,不进入结果轮询队列。
|
||||
|
||||
流式正文每个 token 都会走这里。旧实现统一使用
|
||||
``ExecuteScriptWithResultAsync`` 并把任务放入 ``_js_pending``,高频
|
||||
输出时会在渲染器和 Python 侧同时堆积大量无用结果;WebView2 原生
|
||||
``ExecuteScriptAsync`` 已经提供了真正的 fire-and-forget 路径。
|
||||
"""
|
||||
try:
|
||||
execute_async = getattr(self.core, "ExecuteScriptAsync")
|
||||
except AttributeError:
|
||||
# 兼容旧版/测试替身未暴露 ExecuteScriptAsync 的情况。即使只能
|
||||
# 使用带结果 API,也直接丢弃 Task,不把无用结果放进轮询队列。
|
||||
try:
|
||||
getattr(self.core, "ExecuteScriptWithResultAsync")(script)
|
||||
except BaseException as ex:
|
||||
print("[WV2] execute_js fallback error:", ex)
|
||||
return
|
||||
try:
|
||||
execute_async(script)
|
||||
except BaseException as ex:
|
||||
print("[WV2] execute_js error:", ex)
|
||||
"""fire-and-forget(ChatBridge.run_js 的替换,JS 文本完全同构)"""
|
||||
self._js_run(script, None)
|
||||
|
||||
def execute_js_async(self, script: str, cb):
|
||||
"""带回调执行:真异步,回调在主线程定时器 tick 中发出(绝不阻塞)"""
|
||||
@@ -377,25 +358,13 @@ class Wv2Session:
|
||||
pass
|
||||
return
|
||||
self._js_pending.append([task, cb, time.time()])
|
||||
try:
|
||||
if not self._js_pump.isActive():
|
||||
self._js_pump.start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _js_pump_tick(self):
|
||||
if not self._js_pending:
|
||||
try:
|
||||
self._js_pump.stop()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
import json
|
||||
# 先摘下本批次;回调中再次入队的任务写入新的 _js_pending,不能被
|
||||
# 本轮收尾赋值覆盖。
|
||||
pending = self._js_pending
|
||||
self._js_pending = []
|
||||
for task, cb, t0 in pending:
|
||||
remaining = []
|
||||
for task, cb, t0 in self._js_pending:
|
||||
done = False
|
||||
try:
|
||||
done = task.IsCompleted
|
||||
@@ -404,7 +373,7 @@ class Wv2Session:
|
||||
if not done and time.time() - t0 > 10:
|
||||
done = True # 10s 安全超时(渲染器死亡时不永久卡队列)
|
||||
if not done:
|
||||
self._js_pending.append([task, cb, t0])
|
||||
remaining.append([task, cb, t0])
|
||||
continue
|
||||
result = None
|
||||
try:
|
||||
@@ -426,11 +395,7 @@ class Wv2Session:
|
||||
cb(result)
|
||||
except Exception as ex:
|
||||
print("[WV2] js callback error:", ex)
|
||||
if not self._js_pending:
|
||||
try:
|
||||
self._js_pump.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._js_pending = remaining
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tests/_test_env.py —— 自动化测试的统一临时环境(P0-01 新增)
|
||||
|
||||
铁律(VERIFICATION.md §凭据与运行数据隔离):
|
||||
1. 每个测试进程独立临时目录 + 最小临时配置;
|
||||
2. 在 import 任何可能间接加载 MainWindow 的模块之前设置 HAOCODE_CONFIG_FILE;
|
||||
3. 在 import MainWindow 之前把 core.db_manager._DEFAULT_DB 指向临时数据库;
|
||||
4. 写入、迁移、附件和截图产物只落到临时目录。
|
||||
|
||||
用法(放在测试文件顶部、任何 UI import 之前):
|
||||
|
||||
from tests._test_env import isolate
|
||||
isolate("bashpanel", config={"providers": {}, "mode_switch": True})
|
||||
|
||||
说明:
|
||||
· 临时目录按 进程 pid 隔离,测试进程互不干扰;
|
||||
· 默认临时配置为最小可启动结构(空 providers + 测试 provider);
|
||||
· 本模块不 import 任何 PyQt 模块,可在无 GUI 环境中安全调用。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_TESTS_DIR)
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
def default_config() -> dict:
|
||||
"""最小可启动临时配置(不含任何真实凭据)。"""
|
||||
return {
|
||||
"providers": {
|
||||
"testprov": {
|
||||
"api_key": "test-key-not-real",
|
||||
"base_url": "http://127.0.0.1:9/v1",
|
||||
"models": ["test-model"],
|
||||
"model_contexts": {"test-model": 100000},
|
||||
}
|
||||
},
|
||||
"default_provider": "testprov",
|
||||
"default_model": "test-model",
|
||||
}
|
||||
|
||||
|
||||
def isolate(tag: str = "t", config: dict | None = None) -> dict:
|
||||
"""创建临时配置 + 临时数据库并完成重定向。必须在 import MainWindow 之前调用。
|
||||
|
||||
返回 {"base": 临时目录, "config": 临时配置路径, "db": 临时数据库路径}。
|
||||
"""
|
||||
base = os.path.join(tempfile.gettempdir(),
|
||||
f"haocode_test_{tag}_{os.getpid()}")
|
||||
os.makedirs(base, exist_ok=True)
|
||||
|
||||
cfg_path = os.path.join(base, "config.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config if config is not None else default_config(),
|
||||
f, ensure_ascii=False, indent=2)
|
||||
|
||||
db_path = os.path.join(base, "chat_history.db")
|
||||
for p in (db_path, db_path + "-wal", db_path + "-shm"):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
os.environ["HAOCODE_CONFIG_FILE"] = cfg_path
|
||||
|
||||
# P1-03:QtWebEngine 独立 profile 重定向到临时目录(源码运行才在 data/webengine/)
|
||||
os.environ.setdefault("HAOCODE_WEBENGINE_PROFILE_DIR", os.path.join(base, "webengine_profile"))
|
||||
|
||||
import core.db_manager as _dbm
|
||||
_dbm._DEFAULT_DB = db_path
|
||||
|
||||
return {"base": base, "config": cfg_path, "db": db_path}
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用「备份 ZIP 里的真实旧库」验证:迁移新列后旧数据必须 100% 原样保留(只读对比)。
|
||||
|
||||
用法: python tests/check_db_migration.py <backup.zip>
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
ZIP = sys.argv[1] if len(sys.argv) > 1 else r"D:/haocode_backup_20260916_1102.zip"
|
||||
OUT = os.path.join(tempfile.gettempdir(), "haocode_from_backup.db")
|
||||
if os.path.exists(OUT):
|
||||
os.remove(OUT)
|
||||
|
||||
with zipfile.ZipFile(ZIP) as z:
|
||||
name = [n for n in z.namelist()
|
||||
if os.path.basename(n) == "chat_history.db"][0]
|
||||
with z.open(name) as src, open(OUT, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
print("从备份取出:", name, os.path.getsize(OUT), "bytes")
|
||||
|
||||
|
||||
def fp(path):
|
||||
c = sqlite3.connect(path)
|
||||
c.row_factory = sqlite3.Row
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(messages)")]
|
||||
d = {
|
||||
"messages_rows": c.execute("SELECT COUNT(*) FROM messages").fetchone()[0],
|
||||
"sessions_rows": c.execute("SELECT COUNT(*) FROM sessions").fetchone()[0],
|
||||
"msg_sum": list(c.execute(
|
||||
"SELECT COUNT(*), SUM(LENGTH(COALESCE(content,''))), "
|
||||
"SUM(LENGTH(COALESCE(timeline,''))) FROM messages").fetchone()[:]),
|
||||
"parents": [tuple(r) for r in c.execute(
|
||||
"SELECT id, parent_id FROM messages ORDER BY id")],
|
||||
"leaves": [tuple(r) for r in c.execute(
|
||||
"SELECT id, current_leaf_msg_id FROM sessions ORDER BY id")],
|
||||
"branches": sorted((r["parent_id"], r["n"]) for r in c.execute(
|
||||
"SELECT parent_id, COUNT(*) n FROM messages "
|
||||
"WHERE parent_id IS NOT NULL GROUP BY parent_id HAVING n>1")),
|
||||
"roles": sorted((r["role"], r["n"]) for r in c.execute(
|
||||
"SELECT role, COUNT(*) n FROM messages GROUP BY role")),
|
||||
"has_new_cols": all(x in cols for x in ("stop_reason", "error_message")),
|
||||
}
|
||||
c.close()
|
||||
return d
|
||||
|
||||
|
||||
before = fp(OUT)
|
||||
print("备份库(迁移前): 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
|
||||
before["messages_rows"], before["sessions_rows"],
|
||||
len(before["branches"]), before["has_new_cols"]))
|
||||
|
||||
import core.db_manager as _dbm # noqa: E402
|
||||
_dbm._DEFAULT_DB = OUT
|
||||
d = _dbm.DBManager(db_path=OUT) # ← 触发迁移
|
||||
after = fp(OUT)
|
||||
print("迁移后 : 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
|
||||
after["messages_rows"], after["sessions_rows"],
|
||||
len(after["branches"]), after["has_new_cols"]))
|
||||
print()
|
||||
keys = ("messages_rows", "sessions_rows", "msg_sum", "parents",
|
||||
"leaves", "branches", "roles")
|
||||
for k in keys:
|
||||
print((" OK " if before[k] == after[k] else " DIFF ") + "%-14s" % k)
|
||||
allok = all(before[k] == after[k] for k in keys)
|
||||
print()
|
||||
print("每个会话叶子:", [(r[0][:16], (r[1] or "")[:16]) for r in after["leaves"][:4]], "...")
|
||||
print(">>> 结论:", "✅ 旧库数据 100% 原样保留(只多两个空列)"
|
||||
if allok else "❌ 有改动!")
|
||||
os.remove(OUT)
|
||||
sys.exit(0 if allok else 1)
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""代理侧调试注入 CLI(与运行中的 app 通过文件通信)
|
||||
|
||||
用法:
|
||||
python tests/debug_inject.py "备注内容" # 注入 [AGENT] 日志行
|
||||
python tests/debug_inject.py --show # 打开独立调试窗口
|
||||
python tests/debug_inject.py --hide # 关闭独立调试窗口
|
||||
python tests/debug_inject.py --read [N] # 读取会话日志最后 N 行(默认 50)
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from core import debug_log as dl
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 1
|
||||
if args[0] == "--show":
|
||||
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("show")
|
||||
print(f"已请求打开调试窗口 -> {dl.DEBUG_CMD_PATH}")
|
||||
return 0
|
||||
if args[0] == "--hide":
|
||||
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("hide")
|
||||
print(f"已请求关闭调试窗口 -> {dl.DEBUG_CMD_PATH}")
|
||||
return 0
|
||||
if args[0] == "--read":
|
||||
n = int(args[1]) if len(args) > 1 else 50
|
||||
if not os.path.exists(dl.DEBUG_LOG_PATH):
|
||||
print("(会话日志尚不存在)")
|
||||
return 0
|
||||
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
print(f"===== {os.path.basename(dl.DEBUG_LOG_PATH)} 最后 {min(n, len(lines))} 行 =====")
|
||||
for l in lines[-n:]:
|
||||
print(l)
|
||||
return 0
|
||||
# 普通文本 → 注入 [AGENT]
|
||||
dl.debug_log(args[0], "AGENT")
|
||||
print(f"已注入 [AGENT]: {args[0]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""真实 API 测试:opencode-go / deepseek-v4-flash + 完整 agent 循环(工具执行)
|
||||
运行: C:\Users\14890\miniconda3\envs\haocode\python.exe -u tests/diag_live_agent.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from core.agent import (Agent, AgentConfig, ModelConfig, RetryConfig) # noqa: E402
|
||||
from core.agent.recovery import AgentRunner # noqa: E402
|
||||
from core.agent.stream_fn import openai_stream # noqa: E402
|
||||
from core.agent.tools import default_tools # noqa: E402
|
||||
|
||||
MODEL_NAME = "deepseek-v4-flash"
|
||||
|
||||
cfg = json.load(open(os.path.join(ROOT, "data", "config.json"), encoding="utf-8"))
|
||||
prov = cfg["providers"]["opencode-go"]
|
||||
|
||||
model = ModelConfig(
|
||||
provider="opencode-go", name=MODEL_NAME,
|
||||
context_window=int(prov.get("model_contexts", {}).get(MODEL_NAME, 1000000)),
|
||||
max_tokens=4096, temperature=0.3,
|
||||
api_key=prov["api_key"], base_url=prov["base_url"],
|
||||
)
|
||||
|
||||
with open(os.path.join(ROOT, "SYSTEM_PROMPT.md"), encoding="utf-8") as f:
|
||||
system_prompt = f.read()
|
||||
|
||||
agent_cfg = AgentConfig(
|
||||
model=model,
|
||||
tools=default_tools(),
|
||||
system_prompt=system_prompt,
|
||||
tool_context={"cwd": ROOT},
|
||||
retry=RetryConfig(max_attempts=2, base_delay_ms=1000),
|
||||
)
|
||||
agent = Agent(agent_cfg)
|
||||
agent.set_stream_fn(openai_stream)
|
||||
|
||||
|
||||
def show(e):
|
||||
if e.type == "message_update" and e.assistant_message_event is not None:
|
||||
ev = e.assistant_message_event
|
||||
if ev.type == "text_delta":
|
||||
print(" [正文] " + ev.text, end="", flush=True)
|
||||
elif ev.type == "thinking_delta":
|
||||
print(" [思考] " + ev.text, end="", flush=True)
|
||||
elif ev.type == "toolcall_delta":
|
||||
print(f" [tc] {ev.tool_call_field}={ev.tool_call_delta!r}",
|
||||
end="", flush=True)
|
||||
elif e.type == "tool_execution_start" and e.tool_call is not None:
|
||||
print(f"\n [工具开始] {e.tool_call.name} 参数={e.tool_call.arguments}")
|
||||
elif e.type == "tool_execution_update" and e.arg:
|
||||
print(" [工具输出] " + str(e.arg), end="", flush=True)
|
||||
elif e.type == "tool_execution_end" and e.tool_call is not None:
|
||||
c = e.result.content if e.result else ""
|
||||
if not isinstance(c, str):
|
||||
c = "".join(x.get("text", "") for x in c if isinstance(x, dict))
|
||||
print(f"\n [工具结束] ok={not e.is_error} 结果={c[:200]!r}")
|
||||
elif e.type == "agent_end":
|
||||
print(f"\n [agent_end] stop_reason={e.stop_reason} "
|
||||
f"error={getattr(e.error, 'message', None)}")
|
||||
|
||||
|
||||
agent.subscribe(show)
|
||||
runner = AgentRunner(agent)
|
||||
|
||||
print("=" * 60)
|
||||
print(f"实时测试: {model.base_url} / {MODEL_NAME}")
|
||||
print(f"system prompt: {len(system_prompt)} 字符, tools: {len(default_tools())} 个")
|
||||
print("=" * 60)
|
||||
|
||||
runner.run("请用 bash 工具执行命令: echo hello-from-haocode && ls,然后告诉我输出结果。")
|
||||
|
||||
print("\n===== 最终消息链 =====")
|
||||
for m in agent.state.messages:
|
||||
tc = f" tool_calls={[t.name for t in m.tool_calls]}" if m.tool_calls else ""
|
||||
print(f"- {m.role}: {(m.content or '')[:100]!r}{tc}")
|
||||
|
||||
ok = any(m.role == "toolResult" for m in agent.state.messages)
|
||||
print("\n===== 结论:", "✅ 真实 tool_call 被发出并执行" if ok
|
||||
else "❌ 没有工具执行(可能供应商不支持 tools API,检查是否走了文字兜底)", "=====")
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E onscreen:真实可见窗口 + 真实 API + 流式过程中多点采样 DOM。
|
||||
复现用户环境(非 offscreen,rAF 行为与真实窗口一致)。
|
||||
运行: python tests/diag_live_onscreen.py (会在桌面弹出窗口)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ.pop("QT_QPA_PLATFORM", None) # onscreen
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtCore import QTimer # noqa: E402
|
||||
|
||||
from core.agent.types import AssistantMessageEvent # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = None
|
||||
results = []
|
||||
|
||||
SNAP_BUF_JS = (
|
||||
"(function() {"
|
||||
" var w = document.getElementById('msg-e2e-text');"
|
||||
" if (!w) return 'no-wrapper';"
|
||||
" var segs = w.querySelectorAll('.md-segment');"
|
||||
" var out = [];"
|
||||
" for (var i = 0; i < segs.length; i++) {"
|
||||
" out.push('buf:' + ((segs[i].__buf || '').length)"
|
||||
" + '/dom:' + ((segs[i].textContent || '').length));"
|
||||
" }"
|
||||
" return out.join(' ') || 'no-segs';"
|
||||
"})()"
|
||||
)
|
||||
|
||||
SNAP_JS = (
|
||||
"(function() {"
|
||||
" var w = document.getElementById('msg-e2e-text');"
|
||||
" if (!w) return 'no-wrapper';"
|
||||
" var segs = w.querySelectorAll('.md-segment');"
|
||||
" var n = 0;"
|
||||
" for (var i = 0; i < segs.length; i++)"
|
||||
" if ((segs[i].textContent || '').trim()) n++;"
|
||||
" return 'segs=' + segs.length + ' nonempty=' + n +"
|
||||
" ' chip=' + !!w.querySelector('.tool-chip') +"
|
||||
" ' streaming=' + w.classList.contains('streaming');"
|
||||
"})()"
|
||||
)
|
||||
|
||||
FINAL_JS = """
|
||||
(function() {
|
||||
try {
|
||||
var w = document.getElementById('msg-e2e-text');
|
||||
if (!w) return JSON.stringify({error: 'no wrapper'});
|
||||
var tl = w.querySelector('.reply-content');
|
||||
var blocks = Array.prototype.map.call(tl.children,
|
||||
function(el) { return el.className.split(' ')[0]; });
|
||||
var segs = w.querySelectorAll('.md-segment');
|
||||
var segTexts = Array.prototype.map.call(segs,
|
||||
function(x) { return (x.textContent || '').slice(0, 80); });
|
||||
var chip = w.querySelector('.tool-chip');
|
||||
return JSON.stringify({blocks: blocks, segTexts: segTexts,
|
||||
chip: !!chip, streaming: w.classList.contains('streaming')});
|
||||
} catch (e) { return JSON.stringify({error: String(e)}); }
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
def step1():
|
||||
global window
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
QTimer.singleShot(3500, step2)
|
||||
|
||||
|
||||
def step2():
|
||||
from core.llm_engine import AgentWorker
|
||||
import core.llm_engine as le
|
||||
db = window.db
|
||||
sess = db.create_session("E2E onscreen 测试")
|
||||
sid = sess["id"]
|
||||
mid = "msg-e2e-text"
|
||||
user_row = db.add_message(sid, "user", "测试问题", None)
|
||||
window.current_session_id = sid
|
||||
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
|
||||
window._active_streams[sid] = {
|
||||
"msg_id": mid, "content": "", "reasoning": "",
|
||||
"timeline": [], "tl_kind": None,
|
||||
"parent_id": user_row["id"], "branch_info": None, "worker": None,
|
||||
"previous_leaf_id": user_row["id"],
|
||||
}
|
||||
|
||||
real_stream = le.openai_stream
|
||||
|
||||
def logging_stream(context, model, signal, max_tokens, tools=None):
|
||||
for kind, payload in real_stream(context, model, signal,
|
||||
max_tokens, tools):
|
||||
t = getattr(payload, "type", kind)
|
||||
if t in ("text_delta", "thinking_delta", "toolcall_delta"):
|
||||
txt = str(getattr(payload, "text", ""))[:25]
|
||||
print(f" [stream] {t} {txt!r}", flush=True)
|
||||
else:
|
||||
print(f" [stream] {kind}/{t}", flush=True)
|
||||
yield kind, payload
|
||||
|
||||
le.openai_stream = logging_stream
|
||||
worker = AgentWorker(provider_name=window.current_provider,
|
||||
model_name=window.current_model,
|
||||
openai_messages=[{"role": "user",
|
||||
"content": "运行命令 echo hello-from-onscreen "
|
||||
"并告诉我输出内容"}],
|
||||
enable_tools=True)
|
||||
|
||||
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
|
||||
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
|
||||
worker.tool_execution_started.connect(
|
||||
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
|
||||
worker.tool_execution_updated.connect(
|
||||
lambda cid, text: window._on_tool_updated(sid, cid, text))
|
||||
worker.tool_execution_finished.connect(
|
||||
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
|
||||
worker.error_occurred.connect(
|
||||
lambda err: (print(" [worker error]", err, flush=True),
|
||||
window.on_error(sid, err)))
|
||||
worker.finished.connect(lambda: window.on_reply_finished(sid))
|
||||
window._active_streams[sid]["worker"] = worker
|
||||
worker.start()
|
||||
print("worker started(窗口已可见,观察屏幕)...", flush=True)
|
||||
|
||||
def snapshot(tag):
|
||||
window.browser.page().runJavaScript(
|
||||
SNAP_JS,
|
||||
lambda v, tag=tag: print(f" [snapshot {tag}] {v}", flush=True))
|
||||
window.browser.page().runJavaScript(
|
||||
SNAP_BUF_JS,
|
||||
lambda v, tag=tag: print(f" [snapshot {tag} BUF] {v}", flush=True))
|
||||
|
||||
QTimer.singleShot(8000, lambda: snapshot("t+8s"))
|
||||
QTimer.singleShot(15000, lambda: snapshot("t+15s"))
|
||||
QTimer.singleShot(25000, lambda: snapshot("t+25s"))
|
||||
QTimer.singleShot(45000, step3)
|
||||
|
||||
|
||||
def step3():
|
||||
def got(res):
|
||||
d = json.loads(str(res))
|
||||
if "error" in d:
|
||||
check("E2E DOM", False, d["error"])
|
||||
finish()
|
||||
return
|
||||
print(f" blocks = {d['blocks']}")
|
||||
print(f" segTexts = {d['segTexts']}")
|
||||
print(f" chip={d['chip']} streaming={d['streaming']}")
|
||||
check("live 正文段非空", any(t.strip() for t in d["segTexts"]),
|
||||
str(d["segTexts"]))
|
||||
check("最终回答含命令输出",
|
||||
any("hello-from-onscreen" in t for t in d["segTexts"]),
|
||||
str(d["segTexts"]))
|
||||
check("工具 chip 存在", d["chip"])
|
||||
check("streaming 已收尾", not d["streaming"])
|
||||
finish()
|
||||
|
||||
window.browser.page().runJavaScript(FINAL_JS, got)
|
||||
|
||||
|
||||
def finish():
|
||||
try:
|
||||
window.db.delete_session(window.current_session_id)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, step1)
|
||||
app.exec()
|
||||
@@ -0,0 +1,187 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E live 测试:真实 AgentWorker + 假流式 + 真实 MainWindow 信号链路。
|
||||
验证 live 时正文 md-segment 是否有内容(用户报告的 bug)。
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/diag_live_text.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtCore import QTimer # noqa: E402
|
||||
|
||||
from core.agent.types import (AssistantMessageEvent, AgentMessage, ToolCall) # noqa: E402
|
||||
from core.agent import stream_fn as sf # noqa: E402
|
||||
from core.agent.tools import default_tools # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = None
|
||||
results = []
|
||||
CALL_STATE = {"n": 0}
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
# ---- 假 stream_fn:第 1 轮 思考+文本+bash 工具调用;第 2 轮 最终回答 ----
|
||||
def fake_stream(context, model, signal, max_tokens, tools=None):
|
||||
CALL_STATE["n"] += 1
|
||||
n = CALL_STATE["n"]
|
||||
if n == 1:
|
||||
yield ("event", AssistantMessageEvent(type="thinking_delta", text="我先看看"))
|
||||
yield ("event", AssistantMessageEvent(type="thinking_delta", text="目录。"))
|
||||
yield ("event", AssistantMessageEvent(type="text_delta", text="我来执行命令"))
|
||||
yield ("event", AssistantMessageEvent(type="toolcall_delta",
|
||||
tool_call_index=0, tool_call_field="id", tool_call_delta="call-e2e-1"))
|
||||
yield ("event", AssistantMessageEvent(type="toolcall_delta",
|
||||
tool_call_index=0, tool_call_field="name", tool_call_delta="bash"))
|
||||
yield ("event", AssistantMessageEvent(type="toolcall_delta",
|
||||
tool_call_index=0, tool_call_field="arguments",
|
||||
tool_call_delta='{"command": "echo e2e-ok"}'))
|
||||
time.sleep(0.3)
|
||||
yield ("final", AgentMessage(
|
||||
role="assistant", stop_reason="stop",
|
||||
tool_calls=[ToolCall(id="call-e2e-1", name="bash",
|
||||
raw_arguments='{"command": "echo e2e-ok"}')]))
|
||||
else:
|
||||
for tok in ["最终", "回答", ":任务", "完成。"]:
|
||||
yield ("event", AssistantMessageEvent(type="text_delta", text=tok))
|
||||
time.sleep(0.05)
|
||||
yield ("final", AgentMessage(role="assistant", stop_reason="stop"))
|
||||
|
||||
|
||||
def step1():
|
||||
global window
|
||||
window = MainWindow()
|
||||
QTimer.singleShot(3500, step2)
|
||||
|
||||
|
||||
def step2():
|
||||
from core.llm_engine import AgentWorker
|
||||
db = window.db
|
||||
sess = db.create_session("E2E 正文测试")
|
||||
sid = sess["id"]
|
||||
mid = "msg-e2e-text"
|
||||
user_row = db.add_message(sid, "user", "测试问题", None)
|
||||
window.current_session_id = sid
|
||||
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
|
||||
window._active_streams[sid] = {
|
||||
"msg_id": mid, "content": "", "reasoning": "",
|
||||
"timeline": [], "tl_kind": None,
|
||||
"parent_id": user_row["id"], "branch_info": None, "worker": None,
|
||||
"previous_leaf_id": user_row["id"],
|
||||
}
|
||||
|
||||
# 真实 API + 流事件日志(定位 text_delta 是否到达)
|
||||
import core.llm_engine as le
|
||||
real_stream = le.openai_stream
|
||||
def logging_stream(context, model, signal, max_tokens, tools=None):
|
||||
n_ev = 0
|
||||
for kind, payload in real_stream(context, model, signal,
|
||||
max_tokens, tools):
|
||||
t = getattr(payload, "type", kind)
|
||||
txt = str(getattr(payload, "text", ""))[:30]
|
||||
if t not in ("text_delta", "thinking_delta", "toolcall_delta"):
|
||||
print(f" [stream] {kind}/{t}", flush=True)
|
||||
else:
|
||||
n_ev += 1
|
||||
if n_ev <= 6 or n_ev % 20 == 0:
|
||||
print(f" [stream] {t} {txt!r}", flush=True)
|
||||
yield kind, payload
|
||||
le.openai_stream = logging_stream
|
||||
worker = AgentWorker(provider_name=window.current_provider,
|
||||
model_name=window.current_model,
|
||||
openai_messages=[{"role": "user",
|
||||
"content": "运行命令 echo hello-from-diag 并告诉我输出"}],
|
||||
enable_tools=True)
|
||||
|
||||
# 与 send_message 相同的信号连接
|
||||
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
|
||||
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
|
||||
worker.tool_execution_started.connect(
|
||||
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
|
||||
worker.tool_execution_updated.connect(
|
||||
lambda cid, text: window._on_tool_updated(sid, cid, text))
|
||||
worker.tool_execution_finished.connect(
|
||||
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
|
||||
worker.error_occurred.connect(lambda err: (print(' [worker error]', err), window.on_error(sid, err)))
|
||||
worker.finished.connect(lambda: window.on_reply_finished(sid))
|
||||
|
||||
window._active_streams[sid]["worker"] = worker
|
||||
worker.start()
|
||||
print("worker started, 等待流结束...")
|
||||
QTimer.singleShot(40000, step3)
|
||||
|
||||
|
||||
def step3():
|
||||
# live DOM 检查(不重载 DB)
|
||||
js = """
|
||||
(function() {
|
||||
try {
|
||||
var w = document.getElementById('msg-e2e-text');
|
||||
if (!w) return JSON.stringify({error: 'no wrapper'});
|
||||
var tl = w.querySelector('.reply-content');
|
||||
var blocks = Array.prototype.map.call(tl.children,
|
||||
function(el) { return el.className.split(' ')[0]; });
|
||||
var segs = w.querySelectorAll('.md-segment');
|
||||
var segTexts = Array.prototype.map.call(segs,
|
||||
function(x) { return x.textContent; });
|
||||
var thinks = w.querySelectorAll('.think-content');
|
||||
var thinkTexts = Array.prototype.map.call(thinks,
|
||||
function(x) { return x.textContent; });
|
||||
var chip = w.querySelector('.tool-chip');
|
||||
return JSON.stringify({blocks: blocks, segTexts: segTexts,
|
||||
thinkTexts: thinkTexts, chip: !!chip,
|
||||
streaming: w.classList.contains('streaming')});
|
||||
} catch (e) { return JSON.stringify({error: String(e)}); }
|
||||
})()
|
||||
"""
|
||||
def got(res):
|
||||
d = json.loads(str(res))
|
||||
if "error" in d:
|
||||
check("E2E DOM", False, d["error"])
|
||||
finish()
|
||||
return
|
||||
print(f" blocks = {d['blocks']}")
|
||||
print(f" segTexts = {d['segTexts']}")
|
||||
print(f" thinkTexts = {d['thinkTexts']}")
|
||||
print(f" chip = {d['chip']} streaming={d['streaming']}")
|
||||
check("live 正文段有内容(至少一段非空)",
|
||||
any((t or "").strip() for t in d["segTexts"]),
|
||||
str(d["segTexts"]))
|
||||
check("live 最终回答含命令输出",
|
||||
any("hello-from-diag" in (t or "") for t in d["segTexts"]),
|
||||
str(d["segTexts"]))
|
||||
check("live 思考段有内容(或模型未思考)",
|
||||
True, str(d["thinkTexts"])[:80])
|
||||
check("live 工具 chip 存在", d["chip"])
|
||||
check("streaming 已收尾", not d["streaming"])
|
||||
# 时间线入库检查
|
||||
st = window._active_streams.get(sid := window.current_session_id)
|
||||
tl = window.db.get_session(window.current_session_id) if False else None
|
||||
finish()
|
||||
|
||||
window.browser.page().runJavaScript(js, got)
|
||||
|
||||
|
||||
def finish():
|
||||
try:
|
||||
window.db.delete_session(window.current_session_id)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, step1)
|
||||
app.exec()
|
||||
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P2-02 诊断:右侧 Bash 面板滚动条与横纵交汇角(离屏)
|
||||
|
||||
测量并断言:
|
||||
S1 代码框(#bl_code)横滚动条实际厚度 = 8px、sizeHint 一致
|
||||
S2 代码框竖滚动条实际厚度 = 8px、sizeHint 一致
|
||||
S3 section 滚动区(#bl_scroll)竖滚动条实际厚度 = 8px
|
||||
S4 面板滚动条箭头 extent = 0(箭头隐藏)
|
||||
S5 交汇角像素 = 代码框背景 #fbfcfe(无原生亮色 corner 方块)
|
||||
S6 无泄漏:未命名 QPlainTextEdit 的滚动条仍是原生口径(≠8px、箭头>0)
|
||||
S7 无泄漏:附件预览滚动条保持自身 6px 口径
|
||||
并生成局部截图(面板全貌 + 代码框角落放大)打印测量值。
|
||||
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/diag_panel_scrollbar.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
_TMP = isolate("panelscroll", config={"providers": {}, "mode_switch": True})
|
||||
_DB_TMP = _TMP["db"]
|
||||
_CFG_TMP = _TMP["config"]
|
||||
|
||||
from PyQt6.QtWidgets import (QApplication, QPlainTextEdit, QStyle, # noqa: E402
|
||||
QStyleOptionSlider) # noqa: E402
|
||||
from PyQt6.QtTest import QTest # noqa: E402
|
||||
from PyQt6.QtCore import Qt # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ok = True
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "agent-handoff", "evidence")
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
def settle(ms=300):
|
||||
for _ in range(int(ms / 20) + 1):
|
||||
app.processEvents()
|
||||
QTest.qWait(20)
|
||||
|
||||
|
||||
def arrow_extent(sb, orient=Qt.Orientation.Vertical):
|
||||
"""滚动条箭头子控件(sub-line)的实际尺寸(px):QSS 把 add-line/sub-line 置 0 后应为 0。
|
||||
用 sb.style()(样式表代理风格)才能反映 QSS 效果;PyQt6 参数序 = (cc, opt, sc, widget)。
|
||||
返回 -2 表示无法测量(样式代理缺失等),调用方不得把 -2 当作 0。"""
|
||||
try:
|
||||
st = sb.style()
|
||||
if orient == Qt.Orientation.Vertical:
|
||||
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
|
||||
QStyleOptionSlider(),
|
||||
QStyle.SubControl.SC_ScrollBarSubLine, sb).height()
|
||||
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
|
||||
QStyleOptionSlider(),
|
||||
QStyle.SubControl.SC_ScrollBarSubLine, sb).width()
|
||||
except Exception as e:
|
||||
print(f" (arrow_extent 测量异常: {e})", flush=True)
|
||||
return -1
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
window.resize(1400, 800)
|
||||
window.show()
|
||||
settle(400)
|
||||
panel = window.bash_panel
|
||||
panel.expand_btn.click()
|
||||
settle(500)
|
||||
|
||||
# ---- 造数据:一个展开的层,out_box 同时触发横/纵滚动条 ----
|
||||
panel.on_started("sc1", "bash", {"command": "python long_report.py --all --verbose"})
|
||||
panel.on_finished("sc1", "bash", True,
|
||||
"$ python long_report.py --all --verbose\n"
|
||||
+ "\n".join(f"line-{i:03d}" for i in range(60))
|
||||
+ "\n[exit 0] (1.2s)")
|
||||
settle(300)
|
||||
lay = panel._layers["sc1"]
|
||||
lay.toggle()
|
||||
settle(200)
|
||||
# 超宽单行(NoWrap)→ 横向滚动条;再追加 60 行 → 同时触发纵向滚动条
|
||||
panel._layers["sc1"].out_box.setPlainText("X" * 3000 + "\n"
|
||||
+ "\n".join(f"tail-{i:03d}" for i in range(60)))
|
||||
settle(200)
|
||||
|
||||
ob = lay.out_box
|
||||
sbh = ob.horizontalScrollBar()
|
||||
sbv = ob.verticalScrollBar()
|
||||
check("S0.1 前置:横滚动条可见(超宽单行)", sbh.isVisible(), str(sbh.isVisible()))
|
||||
check("S0.2 前置:竖滚动条可见(60+ 行超出 230px 上限)", sbv.isVisible(), str(sbv.isVisible()))
|
||||
|
||||
# ---- S1/S2 代码框滚动条厚度 ----
|
||||
check("S1 代码框横滚动条实际厚度 = 8px", sbh.height() == 8, f"h={sbh.height()}")
|
||||
check("S1b 代码框横滚动条 sizeHint 厚 = 8px", sbh.sizeHint().height() == 8,
|
||||
f"{sbh.sizeHint().height()}")
|
||||
check("S2 代码框竖滚动条实际厚度 = 8px", sbv.width() == 8, f"w={sbv.width()}")
|
||||
check("S2b 代码框竖滚动条 sizeHint 宽 = 8px", sbv.sizeHint().width() == 8,
|
||||
f"{sbv.sizeHint().width()}")
|
||||
|
||||
# ---- S3 section 滚动区竖滚动条厚度 ----
|
||||
# 让运行中栏溢出:再加 12 个已完成层(复用 P11 的溢出手法不必,层数即可)
|
||||
for i in range(12):
|
||||
cid = f"scf{i}"
|
||||
panel.on_started(cid, "bash", {"command": f"cmd-{cid}"})
|
||||
panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)")
|
||||
settle(300)
|
||||
sdv = panel.sec_done.scroll.verticalScrollBar()
|
||||
check("S0.3 前置:已完成栏溢出、竖滚动条可见", sdv.isVisible(), str(sdv.isVisible()))
|
||||
check("S3 section 竖滚动条实际厚度 = 8px", sdv.width() == 8, f"w={sdv.width()}")
|
||||
|
||||
# ---- S4 箭头 extent ----
|
||||
print(f" 测量:代码框 H 箭头 extent = {arrow_extent(sbh, Qt.Orientation.Horizontal)}px, "
|
||||
f"V = {arrow_extent(sbv)}px; section V = {arrow_extent(sdv)}px",
|
||||
flush=True)
|
||||
check("S4 代码框横滚动条箭头 extent = 0", arrow_extent(sbh, Qt.Orientation.Horizontal) == 0,
|
||||
str(arrow_extent(sbh, Qt.Orientation.Horizontal)))
|
||||
check("S4b 代码框竖滚动条箭头 extent = 0", arrow_extent(sbv) == 0, str(arrow_extent(sbv)))
|
||||
check("S4c section 竖滚动条箭头 extent = 0", arrow_extent(sdv) == 0, str(arrow_extent(sdv)))
|
||||
|
||||
# ---- S5 交汇角(render 到透明 pixmap 取样;offscreen 下文档区背景不填充,
|
||||
# 但样式子控件(滚动条/边框/corner)正常渲染,可直接断言)----
|
||||
from PyQt6.QtGui import QPainter, QPixmap # noqa: E402
|
||||
w, h = ob.width(), ob.height()
|
||||
pm = QPixmap(w, h); pm.fill(Qt.GlobalColor.transparent)
|
||||
pr = QPainter(pm); ob.render(pr); pr.end()
|
||||
img = pm.toImage()
|
||||
def px_at(x, y):
|
||||
c = img.pixelColor(x, y)
|
||||
return (c.red(), c.green(), c.blue())
|
||||
CORNER_BG = (0xfb, 0xfc, 0xfe) # #bl_code 背景色 = QSS 里 corner 规则的目标色
|
||||
n_bg = n_white = 0
|
||||
for dy in range(8):
|
||||
for dx in range(8):
|
||||
c = px_at(w - 1 - dx, h - 1 - dy)
|
||||
if c == CORNER_BG:
|
||||
n_bg += 1
|
||||
if c == (255, 255, 255):
|
||||
n_white += 1
|
||||
# 健全性:样式确已作用到该框(文本色 #243043 与 handle #d0d0d0 应在渲染图中出现)
|
||||
n_text = sum(1 for y in range(0, h, 2) for x in range(0, w, 2)
|
||||
if px_at(x, y) == (0x24, 0x30, 0x43))
|
||||
n_handle = sum(1 for y in range(h - 10, h) for x in range(0, w - 12, 2)
|
||||
if px_at(x, y) == (0xd0, 0xd0, 0xd0))
|
||||
print(f" 测量:corner 8x8 内 #fbfcfe 像素 = {n_bg},亮白(255,255,255)像素 = {n_white},"
|
||||
f"文本色像素 = {n_text},handle色像素 = {n_handle}", flush=True)
|
||||
check("S5 健全性:样式已作用于该框(文本色出现)", n_text > 0, f"n_text={n_text}")
|
||||
check("S5b 健全性:handle #d0d0d0 出现在横滚动条带", n_handle > 0, f"n_handle={n_handle}")
|
||||
check("S5c 交汇角渲染出 #fbfcfe(= 代码框背景,::corner 规则生效)",
|
||||
n_bg >= 1, f"n_bg={n_bg}")
|
||||
check("S5d 交汇角无原生亮白方块(255,255,255)", n_white == 0, f"n_white={n_white}")
|
||||
img.save(os.path.join(OUT_DIR, "p2-02-outbox-render.png"))
|
||||
|
||||
# ---- S6 无泄漏:未命名 QPlainTextEdit 仍为原生口径 ----
|
||||
probe = QPlainTextEdit()
|
||||
probe.setPlainText("Y" * 3000)
|
||||
probe.resize(200, 120)
|
||||
probe.show()
|
||||
settle(150)
|
||||
psb = probe.horizontalScrollBar()
|
||||
probe_sb_extent = arrow_extent(psb, Qt.Orientation.Horizontal)
|
||||
check("S6 未命名代码框横滚动条非面板口径(原生厚≠8 或 有箭头)",
|
||||
(psb.height() != 8) or probe_sb_extent > 0,
|
||||
f"h={psb.height()} extent={probe_sb_extent}")
|
||||
probe.close()
|
||||
|
||||
# ---- S7 无泄漏:附件预览滚动条保持自身 6px 口径 ----
|
||||
att_sb = window.attachment_scroll_area.horizontalScrollBar()
|
||||
att_hint_h = att_sb.sizeHint().height()
|
||||
check("S7 附件预览横滚动条保持 6px(自身 QSS 未被面板规则覆盖)",
|
||||
att_hint_h == 6, f"sizeHint.h={att_hint_h}")
|
||||
|
||||
# ---- 截图(局部:面板全貌 + 代码框角落放大 4x)----
|
||||
# 注意:offscreen 下 ob.grab() 的文档区不填充(黑图),角落放大图从 S5 的
|
||||
# render 图(样式子控件已正常渲染)裁出,才有证据价值
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
panel_path = os.path.join(OUT_DIR, "p2-02-panel.png")
|
||||
panel.grab().save(panel_path)
|
||||
crop = img.copy(max(0, w - 60), max(0, h - 60), 60, 60)
|
||||
scaled = crop.scaled(240, 240,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.FastTransformation)
|
||||
corner_path = os.path.join(OUT_DIR, "p2-02-codebox-corner-4x.png")
|
||||
scaled.save(corner_path)
|
||||
print(f" 截图:{panel_path}", flush=True)
|
||||
print(f" 截图:{corner_path}(代码框右下角 60x60 → 4x)", flush=True)
|
||||
|
||||
# ---- 收尾测量汇总 ----
|
||||
print(f"\n 汇总:代码框 H={sbh.height()}px V={sbv.width()}px | "
|
||||
f"section V={sdv.width()}px | corner #fbfcfe 像素={n_bg} | "
|
||||
f"未命名框 H={psb.height()}px extent={probe_sb_extent} | 附件 H hint={att_hint_h}px",
|
||||
flush=True)
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
if os.path.exists(_DB_TMP):
|
||||
os.remove(_DB_TMP)
|
||||
if os.path.exists(_CFG_TMP):
|
||||
os.remove(_CFG_TMP)
|
||||
# offscreen 铁律:os._exit 强制收尾(QtWebEngine 子进程可能不回收)
|
||||
os._exit(0 if ok else 1)
|
||||
@@ -0,0 +1,262 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P2-03 结构+行为诊断:重命名遮罩 = 独立顶层透明窗(可盖住 WebView2 原生子窗)。
|
||||
|
||||
覆盖修复目标与硬约束:
|
||||
R1 结构:顶层窗(非 bg_widget 子控件)、Tool+无边框、WA_TranslucentBackground、WA_DeleteOnClose
|
||||
R2 几何:覆盖主窗口客户区(标题栏/窗口控制不被盖)、卡片居中、输入框初始全选
|
||||
R3 跟随:主窗口移动/缩放/窗口状态变化 → 遮罩同步(move/resize/WindowStateChange 事件过滤器)
|
||||
R4 行为:Enter 提交(renamed 信号→DB+侧栏)、Esc 关闭、点空白关闭、✕ 关闭、取消关闭
|
||||
R5 释放:关闭后顶层窗口消失、无残留(deleteLater + 事件过滤器卸载)
|
||||
R6 焦点(软检查,仅打印):关闭后焦点回主窗口
|
||||
|
||||
隔离 + offscreen;os._exit 收尾。
|
||||
用法: QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software .venv/Scripts/python.exe tests/diag_rename_overlay.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
|
||||
from tests._test_env import isolate
|
||||
isolate()
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
from PyQt6.QtTest import QTest
|
||||
from ui.views.main_window import MainWindow, RenameOverlay
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
def check(name, cond, info=""):
|
||||
ok = bool(cond)
|
||||
results.append((name, ok))
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name} {info}", flush=True)
|
||||
|
||||
|
||||
def settle(ms=120):
|
||||
t0 = time.time()
|
||||
while (time.time() - t0) * 1000 < ms:
|
||||
QtWidgets.QApplication.processEvents()
|
||||
time.sleep(0.01)
|
||||
# offscreen 无真实事件循环:processEvents 不处理 DeferredDelete,显式冲刷(生产中事件循环常驻,deleteLater 正常)
|
||||
QtCore.QCoreApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete)
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
|
||||
def find_overlay():
|
||||
for w in QtWidgets.QApplication.topLevelWidgets():
|
||||
if isinstance(w, RenameOverlay):
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.resize(1400, 800)
|
||||
window.show()
|
||||
settle(400)
|
||||
|
||||
sid = window.db.create_session("旧标题A")["id"]
|
||||
window.rebuild_sidebar()
|
||||
settle(150)
|
||||
|
||||
# ---------- 打开遮罩 ----------
|
||||
window._rename_session(sid)
|
||||
settle(250) # 含 150ms 入场动画
|
||||
ov = find_overlay()
|
||||
check("R0.1 overlay 已创建且为顶层窗口", ov is not None,
|
||||
"" if ov is not None else "topLevelWidgets 中找不到 RenameOverlay")
|
||||
if ov is None:
|
||||
print(f"\n{'='*60}\nRESULT: 1 FAIL -> FAIL\n{'='*60}", flush=True)
|
||||
os._exit(1)
|
||||
|
||||
# ---------- R1 结构 ----------
|
||||
print("R1 结构(独立顶层透明窗)", flush=True)
|
||||
check("R1.1 isWindow()", ov.isWindow())
|
||||
check("R1.2 自身即顶层窗口(非 bg_widget 内嵌子控件)", ov.window() is ov,
|
||||
f"window() is ov={ov.window() is ov}")
|
||||
check("R1.3 非主窗口自身", ov is not window)
|
||||
check("R1.4 Tool 窗(不入任务栏)", bool(ov.windowFlags() & QtCore.Qt.WindowType.Tool))
|
||||
check("R1.5 无边框", bool(ov.windowFlags() & QtCore.Qt.WindowType.FramelessWindowHint))
|
||||
check("R1.6 WA_TranslucentBackground", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
|
||||
check("R1.7 WA_DeleteOnClose", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose))
|
||||
check("R1.8 主窗口未设 WA_TranslucentBackground(保持原生不透明底)",
|
||||
not window.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
|
||||
|
||||
# ---------- R2 几何 ----------
|
||||
print("R2 几何(覆盖客户区,标题栏可操作)", flush=True)
|
||||
exp_tl = window.mapToGlobal(window.rect().topLeft())
|
||||
exp_size = window.rect().size()
|
||||
g = ov.geometry()
|
||||
check("R2.1 覆盖客户区左上角", abs(g.left() - exp_tl.x()) <= 2 and abs(g.top() - exp_tl.y()) <= 2,
|
||||
f"overlay=({g.left()},{g.top()}) expect=({exp_tl.x()},{exp_tl.y()})")
|
||||
check("R2.2 覆盖客户区尺寸", abs(g.width() - exp_size.width()) <= 2 and abs(g.height() - exp_size.height()) <= 2,
|
||||
f"overlay={g.width()}x{g.height()} expect={exp_size.width()}x{exp_size.height()}")
|
||||
fx, fy = (g.width() - ov.form.width()) // 2, (g.height() - ov.form.height()) // 2
|
||||
check("R2.3 卡片居中", abs(ov.form.x() - fx) <= 2 and abs(ov.form.y() - fy) <= 2,
|
||||
f"form=({ov.form.x()},{ov.form.y()}) expect=({fx},{fy})")
|
||||
check("R2.4 输入框初始全选", ov.input.hasSelectedText(), f"text={ov.input.text()!r}")
|
||||
check("R2.5 输入框预填旧标题", ov.input.text() == "旧标题A", f"text={ov.input.text()!r}")
|
||||
|
||||
# ---------- R3 跟随 ----------
|
||||
print("R3 跟随(move/resize/状态变化)", flush=True)
|
||||
base = window.pos()
|
||||
window.move(base.x() + 150, base.y() + 80)
|
||||
settle(150)
|
||||
exp_tl2 = window.mapToGlobal(window.rect().topLeft())
|
||||
check("R3.1 主窗口移动→遮罩跟随", abs(ov.geometry().left() - exp_tl2.x()) <= 2
|
||||
and abs(ov.geometry().top() - exp_tl2.y()) <= 2,
|
||||
f"overlay_tl=({ov.geometry().left()},{ov.geometry().top()}) expect=({exp_tl2.x()},{exp_tl2.y()})")
|
||||
|
||||
window.resize(1200, 700)
|
||||
settle(150)
|
||||
g2 = ov.geometry()
|
||||
check("R3.2 主窗口缩放→遮罩同步尺寸",
|
||||
abs(g2.width() - 1200) <= 4 and abs(g2.height() - 700) <= 4,
|
||||
f"overlay={g2.width()}x{g2.height()} expect=1200x700")
|
||||
fx2 = (g2.width() - ov.form.width()) // 2
|
||||
check("R3.3 缩放后卡片重新居中", abs(ov.form.x() - fx2) <= 2, f"form.x={ov.form.x()} expect={fx2}")
|
||||
|
||||
# WindowStateChange 分支(最大化/还原走同一条 _sync_geometry 路径;offscreen 直接投递事件验证分支)
|
||||
before = ov.geometry()
|
||||
QtWidgets.QApplication.sendEvent(window, QtCore.QEvent(QtCore.QEvent.Type.WindowStateChange))
|
||||
QtWidgets.QApplication.processEvents()
|
||||
check("R3.4 WindowStateChange 分支不崩溃且几何仍正确",
|
||||
abs(ov.geometry().width() - 1200) <= 4 and ov is find_overlay(),
|
||||
f"geometry={ov.geometry().width()}x{ov.geometry().height()}")
|
||||
|
||||
# ---------- R4 行为 ----------
|
||||
print("R4 行为", flush=True)
|
||||
# R4.1 Enter 提交 → renamed 信号 → DB + 侧栏
|
||||
ov.input.setText("P203新会话名")
|
||||
ov.confirm()
|
||||
settle(250)
|
||||
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||
check("R4.1a confirm 后 DB 标题已更新", sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||
side_ok = False
|
||||
for i in range(window.history_list.count()):
|
||||
it = window.history_list.item(i)
|
||||
if it and it.data(QtCore.Qt.ItemDataRole.UserRole) == sid:
|
||||
w = window.history_list.itemWidget(it)
|
||||
if w and hasattr(w, "title_label") and w.title_label.text() == "P203新会话名":
|
||||
side_ok = True
|
||||
check("R4.1b confirm 后侧栏标题已更新", side_ok)
|
||||
check("R4.1c 提交后 overlay 已从顶层窗口消失", find_overlay() is None)
|
||||
|
||||
# R4.2 空标题 confirm 不提交(只关闭)
|
||||
window._rename_session(sid)
|
||||
settle(200)
|
||||
ovz = find_overlay()
|
||||
if ovz:
|
||||
ovz.input.clear()
|
||||
ovz.confirm()
|
||||
settle(200)
|
||||
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||
check("R4.2 空标题 confirm 不改 DB 标题",
|
||||
sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||
|
||||
check("R4.2b 空标题确认后 overlay 已消失", find_overlay() is None)
|
||||
|
||||
# R4.3 Esc 关闭
|
||||
window._rename_session(sid)
|
||||
settle(200)
|
||||
ov2 = find_overlay()
|
||||
check("R4.3a 重开 overlay", ov2 is not None)
|
||||
if ov2:
|
||||
QtWidgets.QApplication.sendEvent(
|
||||
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, QtCore.Qt.Key.Key_Escape,
|
||||
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
|
||||
QtWidgets.QApplication.sendEvent(
|
||||
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyRelease, QtCore.Qt.Key.Key_Escape,
|
||||
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
|
||||
QtWidgets.QApplication.processEvents()
|
||||
settle(200)
|
||||
check("R4.3b Esc 关闭", find_overlay() is None)
|
||||
|
||||
# R4.4 点空白关闭
|
||||
window._rename_session(sid)
|
||||
settle(200)
|
||||
ov3 = find_overlay()
|
||||
check("R4.4a 重开 overlay", ov3 is not None)
|
||||
if ov3:
|
||||
QTest.mouseClick(ov3, QtCore.Qt.MouseButton.LeftButton,
|
||||
QtCore.Qt.KeyboardModifier.NoModifier, QtCore.QPoint(10, 10))
|
||||
settle(200)
|
||||
check("R4.4b 点空白关闭", find_overlay() is None)
|
||||
|
||||
# R4.5 ✕ 关闭
|
||||
window._rename_session(sid)
|
||||
settle(200)
|
||||
ov4 = find_overlay()
|
||||
check("R4.5a 重开 overlay", ov4 is not None)
|
||||
if ov4:
|
||||
btn_x = None
|
||||
for b in ov4.form.findChildren(QtWidgets.QPushButton):
|
||||
if b.text() == "✕":
|
||||
btn_x = b
|
||||
break
|
||||
check("R4.5b ✕ 按钮存在", btn_x is not None)
|
||||
if btn_x:
|
||||
btn_x.click()
|
||||
settle(200)
|
||||
check("R4.5c ✕ 关闭", find_overlay() is None)
|
||||
|
||||
# R4.6 取消按钮关闭
|
||||
window._rename_session(sid)
|
||||
settle(200)
|
||||
ov5 = find_overlay()
|
||||
if ov5:
|
||||
for b in ov5.form.findChildren(QtWidgets.QPushButton):
|
||||
if b.text() == "取消":
|
||||
b.click()
|
||||
break
|
||||
settle(200)
|
||||
check("R4.6 取消按钮关闭", find_overlay() is None)
|
||||
|
||||
# R4.7 非确认关闭(Esc/空白/✕/取消)均不改标题
|
||||
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||
check("R4.7 非确认关闭不改 DB 标题",
|
||||
sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||
|
||||
# ---------- R5 释放 ----------
|
||||
print("R5 释放(无残留)", flush=True)
|
||||
check("R5.1 无残留 rename_form 顶层窗口",
|
||||
not [w for w in QtWidgets.QApplication.topLevelWidgets()
|
||||
if w.findChild(QtWidgets.QFrame, "rename_form") is not None])
|
||||
check("R5.2 主窗口仍存活可用", window.isVisible() and window.db.get_all_sessions() is not None)
|
||||
|
||||
# ---------- R6 焦点(软检查) ----------
|
||||
print("R6 焦点回主窗口(软检查)", flush=True)
|
||||
window.activateWindow()
|
||||
settle(200)
|
||||
print(f" [INFO] window.isActiveWindow()={window.isActiveWindow()} "
|
||||
f"(offscreen 下不可靠,仅记录;真实机器人工走查确认)", flush=True)
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
ok = all(o for _, o in results)
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"RESULT: {sum(o for _, o in results)}/{len(results)} -> {'ALL PASS' if ok else 'FAIL'}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
sys.stdout.flush()
|
||||
os._exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except BaseException:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
os._exit(1)
|
||||
@@ -0,0 +1,336 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P1-01 渲染窗口 400 条消息规模诊断(offscreen;临时 DB + 临时配置;不启动真实 LLM)
|
||||
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/diag_render_scale.py [N]
|
||||
N 默认 400(位置参数可覆盖)
|
||||
|
||||
验证项(manual 模式保证确定性;末尾追加 auto 模式抽查):
|
||||
1. 初始窗口:.message-wrapper == min(N, size) 且 ≤ size(默认 40);
|
||||
窗口 = 最新 size 条;load-older 可见、load-newer 隐藏;
|
||||
2. 向上分页至头部:每页锚点误差 ≤ 2px(绝对顶部例外:scrollTop==0 且露出新页);
|
||||
全程窗口 ≤ size;到达头部后 hasMoreOlder=false;
|
||||
全部 N 条消息可达(各步渲染 id 并集 == 链 id 集),无重复 id;
|
||||
3. 自头部向下回翻 2 页:锚点稳定(≤2px);
|
||||
4. 规模报告:DOM 节点总数、页面高度、每页耗时采样(ms);
|
||||
5. auto 模式抽查:切 auto + 滚到顶部 → 自动补页发生。
|
||||
|
||||
调试铁律:本脚本自带总超时(QTimer 300s),bash 侧以 timeout=360 运行。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
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"
|
||||
|
||||
# 铁律:临时 DB + 临时配置(含 render_window_mode/size),先于任何 UI import
|
||||
from tests._test_env import isolate, default_config # noqa: E402
|
||||
|
||||
N = int(sys.argv[1]) if len(sys.argv) > 1 else 400
|
||||
_SIZE = 40
|
||||
_PAGE = _SIZE // 2 # 页大小 = 半窗(与 main_window._rw_page_size 一致)
|
||||
_cfg = dict(default_config())
|
||||
_cfg["render_window_mode"] = "manual" # 确定性诊断;auto 行为末尾单独抽查
|
||||
_cfg["render_window_size"] = _SIZE
|
||||
_env = isolate("render_scale", config=_cfg)
|
||||
|
||||
import core.db_manager as _dbm # noqa: E402
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
|
||||
# ---------- 造数:N 条链(含附件用户消息 / assistant 时间线 / 一个分支兄弟) ----------
|
||||
db = DBManager()
|
||||
sess = db.create_session(title=f"P1-01 scale {N}")
|
||||
sid = sess["id"]
|
||||
parent = None
|
||||
chain_ids = []
|
||||
for i in range(N):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
content = f"消息 {i} —— " + "这是一段用于撑起 DOM 高度的填充段落。" * 6
|
||||
atts = None
|
||||
if role == "user" and i % 50 == 0:
|
||||
atts = json.dumps({"user_text": content, "attachments": [
|
||||
{"type": "pdf", "mode": "text", "name": f"spec_{i}.pdf",
|
||||
"size_kb": 12, "pages": 3, "lines": 40, "content": "文本附件内容"}
|
||||
]}, ensure_ascii=False)
|
||||
timeline = None
|
||||
if role == "assistant" and i % 75 == 3:
|
||||
timeline = json.dumps([
|
||||
{"t": "think", "text": f"思考片段 {i}"},
|
||||
{"t": "text", "text": f"时间线正文 {i}"},
|
||||
], ensure_ascii=False)
|
||||
msg_id = f"scale-{i}"
|
||||
db.add_message(session_id=sid, role=role, content=content, parent_id=parent,
|
||||
msg_id=msg_id, attachment_metadata=atts, timeline=timeline)
|
||||
parent = msg_id
|
||||
chain_ids.append(msg_id)
|
||||
# 分支兄弟:必须插在链中间(add_message 会把新消息设为叶子,
|
||||
# 若放在主链之后会抢走叶子、截断可见链)
|
||||
if i == 298 and N > 300:
|
||||
db.add_message(session_id=sid, role="assistant", content="分支兄弟回复",
|
||||
parent_id=parent, msg_id="scale-branch-sib")
|
||||
db.mark_session_has_messages(sid)
|
||||
|
||||
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 = []
|
||||
|
||||
|
||||
def check(name, fn):
|
||||
try:
|
||||
fn()
|
||||
print(f" PASS {name}")
|
||||
results.append(True)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f" FAIL {name}: {e}")
|
||||
results.append(False)
|
||||
|
||||
|
||||
window = None
|
||||
js = {"done": False}
|
||||
|
||||
|
||||
def _run_js(code, timeout_s=15):
|
||||
result = {"val": None, "done": False}
|
||||
|
||||
def on_ret(val):
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
val = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
result["val"] = val
|
||||
result["done"] = True
|
||||
|
||||
if hasattr(window.browser, "execute_js_async"):
|
||||
window.browser.execute_js_async(code, on_ret)
|
||||
else:
|
||||
window.browser.page().runJavaScript(code, on_ret)
|
||||
t0 = time.time()
|
||||
while not result["done"] and time.time() - t0 < timeout_s:
|
||||
app.processEvents()
|
||||
time.sleep(0.02)
|
||||
assert result["done"], f"JS 执行超时: {code[:60]}"
|
||||
return result["val"]
|
||||
|
||||
|
||||
def wait_until(cond_js, timeout_s=30, desc=""):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout_s:
|
||||
if _run_js(cond_js, timeout_s=5):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"等待超时: {desc}")
|
||||
|
||||
|
||||
page_metrics = {"pages": [], "heights": [], "dom_nodes": []}
|
||||
|
||||
|
||||
def try_load():
|
||||
global window
|
||||
window = MainWindow()
|
||||
# offscreen 零视口(innerHeight=0)→ 给真实尺寸,滚动/锚点几何才有效
|
||||
window.resize(1400, 950)
|
||||
window.show()
|
||||
for _ in range(10):
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
# 等真实视口
|
||||
wait_until("window.innerHeight > 0 && window.innerWidth > 0", 30, "视口尺寸")
|
||||
# 等 JS 就绪 + 首轮(可能零视口)渲染完成
|
||||
wait_until("window.jsReady === true", 30, "jsReady")
|
||||
wait_until(f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
|
||||
60, "首轮渲染")
|
||||
# 真实视口下走完整 load_messages_to_web 路径重新窗口化加载
|
||||
window.load_messages_to_web(window.current_session_id, show_loading=False)
|
||||
wait_until(f"rwState !== null && rwState.generation >= 2 && "
|
||||
f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
|
||||
60, "视口重载")
|
||||
wait_until("(window.__rwPageRendering === false && document.getElementById('load-older') !== null) ? 1 : 0",
|
||||
10, "初始窗口收尾")
|
||||
run_checks()
|
||||
|
||||
|
||||
def run_checks():
|
||||
sel = "#chat-container .message-wrapper"
|
||||
|
||||
# ---- 1) 初始窗口 ----
|
||||
def initial():
|
||||
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
|
||||
assert cnt == _SIZE, f"初始窗口 {cnt} != {_SIZE}"
|
||||
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
|
||||
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer, "
|
||||
"mode: rwState.mode, size: rwState.size})")
|
||||
assert st["mode"] == "manual" and st["size"] == _SIZE, st
|
||||
assert st["len"] == _SIZE and st["hidO"] == N - _SIZE, st
|
||||
assert st["newer"] is False and st["older"] is True, st
|
||||
assert _run_js("document.getElementById('load-older').hidden") is False
|
||||
assert _run_js("document.getElementById('load-newer').hidden") is True
|
||||
# 窗口 = 最新 size 条
|
||||
first = _run_js(f"document.querySelector('{sel}').id")
|
||||
assert first == chain_ids[N - _SIZE], first
|
||||
# 初始贴底
|
||||
assert _run_js("isNearBottom()"), "初始窗口未对齐底部"
|
||||
|
||||
check(f"初始窗口 = 最新 {_SIZE} 条(链长 {N})", initial)
|
||||
|
||||
# ---- 2a) 中部锚点保持(真实"窗口中部换页"路径,误差 ≤2px) ----
|
||||
seen = set(chain_ids[-_SIZE:])
|
||||
anchor_errors = []
|
||||
|
||||
def page_older_once():
|
||||
"""执行一次向上换页(调用方保证视口位置);断言窗口上限与锚点。"""
|
||||
before = _run_js("JSON.stringify(rwCaptureAnchor() || {})")
|
||||
before = json.loads(before) if isinstance(before, str) else (before or {})
|
||||
scroll_before = _run_js("window.scrollY")
|
||||
t0 = time.time()
|
||||
_run_js("rwRequestPage('older')")
|
||||
# 等待 pending 消费 + 批次渲染收尾
|
||||
wait_until("rwState.pending === null && window.__rwPageRendering === false",
|
||||
30, "换页完成")
|
||||
dt = (time.time() - t0) * 1000
|
||||
page_metrics["pages"].append(dt)
|
||||
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
|
||||
assert cnt <= _SIZE, f"窗口超限 {cnt}"
|
||||
if before.get("msgId"):
|
||||
el = _run_js(f"document.getElementById({json.dumps(before['msgId'])}) ? "
|
||||
f"document.getElementById({json.dumps(before['msgId'])}).getBoundingClientRect().top + window.scrollY : null")
|
||||
assert el is not None, f"锚点消息被裁剪: {before['msgId']}"
|
||||
if scroll_before <= 1:
|
||||
# 绝对顶部:停在 0 露出新页(需求定义的例外:不做锚点断言)
|
||||
assert _run_js("window.scrollY") <= 1, "绝对顶部未保持"
|
||||
anchor_errors.append(0.0)
|
||||
else:
|
||||
expect = scroll_before + (el - before["docTop"])
|
||||
actual = _run_js("window.scrollY")
|
||||
err = abs(expect - actual)
|
||||
anchor_errors.append(err)
|
||||
assert err <= 2.0, f"锚点误差 {err}px (expect={expect} actual={actual})"
|
||||
else:
|
||||
anchor_errors.append(0.0)
|
||||
ids_now = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
|
||||
for i in ids_now:
|
||||
seen.add(i)
|
||||
page_metrics["dom_nodes"].append(_run_js("document.querySelectorAll('body *').length"))
|
||||
page_metrics["heights"].append(_run_js("document.body.scrollHeight"))
|
||||
|
||||
def anchor_middle():
|
||||
# 滚到窗口中部(35% 处):既非绝对顶部也非贴底 → 走完整锚点数学
|
||||
_run_js("window.scrollTo(0, Math.max(1, document.body.scrollHeight * 0.15)); 1")
|
||||
time.sleep(0.2)
|
||||
app.processEvents()
|
||||
scroll_before = _run_js("window.scrollY")
|
||||
assert scroll_before > 10, f"未到中部: {scroll_before}"
|
||||
assert not _run_js("isNearBottom()"), "中部位置不应贴底(防振荡前置条件)"
|
||||
page_older_once()
|
||||
st = _run_js("JSON.stringify({hidO: rwState.hiddenOlder, hidN: rwState.hiddenNewer})")
|
||||
assert st["hidN"] > 0, f"向上换页后应裁出较新隐藏区(无振荡): {st}"
|
||||
assert st["hidO"] == N - _SIZE - _PAGE, f"窗口应上移 _PAGE 条: {st}"
|
||||
|
||||
check("中部锚点保持(误差≤2px,无 newer 振荡)", anchor_middle)
|
||||
|
||||
# ---- 2b) 自顶部循环向上分页至头部(真实"load older"点击路径) ----
|
||||
def page_up_to_head():
|
||||
_run_js("window.scrollTo(0, 0); 1")
|
||||
time.sleep(0.2)
|
||||
app.processEvents()
|
||||
steps = 0
|
||||
while True:
|
||||
if not _run_js("rwState.hasMoreOlder"):
|
||||
break
|
||||
page_older_once()
|
||||
steps += 1
|
||||
assert steps <= N // _SIZE + 10, "换页次数异常(疑似死循环)"
|
||||
first = _run_js(f"document.querySelector('{sel}').id")
|
||||
assert first == chain_ids[0], f"未到达头部: {first}"
|
||||
|
||||
check("自顶部向上分页至头部(绝对顶部例外)", page_up_to_head)
|
||||
|
||||
def head_state():
|
||||
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
|
||||
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer})")
|
||||
assert st["hidO"] == 0 and st["older"] is False, st
|
||||
assert st["newer"] is True and st["hidN"] == N - _SIZE, st
|
||||
# 全链可达 + 无重复
|
||||
all_ids = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
|
||||
assert len(all_ids) == len(set(all_ids)), "DOM 出现重复消息 id"
|
||||
assert len(seen) == N, f"可达 {len(seen)}/{N} 条"
|
||||
|
||||
check("头部状态 + 全链可达无重复", head_state)
|
||||
|
||||
# ---- 3) 自顶部向下回翻 2 页(窗口下移,视口留在顶部) ----
|
||||
def page_down_two():
|
||||
for k in range(2):
|
||||
_run_js("window.scrollTo(0, 0); 1")
|
||||
time.sleep(0.15)
|
||||
app.processEvents()
|
||||
_run_js("rwRequestPage('newer')")
|
||||
wait_until("rwState.pending === null && window.__rwPageRendering === false",
|
||||
30, f"向下换页{k}完成")
|
||||
# 顶部回翻:锚点(最旧一条)随 trimHead 移除 → 视口留在顶部露出新页
|
||||
assert _run_js("window.scrollY") <= 1, "顶部回翻应留在顶部"
|
||||
first = _run_js(f"document.querySelector('{sel}').id")
|
||||
assert first == chain_ids[_PAGE * (k + 1)], f"窗口未下移: {first}"
|
||||
st = _run_js("rwState.hasMoreOlder")
|
||||
assert st is True, "回翻后应仍有更旧消息"
|
||||
|
||||
check("自顶部向下回翻 2 页(窗口下移)", page_down_two)
|
||||
|
||||
# ---- 5) auto 模式抽查:顶部自动补页 ----
|
||||
def auto_probe():
|
||||
_run_js("window.scrollTo(0, 0); 1")
|
||||
time.sleep(0.15)
|
||||
app.processEvents()
|
||||
_run_js("rwState.mode = 'auto'; 1")
|
||||
hid_before = _run_js("rwState.hiddenOlder")
|
||||
_run_js("rwAutoCheck(); 1") # 直接触发自动补页判定(事件接线另行覆盖)
|
||||
# 给自动补页 12s(每页渲染约 0.5-2s)
|
||||
t0 = time.time()
|
||||
moved = False
|
||||
while time.time() - t0 < 12:
|
||||
app.processEvents()
|
||||
time.sleep(0.2)
|
||||
if _run_js("rwState.hiddenOlder") < hid_before:
|
||||
moved = True
|
||||
break
|
||||
assert moved, "auto 模式顶部未自动补页"
|
||||
|
||||
check("auto 模式:顶部自动补页", auto_probe)
|
||||
|
||||
# ---- 4) 规模报告 ----
|
||||
nodes = page_metrics["dom_nodes"]
|
||||
heights = page_metrics["heights"]
|
||||
pages_ms = page_metrics["pages"]
|
||||
print("\n================ 规模报告 ================")
|
||||
print(f"链长 N={N} | 窗口 size={_SIZE}")
|
||||
print(f"换页数={len(pages_ms)} | 每页耗时 min/avg/max = "
|
||||
f"{min(pages_ms):.0f}/{(sum(pages_ms) / len(pages_ms)):.0f}/{max(pages_ms):.0f} ms")
|
||||
print(f"DOM 节点总数 min/max = {min(nodes)}/{max(nodes)}")
|
||||
print(f"页面高度 min/max = {min(heights)}/{max(heights)} px")
|
||||
if anchor_errors:
|
||||
print(f"锚点误差 min/max = {min(anchor_errors):.2f}/{max(anchor_errors):.2f} px")
|
||||
print("==========================================")
|
||||
|
||||
|
||||
def finish(ok=None):
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, try_load)
|
||||
QTimer.singleShot(300000, lambda: (print("[超时] 300s 总超时"), finish(False)))
|
||||
app.exec()
|
||||
try:
|
||||
window.close()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
|
||||
# QtWebEngine 退出段错误规避:汇总已打印,直接退出
|
||||
os._exit(0 if all(results) else 1)
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""公式渲染验收消息注入:新建「公式渲染验收」会话,写入用户原始样本 + 正/负例
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/inject_math_demo.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
|
||||
USER_MSG = """帮我看看这个推导,公式渲染对不对?
|
||||
|
||||
上面是推导结果:
|
||||
[
|
||||
P_4=\\operatorname{BRF}(M_4,M_5)
|
||||
]
|
||||
|
||||
[
|
||||
P_3=\\operatorname{BRF}(S_3,P_4)
|
||||
]
|
||||
|
||||
其中:
|
||||
[
|
||||
\\operatorname{BRF}(L,H)
|
||||
L+
|
||||
\\Gamma(L,\\operatorname{Up}(H))
|
||||
\\odot
|
||||
\\Phi(\\operatorname{Up}(H))
|
||||
]
|
||||
"""
|
||||
|
||||
ASSISTANT_MSG = r"""## 公式渲染验收
|
||||
|
||||
### 1) 你原始样本(单括号块,应渲染为独立居中公式)
|
||||
|
||||
上面是推导结果:
|
||||
[
|
||||
P_4=\operatorname{BRF}(M_4,M_5)
|
||||
]
|
||||
|
||||
[
|
||||
P_3=\operatorname{BRF}(S_3,P_4)
|
||||
]
|
||||
|
||||
其中:
|
||||
[
|
||||
\operatorname{BRF}(L,H)
|
||||
L+
|
||||
\Gamma(L,\operatorname{Up}(H))
|
||||
\odot
|
||||
\Phi(\operatorname{Up}(H))
|
||||
]
|
||||
|
||||
### 2) 标准定界符
|
||||
|
||||
行内混合:能量公式 $E=mc^2$ 出现在句子中间;再来一个 $x_i^2 + y_j^2 = z_{ij}^2$。
|
||||
|
||||
行内括号形式:\(\alpha + \beta = \gamma\)
|
||||
|
||||
双美元块:
|
||||
$$
|
||||
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
|
||||
$$
|
||||
|
||||
方括号块:
|
||||
\[
|
||||
\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}
|
||||
\]
|
||||
|
||||
带矩阵与希腊字母:
|
||||
[
|
||||
\begin{pmatrix} a & b \\ c & d \end{pmatrix}
|
||||
\begin{pmatrix} x \\ y \end{pmatrix}
|
||||
=
|
||||
\begin{pmatrix} ax+by \\ cx+dy \end{pmatrix}
|
||||
]
|
||||
|
||||
### 3) 反例(不应渲染成公式)
|
||||
|
||||
- 编号引用:见[1]和[2]的说明。
|
||||
- 链接:[KaTeX 官网](https://katex.org)
|
||||
- 列表内容:[a, b] 只是一个数组。
|
||||
- 货币:价格 $1,000 and $2,000 之间。
|
||||
- 代码块:
|
||||
|
||||
```python
|
||||
price = "$5"
|
||||
pattern = r"$x + y$"
|
||||
arr[0] = 1
|
||||
```
|
||||
|
||||
- 行内代码:使用 `$z$` 表示变量。
|
||||
|
||||
### 4) 复杂嵌套(流式增量渲染路径同样适用)
|
||||
|
||||
$$
|
||||
f(x) = \sum_{k=0}^{n} \binom{n}{k} x^k (1-x)^{n-k}
|
||||
$$
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
db = DBManager()
|
||||
sess = db.create_session("公式渲染验收")
|
||||
sid = sess["id"]
|
||||
parent = sess.get("current_leaf_msg_id")
|
||||
m1 = db.add_message(sid, "user", USER_MSG, parent_id=parent)
|
||||
db.add_message(sid, "assistant", ASSISTANT_MSG, parent_id=m1["id"])
|
||||
db.mark_session_has_messages(sid)
|
||||
print(f"已注入会话: {sid}")
|
||||
print(f"标题: 公式渲染验收")
|
||||
print(f"用户消息 {len(USER_MSG)}c / 助手消息 {len(ASSISTANT_MSG)}c")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P2-04 跨平台聚合测试入口(无 pytest / npm / 网络要求)。
|
||||
|
||||
用法:
|
||||
python tests/run_all.py --group logic
|
||||
python tests/run_all.py --group offscreen
|
||||
python tests/run_all.py --group all
|
||||
python tests/run_all.py --group all --list
|
||||
python tests/run_all.py --group all --keep-logs
|
||||
python tests/run_all.py --group logic --only test_tool_params
|
||||
|
||||
设计(对应 REPAIR_BACKLOG.md P2-04 硬约束):
|
||||
* 保留所有独立命令:每个条目就是 [解释器, 测试文件] 的普通子进程调用,
|
||||
与各测试文件头部的“运行:”命令等价(同一解释器、同一 cwd、同组环境)。
|
||||
* 聚合入口不要求 pytest / npm / 网络;缺 node 或第三方依赖的条目的
|
||||
呈现方式(SKIP + 理由),不伪装通过。
|
||||
* 每个子测试使用独立临时目录(Windows: TEMP/TMP;POSIX: TMPDIR),
|
||||
GUI 测试的临时配置/临时数据库由各测试自身在 import MainWindow 前
|
||||
通过 tests/_test_env.isolate() 完成(仓库既有惯例)。
|
||||
* 默认不运行 diag_* / verify_* / tune_* / 真实 API(smoke_live_*、
|
||||
diag_live_*)/ 真实桌面 / 需要凭据的脚本,也不运行使用真实 DB 的
|
||||
遗留脚本(smoke_persist、smoke_repro_real)。
|
||||
* 平台不适用项以明确 SKIP + 理由呈现;平台共同项失败 → 退出码非零。
|
||||
* 单个子测试崩溃或超时不阻止后续测试与最终汇总;退出码反映失败:
|
||||
0 = 无 FAIL/TIMEOUT(SKIP 不影响);1 = 存在 FAIL 或 TIMEOUT。
|
||||
* 不读取真实配置、不访问网络、不删除用户运行数据。
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PY = sys.executable # 用启动本入口的解释器跑所有 Python 子测试(与独立命令一致)
|
||||
NODE = shutil.which("node")
|
||||
|
||||
# 控制台编码安全化(与 main.py 同惯例):git-bash/GBK/cp1252 终端下
|
||||
# 聚合器自身的中文输出不得崩溃(子测试输出经 UTF-8 解码后同样安全打印)。
|
||||
for _name in ("stdout", "stderr"):
|
||||
_s = getattr(sys, _name, None)
|
||||
if _s is not None:
|
||||
try:
|
||||
_s.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试清单 / 分组元数据
|
||||
# (id, groups, argv, timeout_sec, needs, note)
|
||||
# needs 取值:
|
||||
# "node" —— 需要 node 可执行文件
|
||||
# "openai" —— 需要 openai 包(当前解释器)
|
||||
# "pymupdf" —— 需要 PyMuPDF (fitz)
|
||||
# "pyqt6" —— 需要 PyQt6(GUI/offscreen 条目)
|
||||
# ---------------------------------------------------------------------------
|
||||
JS = lambda f: [NODE, os.path.join(REPO, "tests", f)] if NODE else [None]
|
||||
PYT = lambda f: [PY, os.path.join(REPO, "tests", f)]
|
||||
|
||||
TESTS = [
|
||||
# ---- logic:纯逻辑 / 无 GUI(平台共同项,失败不得豁免)----
|
||||
("run_tests.py (agent core 41)", ("logic",), PYT("run_tests.py"), 300,
|
||||
("openai",), "core.agent 单测套件(既有 run_tests.py 入口,原样保留)"),
|
||||
("test_tool_params.py", ("logic",), PYT("test_tool_params.py"), 120,
|
||||
("openai",), "工具参数解析(import core.agent.tools → openai)"),
|
||||
("test_bash_stream.py", ("logic",), PYT("test_bash_stream.py"), 180,
|
||||
("openai",), "bash 流式 / 进程树(P1-02、P2-01)"),
|
||||
("test_copy_session.py", ("logic",), PYT("test_copy_session.py"), 180,
|
||||
(), "会话复制 DB 逻辑"),
|
||||
("test_compaction_persist.py", ("logic",), PYT("test_compaction_persist.py"), 180,
|
||||
("openai",), "压缩持久化"),
|
||||
("test_file_attach.py", ("logic",), PYT("test_file_attach.py"), 180,
|
||||
(), "附件链路(P1-04)"),
|
||||
("test_cross_platform_shell.py", ("logic",), PYT("test_cross_platform_shell.py"), 240,
|
||||
("openai",), "跨平台 shell 契约 + 进程树终止(P1-02)"),
|
||||
("test_global_hotkey_platforms.py", ("logic",), PYT("test_global_hotkey_platforms.py"), 240,
|
||||
("pyqt6",), "全局热键平台矩阵 mock(P1-04;含 QShortcut/overlay 构造)"),
|
||||
("test_wv2_guard.py", ("logic",), PYT("test_wv2_guard.py"), 240,
|
||||
("platform:win32",), "WebView2 守卫(T0 事故锁死;msvcrt 单实例互斥为 Windows 专属,Linux 无 WebView2 链路)"),
|
||||
("test_pdf_reader.py", ("logic",), PYT("test_pdf_reader.py"), 240,
|
||||
("pymupdf",), "PDF 提取"),
|
||||
("test_math_extract.js", ("logic",), JS("test_math_extract.js"), 120,
|
||||
("node",), "数学提取 JS 单测(P1-03)"),
|
||||
("test_render_window.js", ("logic",), JS("test_render_window.js"), 180,
|
||||
("node",), "渲染窗口状态机 JS 单测(P1-01)"),
|
||||
|
||||
# ---- offscreen:Qt GUI(QT_QPA_PLATFORM=offscreen 子环境)----
|
||||
("smoke_offscreen.py", ("offscreen",), PYT("smoke_offscreen.py"), 420,
|
||||
("pyqt6",), "主窗口 + 核心链路冒烟"),
|
||||
("smoke_mode.py", ("offscreen",), PYT("smoke_mode.py"), 420,
|
||||
("pyqt6",), "模式切换(P0-02)"),
|
||||
("smoke_copy_session.py", ("offscreen",), PYT("smoke_copy_session.py"), 420,
|
||||
("pyqt6",), "会话复制 UI 端到端"),
|
||||
("smoke_bash_panel.py", ("offscreen",), PYT("smoke_bash_panel.py"), 480,
|
||||
("pyqt6",), "右侧 Bash 面板端到端(P2-01、P2-02)"),
|
||||
("smoke_timeline.py", ("offscreen",), PYT("smoke_timeline.py"), 420,
|
||||
("pyqt6",), "时间线持久化/还原"),
|
||||
("smoke_midswitch.py", ("offscreen",), PYT("smoke_midswitch.py"), 420,
|
||||
("pyqt6",), "流式中途切会话(P1-01)"),
|
||||
("test_main_window_event_filter.py", ("offscreen",), PYT("test_main_window_event_filter.py"), 300,
|
||||
("pyqt6",), "MainWindow.eventFilter(P0-02)"),
|
||||
("test_config_isolation.py", ("offscreen",), PYT("test_config_isolation.py"), 300,
|
||||
("pyqt6",), "配置/DB 隔离铁律(P0-01)"),
|
||||
("test_error_persist.py", ("offscreen",), PYT("test_error_persist.py"), 300,
|
||||
("pyqt6",), "错误持久化"),
|
||||
("test_think_code_neutral.py", ("offscreen",), PYT("test_think_code_neutral.py"), 420,
|
||||
("pyqt6",), "思考/代码块渲染中立性"),
|
||||
("test_debug_window.py", ("offscreen",), PYT("test_debug_window.py"), 300,
|
||||
("pyqt6",), "调试窗口"),
|
||||
("test_renderer_matrix.py", ("offscreen",), PYT("test_renderer_matrix.py"), 600,
|
||||
("pyqt6",), "渲染器矩阵(P1-03,含子进程并行)"),
|
||||
("test_screen_capture_platforms.py", ("offscreen",), PYT("test_screen_capture_platforms.py"), 300,
|
||||
("pyqt6",), "截图平台矩阵 mock + overlay 守卫(P1-04)"),
|
||||
]
|
||||
|
||||
# 默认不聚合(人工/半自动/真实资源):diag_*、verify_*、tune_*、
|
||||
# smoke_live_guard.py、diag_live_*.py、smoke_manual.py、smoke_repro_real.py、
|
||||
# smoke_persist.py(真实 DB)、smoke_probe.py、_probe_*.py、
|
||||
# debug_inject.py、inject_math_demo.py、check_db_migration.py
|
||||
|
||||
|
||||
def _has_module(name):
|
||||
try:
|
||||
return importlib.util.find_spec(name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def skip_reason(entry):
|
||||
"""返回 SKIP 理由;None 表示可运行。"""
|
||||
_id, _groups, _argv, _to, needs, _note = entry
|
||||
for need in needs:
|
||||
if need.startswith("platform:"):
|
||||
want = need.split(":", 1)[1]
|
||||
if sys.platform != want:
|
||||
return f"平台不适用:该条目仅 {want} 路径适用(当前 {sys.platform})"
|
||||
continue
|
||||
if need == "node" and not NODE:
|
||||
return "node 不可用(聚合入口不要求 npm/node;安装 node 后自动纳入)"
|
||||
if need == "openai" and not _has_module("openai"):
|
||||
return "当前解释器无 openai 包(本环境不允许联网安装)"
|
||||
if need == "pymupdf" and not _has_module("fitz"):
|
||||
return "当前解释器无 PyMuPDF(本环境不允许联网安装)"
|
||||
if need == "pyqt6" and not _has_module("PyQt6"):
|
||||
return "当前解释器无 PyQt6(GUI 条目需在装有依赖的解释器运行)"
|
||||
return None
|
||||
|
||||
|
||||
def _child_env(extra_temp, offscreen):
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
if extra_temp:
|
||||
if os.name == "nt":
|
||||
env["TEMP"] = extra_temp
|
||||
env["TMP"] = extra_temp
|
||||
else:
|
||||
env["TMPDIR"] = extra_temp
|
||||
if offscreen:
|
||||
env.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
env.setdefault("HAOCODE_RENDER", "software")
|
||||
env.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
return env
|
||||
|
||||
|
||||
def run_group(group, keep_logs, only):
|
||||
entries = [e for e in TESTS if group in ("all",) or e[1][0] == group]
|
||||
if only:
|
||||
subs = [s.strip() for s in only.split(",") if s.strip()]
|
||||
entries = [e for e in entries if any(s in e[0] for s in subs)]
|
||||
if not entries:
|
||||
print("没有匹配的测试条目(--group/--only 组合为空)")
|
||||
return 1
|
||||
|
||||
run_dir = tempfile.mkdtemp(prefix="haocode_runall_")
|
||||
print("=" * 72)
|
||||
print("haocode 聚合测试入口 (P2-04)")
|
||||
print(f"platform={sys.platform} python={sys.version.split()[0]} ({PY})")
|
||||
print(f"group={group} entries={len(entries)} 开始={datetime.now():%H:%M:%S}")
|
||||
print("=" * 72)
|
||||
|
||||
results = [] # (id, status, seconds, note, argv)
|
||||
t_all = time.time()
|
||||
for entry in entries:
|
||||
tid, _groups, argv, budget, needs, note = entry
|
||||
off = "offscreen" in entry[1]
|
||||
reason = skip_reason(entry)
|
||||
if reason:
|
||||
results.append((tid, "SKIP", 0.0, reason, argv))
|
||||
print(f" SKIP {tid} [{reason}]")
|
||||
continue
|
||||
t0 = time.time()
|
||||
log_path = os.path.join(run_dir, tid.replace(" ", "_").replace("(", "").replace(")", "") + ".log")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="runall_t_") as td:
|
||||
proc = subprocess.run(
|
||||
argv, cwd=REPO, env=_child_env(td, off),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=budget)
|
||||
rc, err = proc.returncode, None
|
||||
out = proc.stdout
|
||||
except subprocess.TimeoutExpired as e:
|
||||
rc, out, err = 124, (e.stdout or b""), f"超时(预算 {budget}s)"
|
||||
except Exception as e: # 聚合器自身健壮性:单条崩溃不阻止汇总
|
||||
rc, out, err = 125, b"", f"聚合器异常: {e!r}"
|
||||
dt = max(0.0, time.time() - t0) # WSL/9P 时钟回拨保护
|
||||
status = "PASS" if rc == 0 else ("TIMEOUT" if rc == 124 else "FAIL")
|
||||
results.append((tid, status, dt, note, argv))
|
||||
tag = f" {status:<7} {dt:7.1f}s {tid}"
|
||||
print(tag, flush=True)
|
||||
# 非 PASS:打印最后 15 行帮助定位
|
||||
tail = (out or b"").decode("utf-8", "replace").splitlines()[-15:]
|
||||
if status != "PASS" and tail:
|
||||
for line in tail:
|
||||
print(f" | {line}")
|
||||
with open(log_path, "wb") as f:
|
||||
f.write(b"# argv: " + " ".join(str(a) for a in argv).encode() + b"\n")
|
||||
if err:
|
||||
f.write(f"# {err}\n".encode())
|
||||
f.write(out or b"")
|
||||
|
||||
dt_all = time.time() - t_all
|
||||
n_pass = sum(1 for r in results if r[1] == "PASS")
|
||||
n_fail = sum(1 for r in results if r[1] in ("FAIL", "TIMEOUT"))
|
||||
n_skip = sum(1 for r in results if r[1] == "SKIP")
|
||||
print("-" * 72)
|
||||
print(f"总计 {len(results)}: PASS {n_pass} FAIL {n_fail} SKIP {n_skip}"
|
||||
f" 总耗时 {dt_all:.1f}s")
|
||||
if keep_logs or n_fail:
|
||||
print(f"日志目录(保留): {run_dir}")
|
||||
else:
|
||||
shutil.rmtree(run_dir, ignore_errors=True)
|
||||
print("日志目录(无失败,已清理)")
|
||||
bad = [r for r in results if r[1] in ("FAIL", "TIMEOUT")]
|
||||
if bad:
|
||||
print("失败命令:")
|
||||
for tid, status, dt, _note, argv in bad:
|
||||
print(f" [{status}] {' '.join(str(a) for a in argv)}")
|
||||
print("=" * 72)
|
||||
return 1 if n_fail else 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="haocode 跨平台聚合测试入口(P2-04)")
|
||||
ap.add_argument("--group", choices=("logic", "offscreen", "all"), default="all",
|
||||
help="logic=纯逻辑; offscreen=Qt GUI offscreen; all=两者(默认)")
|
||||
ap.add_argument("--list", action="store_true", help="只列出条目,不执行")
|
||||
ap.add_argument("--keep-logs", action="store_true", help="保留全部子测试日志")
|
||||
ap.add_argument("--only", default="", help="只运行 id 含子串的条目(逗号分隔)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.list:
|
||||
for tid, groups, argv, budget, needs, note in TESTS:
|
||||
mark = "SKIP?" if skip_reason((tid, groups, argv, budget, needs, note)) else "run "
|
||||
print(f" [{mark}] {groups[0]:<10} {budget:>4}s {tid} {note}")
|
||||
return 0
|
||||
sys.exit(run_group(args.group, args.keep_logs, args.only))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""离线测试 harness(无 pytest 依赖):
|
||||
运行: conda run -n haocode python tests/run_tests.py
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"test_agent_core", os.path.join(os.path.dirname(__file__), "test_agent_core.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# 替换 pytest 依赖后加载
|
||||
import types
|
||||
_py_stub = types.ModuleType("pytest")
|
||||
sys.modules["pytest"] = _py_stub
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
tests = [(n, f) for n, f in sorted(vars(mod).items())
|
||||
if n.startswith("test_") and callable(f)]
|
||||
passed = failed = 0
|
||||
for name, fn in tests:
|
||||
try:
|
||||
fn()
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
except Exception:
|
||||
print(f" FAIL {name}")
|
||||
traceback.print_exc()
|
||||
failed += 1
|
||||
print(f"\n===== {passed} passed, {failed} failed / {len(tests)} =====")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,737 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""右侧任务面板 · offscreen 端到端
|
||||
链路:顶部按钮(原「导出」位置)→ BashPanel 展开/收起 → tool_execution_* 事件
|
||||
→ 层(运行中/已完成)→ 单击展开(参数 + 输出)→ 会话切换刷新
|
||||
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_bash_panel.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||
# (临时配置保留本套件的 mode_switch=true 语义)
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
_TMP = isolate("bashpanel", config={"providers": {}, "mode_switch": True})
|
||||
_DB_TMP = _TMP["db"]
|
||||
_CFG_TMP = _TMP["config"]
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtTest import QTest # noqa: E402
|
||||
from PyQt6 import QtCore, QtWidgets # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
from ui.views.bash_panel import BashLayer, LIVE_BUF_CAP, LAYER_LIMIT # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
def settle(ms=400):
|
||||
for _ in range(int(ms / 20) + 1):
|
||||
app.processEvents()
|
||||
QTest.qWait(20)
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
panel = window.bash_panel
|
||||
|
||||
|
||||
def px(widget, x, y):
|
||||
"""取控件自身坐标系下 (x,y) 的渲染像素"""
|
||||
img = widget.grab().toImage()
|
||||
c = img.pixelColor(x, y)
|
||||
return (c.red(), c.green(), c.blue())
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 1) 外壳:默认收起 52px、顶部按钮存在且尺寸与左侧收起按钮一致
|
||||
# ======================================================================
|
||||
check("P1.1 面板已创建并挂进主布局", panel is not None and panel.parent() is not None)
|
||||
check("P1.2 默认收起 52px", panel.width() == 52 and panel.collapsed is True,
|
||||
f"w={panel.width()} collapsed={panel.collapsed}")
|
||||
check("P1.3 「导出」按钮已移除", not hasattr(window, "btn_export"))
|
||||
# 🆕 开关只在右栏内部(与左侧栏一致),主界面顶部【不得】有任何面板按钮
|
||||
check("P1.4 主界面顶部【没有】面板开关按钮", not hasattr(window, "btn_panel"))
|
||||
|
||||
|
||||
def visible_toggles():
|
||||
"""全窗口可见的面板开关按钮(tooltip 含“任务面板”)"""
|
||||
return [b for b in window.findChildren(QtWidgets.QPushButton)
|
||||
if "任务面板" in (b.toolTip() or "") and b.isVisible()]
|
||||
|
||||
|
||||
check("P1.5 收起态:可见开关恒为 1 个", len(visible_toggles()) == 1, str(len(visible_toggles())))
|
||||
check("P1.6 收起态开关 = 面板内的 expand_btn(居中)",
|
||||
visible_toggles()[0] is panel.expand_btn)
|
||||
check("P1.7 expand_btn 34×34、icon 18×18(与左侧栏收起页按钮同规格)",
|
||||
panel.expand_btn.width() == 34 and panel.expand_btn.height() == 34
|
||||
and panel.expand_btn.iconSize() == QtCore.QSize(18, 18)
|
||||
and panel.expand_btn.width() == window.collapse_expand_btn.width(),
|
||||
f"{panel.expand_btn.width()}x{panel.expand_btn.height()} vs 左 {window.collapse_expand_btn.width()}")
|
||||
check("P1.8 expand_btn 与左侧按钮同 objectName(继承同一套 QSS)",
|
||||
panel.expand_btn.objectName() == window.collapse_expand_btn.objectName() == "collapse_btn")
|
||||
_x = panel.expand_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
|
||||
check("P1.9 expand_btn 水平居中于 52px 栏内",
|
||||
abs((_x + 34 / 2) - panel.width() / 2) <= 1.5, f"center={_x + 17} panel_half={panel.width()/2}")
|
||||
|
||||
# 展开(用栏内按钮,不用主界面按钮)
|
||||
panel.expand_btn.click()
|
||||
settle(500)
|
||||
check("P1.10 点击收起页按钮 → 展开到 260px", panel.width() == 260 and panel.collapsed is False,
|
||||
f"w={panel.width()} collapsed={panel.collapsed}")
|
||||
check("P1.11 展开态:可见开关仍恒为 1 个(不叠加)",
|
||||
len(visible_toggles()) == 1, str(len(visible_toggles())))
|
||||
check("P1.12 展开态开关 = 面板标题行右上角的 fold_btn",
|
||||
visible_toggles()[0] is panel.fold_btn)
|
||||
check("P1.13 fold_btn 28×28、icon 16×16(与左侧栏收起按钮同规格)",
|
||||
panel.fold_btn.width() == 28 and panel.fold_btn.height() == 28
|
||||
and panel.fold_btn.iconSize() == QtCore.QSize(16, 16)
|
||||
and panel.fold_btn.width() == window.collapse_btn.width(),
|
||||
f"{panel.fold_btn.width()}x{panel.fold_btn.height()} vs 左 {window.collapse_btn.width()}")
|
||||
check("P1.14 fold_btn 与左侧按钮同 objectName(同一套 QSS)",
|
||||
panel.fold_btn.objectName() == window.collapse_btn.objectName() == "collapse_btn")
|
||||
_fx = panel.fold_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
|
||||
check("P1.15 fold_btn 位于标题行右端(右侧留白 ≤14px)",
|
||||
panel.width() - (_fx + 28) <= 14, f"right_gap={panel.width() - (_fx + 28)}")
|
||||
check("P1.16 展开后两栏同时可见(不互斥)",
|
||||
(not panel.sec_running.isHidden()) and (not panel.sec_done.isHidden()),
|
||||
f"run_hidden={panel.sec_running.isHidden()} done_hidden={panel.sec_done.isHidden()}")
|
||||
check("P1.17 两栏为上下排列(分隔线可拖动)",
|
||||
panel.splitter.orientation() == QtCore.Qt.Orientation.Vertical)
|
||||
check("P1.18 默认两栏提示语",
|
||||
panel.sec_running.hint.text() == "暂无正在运行的 bash"
|
||||
and panel.sec_done.hint.text() == "本会话还没有已完成的 bash",
|
||||
f"{panel.sec_running.hint.text()!r} / {panel.sec_done.hint.text()!r}")
|
||||
# 🐛 已修:QWidget 子类样式表背景 + 底色/分隔线继承左侧
|
||||
check("P1.19 样式表背景已启用(WA_StyledBackground)",
|
||||
panel.testAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground))
|
||||
check("P1.20 面板底色 = 左侧栏底色 (#f7f8fa)",
|
||||
px(panel, 30, 400) == (0xf7, 0xf8, 0xfa), str(px(panel, 30, 400)))
|
||||
check("P1.21 面板左侧分隔线 = 左侧栏分隔线 (#ececec)",
|
||||
px(panel, 0, 400) == (0xec, 0xec, 0xec), str(px(panel, 0, 400)))
|
||||
check("P1.22 左侧栏对照:底色+分隔线",
|
||||
px(window.sidebar, 30, 400) == (0xf7, 0xf8, 0xfa)
|
||||
and px(window.sidebar, 259, 400) == (0xec, 0xec, 0xec),
|
||||
f"{px(window.sidebar, 30, 400)} {px(window.sidebar, 259, 400)}")
|
||||
check("P1.23 标题行与左侧栏标题行垂直对齐(y 相等、高 36)",
|
||||
panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()
|
||||
== window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()
|
||||
and panel.header.height() == window.sidebar_header.height() == 36,
|
||||
f"panel_y={panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()} "
|
||||
f"left_y={window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()}")
|
||||
|
||||
# ======================================================================
|
||||
# 2) 事件驱动:运行中 → 实时输出 → 读秒 → 完成归位
|
||||
# ======================================================================
|
||||
sid = window.current_session_id
|
||||
if not sid:
|
||||
window.on_new_chat_clicked()
|
||||
sid = window.current_session_id
|
||||
|
||||
window._on_tool_started(sid, "c1", "bash", json.dumps({"command": "echo hi"}, ensure_ascii=False))
|
||||
check("P2.1 bash 事件 → 运行中栏出现 1 层", panel.layer_ids("running") == ["c1"],
|
||||
str(panel.layer_ids("running")))
|
||||
check("P2.2 已完成栏仍为空", panel.layer_ids("done") == [], str(panel.layer_ids("done")))
|
||||
lay = panel._layers["c1"]
|
||||
check("P2.3 层头显示 bash + 运行中标签",
|
||||
lay.name.text() == "bash" and lay.tag.text() == "运行中", lay.tag.text())
|
||||
check("P2.4 层头显示命令预览", "echo hi" in lay.cmd.text(), lay.cmd.text())
|
||||
|
||||
window._on_tool_updated(sid, "c1", "hi\n")
|
||||
window._on_tool_updated(sid, "c1", "second\n")
|
||||
check("P2.5 实时输出被累积", lay._live == "hi\nsecond\n", repr(lay._live))
|
||||
window._on_tool_timed(sid, "c1", 3, 120)
|
||||
check("P2.6 读秒显示 3/120s", lay.meta.text() == "3/120s", lay.meta.text())
|
||||
|
||||
# 非 bash 工具不登记
|
||||
window._on_tool_started(sid, "c9", "read", json.dumps({"path": "x"}))
|
||||
check("P2.7 非 bash 工具不登记", "c9" not in panel._layers)
|
||||
|
||||
# 完成 → 归位到已完成栏
|
||||
final_text = "$ echo hi\nhi\n[exit 0] (0.1s)"
|
||||
window._on_tool_finished(sid, "c1", "bash", True, final_text)
|
||||
check("P2.8 完成后从运行中移除", panel.layer_ids("running") == [], str(panel.layer_ids("running")))
|
||||
check("P2.9 完成后进入已完成栏", panel.layer_ids("done") == ["c1"], str(panel.layer_ids("done")))
|
||||
check("P2.10 已完成标签 + 耗时(从 (0.1s) 解析)",
|
||||
lay.tag.text() == "已完成" and lay.meta.text() == "0.1s", f"{lay.tag.text()} {lay.meta.text()}")
|
||||
check("P2.11 已完成层输出 = 进入上下文的原文", lay._final == final_text)
|
||||
|
||||
# ======================================================================
|
||||
# 3) 单击展开 → 参数 + 输出
|
||||
# ======================================================================
|
||||
check("P3.1 初始为收起态", lay.expanded is False and lay.body.isHidden())
|
||||
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
|
||||
QtCore.Qt.KeyboardModifier.NoModifier,
|
||||
QtCore.QPoint(10, lay.head.height() // 2))
|
||||
settle(120)
|
||||
check("P3.2 单击(非双击)即展开", lay.expanded is True and not lay.body.isHidden())
|
||||
check("P3.3 参数区显示完整命令", lay.arg_box.toPlainText() == "echo hi",
|
||||
repr(lay.arg_box.toPlainText()))
|
||||
check("P3.4 输出区标题标明「进入上下文」", lay.lbl_out.text() == "输出(进入上下文)",
|
||||
lay.lbl_out.text())
|
||||
check("P3.5 输出区显示上下文原文", lay.out_box.toPlainText() == final_text)
|
||||
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
|
||||
QtCore.Qt.KeyboardModifier.NoModifier,
|
||||
QtCore.QPoint(10, lay.head.height() // 2))
|
||||
settle(120)
|
||||
check("P3.6 再点一次收起", lay.expanded is False and lay.body.isHidden())
|
||||
|
||||
# 运行中的层展开 → 实时输出
|
||||
window._on_tool_started(sid, "c2", "bash", json.dumps({"command": "ping -t x"}))
|
||||
window._on_tool_updated(sid, "c2", "line-A\n")
|
||||
lay2 = panel._layers["c2"]
|
||||
lay2.toggle()
|
||||
check("P3.7 运行中层输出标题 = 输出(实时)", lay2.lbl_out.text() == "输出(实时)",
|
||||
lay2.lbl_out.text())
|
||||
check("P3.8 运行中层实时内容可见", "line-A" in lay2.out_box.toPlainText())
|
||||
window._on_tool_updated(sid, "c2", "line-B\n")
|
||||
check("P3.9 展开状态下新输出即时可见(无需重新展开)",
|
||||
"line-B" in lay2.out_box.toPlainText(), repr(lay2.out_box.toPlainText()))
|
||||
window._on_tool_finished(sid, "c2", "bash", False, "$ ping -t x\nline-A\n[exit 1] (0.5s)")
|
||||
check("P3.10 失败层标签为「失败」", lay2.tag.text() == "失败", lay2.tag.text())
|
||||
|
||||
# 实时缓冲上限
|
||||
big = BashLayer("cbuf", "x")
|
||||
for _ in range(260):
|
||||
big.append_live("y" * 1000)
|
||||
check("P3.11 实时缓冲截到 200KB 内", len(big._live) <= LIVE_BUF_CAP, str(len(big._live)))
|
||||
check("P3.12 截断被标注", big._live_truncated is True)
|
||||
|
||||
# ======================================================================
|
||||
# 4) 压缩切点 → 已出上下文标注
|
||||
# ======================================================================
|
||||
sid2 = window.db.create_session("面板压缩测试")["id"]
|
||||
leaf = window.db.get_session_leaf(sid2)
|
||||
tl1 = json.dumps([{"t": "tool", "id": "old1", "name": "bash",
|
||||
"args": json.dumps({"command": "old-cmd"}),
|
||||
"ok": True, "result": "$ old-cmd\nold\n[exit 0] (0.1s)"}],
|
||||
ensure_ascii=False)
|
||||
m_sys = leaf
|
||||
m_u1 = window.db.add_message(sid2, "user", "q1", m_sys)["id"]
|
||||
m_a1 = window.db.add_message(sid2, "assistant", "a1", m_u1, timeline=tl1)["id"]
|
||||
m_u2 = window.db.add_message(sid2, "user", "q2", m_a1)["id"]
|
||||
tl2 = json.dumps([{"t": "tool", "id": "new1", "name": "bash",
|
||||
"args": json.dumps({"command": "new-cmd"}),
|
||||
"ok": True, "result": "$ new-cmd\nnew\n[exit 0] (0.1s)"}],
|
||||
ensure_ascii=False)
|
||||
m_a2 = window.db.add_message(sid2, "assistant", "a2", m_u2, timeline=tl2)["id"]
|
||||
window.db.insert_compaction_mark(sid2, "【摘要】前文略", cut_before_id=m_a1,
|
||||
first_retained_id=m_u2)
|
||||
window.load_messages_to_web(sid2)
|
||||
settle(300)
|
||||
check("P4.1 切换会话后面板跟随刷新", set(panel.layer_ids()) == {"old1", "new1"},
|
||||
str(panel.layer_ids()))
|
||||
check("P4.2 压缩切点之前的层标注「已出上下文」",
|
||||
panel._layers["old1"].tag.text() == "已出上下文", panel._layers["old1"].tag.text())
|
||||
check("P4.3 切点之后的层仍为「已完成」",
|
||||
panel._layers["new1"].tag.text() == "已完成", panel._layers["new1"].tag.text())
|
||||
check("P4.4 已完成层不截断输出(原文保留)",
|
||||
panel._layers["old1"]._final.endswith("[exit 0] (0.1s)"))
|
||||
|
||||
# ======================================================================
|
||||
# 5) 层数上限
|
||||
# ======================================================================
|
||||
sid3 = window.db.create_session("面板层数上限")["id"]
|
||||
leaf3 = window.db.get_session_leaf(sid3)
|
||||
entries = [{"t": "tool", "id": f"many{i}", "name": "bash",
|
||||
"args": json.dumps({"command": f"cmd{i}"}),
|
||||
"ok": True, "result": f"$ cmd{i}\nok\n[exit 0] (0.1s)"} for i in range(35)]
|
||||
window.db.add_message(sid3, "assistant", "批量", leaf3,
|
||||
timeline=json.dumps(entries, ensure_ascii=False))
|
||||
window.load_messages_to_web(sid3)
|
||||
settle(300)
|
||||
check("P5.1 只渲染最近 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT),
|
||||
panel.sec_done.count.text())
|
||||
check("P5.2 有「仅显示最近 N 层」提示", "仅显示最近 30 层" in panel.sec_done.hint.text(),
|
||||
panel.sec_done.hint.text())
|
||||
check("P5.3 面板内部仍保留全部 35 层(只是不渲染)",
|
||||
len(panel.layer_ids("done")) == 35, str(len(panel.layer_ids("done"))))
|
||||
|
||||
# ======================================================================
|
||||
# 6) 不自动收缩(resizeEvent 不影响右侧面板)+ 手动切换
|
||||
# ======================================================================
|
||||
was = panel.collapsed
|
||||
window.resize(920, 700)
|
||||
settle(200)
|
||||
check("P6.1 窗口变小不触发面板自动收缩", panel.collapsed == was,
|
||||
f"before={was} after={panel.collapsed}")
|
||||
window.resize(1400, 800)
|
||||
settle(200)
|
||||
check("P6.2 窗口变大也不触发面板自动展开/收缩", panel.collapsed == was)
|
||||
panel.fold_btn.click()
|
||||
settle(500)
|
||||
check("P6.3 只有点栏内按钮才收起", panel.width() == 52 and panel.collapsed is True)
|
||||
check("P6.4 收起后可见开关切回 expand_btn(恒为 1 个)",
|
||||
len(visible_toggles()) == 1 and visible_toggles()[0] is panel.expand_btn)
|
||||
panel.expand_btn.click()
|
||||
settle(500)
|
||||
check("P6.5 收起页按钮再点一次即可展开", panel.width() == 260 and panel.collapsed is False)
|
||||
check("P6.6 全窗口仍只有 1 个可见面板开关(且主界面顶部没有)",
|
||||
len(visible_toggles()) == 1 and not hasattr(window, "btn_panel"))
|
||||
|
||||
# ======================================================================
|
||||
# 7) 会话切回空会话 → 面板清空
|
||||
# ======================================================================
|
||||
sid4 = window.db.create_session("面板空会话")["id"]
|
||||
window.load_messages_to_web(sid4)
|
||||
settle(250)
|
||||
check("P7.1 切到空会话后面板清空", panel.layer_ids() == [], str(panel.layer_ids()))
|
||||
|
||||
# ======================================================================
|
||||
# 8) 🆕 左边缘拖拽调宽 → 记录 → 下次展开自动恢复(含最小宽度)
|
||||
# ======================================================================
|
||||
from ui.views.bash_panel import (PANEL_W_DEFAULT, PANEL_W_MIN, PANEL_W_MAX, # noqa: E402
|
||||
BashPanel, load_panel_width)
|
||||
|
||||
if panel.collapsed:
|
||||
panel.expand_btn.click()
|
||||
settle(400)
|
||||
|
||||
check("P8.1 拖拽手柄存在、宽 4px、贴在最左",
|
||||
panel._handle.width() == 4 and panel._handle.x() == 0,
|
||||
f"w={panel._handle.width()} x={panel._handle.x()}")
|
||||
check("P8.2 手柄覆盖面板全高", panel._handle.height() == panel.height(),
|
||||
f"{panel._handle.height()} vs {panel.height()}")
|
||||
check("P8.3 手柄光标 = 水平拖拽",
|
||||
panel._handle.cursor().shape() == QtCore.Qt.CursorShape.SizeHorCursor)
|
||||
check("P8.4 最小宽度已设定(%d px)" % PANEL_W_MIN,
|
||||
PANEL_W_MIN == 200 and panel.W_MIN == PANEL_W_MIN)
|
||||
|
||||
|
||||
def drag_to(width):
|
||||
"""模拟真实拖拽:按下(记右边界)→ 鼠标移到目标位置 → 松手"""
|
||||
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
|
||||
panel._drag_begin(right)
|
||||
panel._drag_to_global_x(right - width)
|
||||
panel._commit_drag_width()
|
||||
settle(120)
|
||||
|
||||
|
||||
# 8.1 正常拖宽 → 立即生效 + 落盘
|
||||
cfg_before = json.load(open(_CFG_TMP, encoding="utf-8"))
|
||||
drag_to(340)
|
||||
check("P8.5 拖到 340px 立即生效", panel.width() == 340 and panel.collapsed is False,
|
||||
f"w={panel.width()}")
|
||||
cfg_after = json.load(open(_CFG_TMP, encoding="utf-8"))
|
||||
check("P8.6 已落盘 config.json['bash_panel_width'] = 340",
|
||||
cfg_after.get("bash_panel_width") == 340, str(cfg_after.get("bash_panel_width")))
|
||||
check("P8.7 落盘不破坏其它键(providers/mode_switch 保留)",
|
||||
"providers" in cfg_after and cfg_after.get("mode_switch") is True)
|
||||
|
||||
# 8.2 收起再展开 → 回到记录值(不是默认 260)
|
||||
panel.fold_btn.click()
|
||||
settle(450)
|
||||
check("P8.8 收起仍为 52px", panel.width() == 52 and panel.collapsed is True, f"w={panel.width()}")
|
||||
check("P8.9 收起条居中按钮不被 4px 手柄遮住", panel.expand_btn.x() >= panel._handle.width(),
|
||||
f"btn_x={panel.expand_btn.x()}")
|
||||
panel.expand_btn.click()
|
||||
settle(450)
|
||||
check("P8.10 再展开 = 记录的 340px(不是默认 %d)" % PANEL_W_DEFAULT,
|
||||
panel.width() == 340 and panel.W_EXPAND == 340, f"w={panel.width()} W_EXPAND={panel.W_EXPAND}")
|
||||
|
||||
# 8.3 最小宽度夹紧
|
||||
win_half = int(window.width() * 0.5)
|
||||
drag_to(120)
|
||||
check("P8.11 拖到 120px → 被夹到最小 200px", panel.width() == PANEL_W_MIN, f"w={panel.width()}")
|
||||
check("P8.12 夹紧后仍是展开态(不会误判为收起)",
|
||||
panel.collapsed is False and panel.width() > panel.W_COLLAPSE)
|
||||
|
||||
# 8.4 上限夹紧
|
||||
drag_to(5000)
|
||||
exp_hi = min(PANEL_W_MAX, max(PANEL_W_MIN, win_half))
|
||||
check("P8.13 拖到 5000px → 被夹到上限(≤%d)" % exp_hi, panel.width() == exp_hi,
|
||||
f"w={panel.width()} exp={exp_hi}")
|
||||
|
||||
# 8.5 从收起态直接拖开 → 自动进展开态
|
||||
drag_to(PANEL_W_MIN)
|
||||
panel.fold_btn.click()
|
||||
settle(450)
|
||||
check("P8.14 已收起 52px", panel.width() == 52)
|
||||
panel._drag_begin(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x())
|
||||
panel._drag_to_global_x(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() + 100)
|
||||
check("P8.15 从收起态向外拖 → 立即进入展开态(不为 0/负数)",
|
||||
panel.collapsed is False and panel.width() >= PANEL_W_MIN, f"w={panel.width()}")
|
||||
panel._commit_drag_width()
|
||||
settle(120)
|
||||
|
||||
# 8.6 持久化:新实例直接读出记录值
|
||||
drag_to(300)
|
||||
check("P8.16 记录 300px 后,新实例读出同一值",
|
||||
load_panel_width() == 300, str(load_panel_width()))
|
||||
_p2 = BashPanel()
|
||||
check("P8.17 新面板 W_EXPAND 即上次记录值(重启后自动恢复)",
|
||||
_p2.W_EXPAND == 300, str(_p2.W_EXPAND))
|
||||
_p2.deleteLater()
|
||||
|
||||
# 8.7 只拖不松手不写盘
|
||||
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
|
||||
json.dump({"providers": {}, "mode_switch": True}, _f)
|
||||
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
|
||||
panel._drag_begin(right)
|
||||
panel._drag_to_global_x(right - 420)
|
||||
check("P8.18 拖动过程中不写盘(只在松手时记录)",
|
||||
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") is None)
|
||||
panel._commit_drag_width()
|
||||
check("P8.19 松手才落盘",
|
||||
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") == panel.width(),
|
||||
str(json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width")))
|
||||
|
||||
# ======================================================================
|
||||
# 9) 🆕 两栏折叠/展开:只收「下面填充的 bash 层」,头部行高恒定(抽屉动画)
|
||||
# ======================================================================
|
||||
if panel.collapsed:
|
||||
panel.expand_btn.click()
|
||||
settle(400)
|
||||
|
||||
# 造数据:运行中 1 层 + 已完成 2 层
|
||||
panel.on_started("p9_run", "bash", {"command": "python long_task.py"})
|
||||
panel.on_timed("p9_run", 3, 120)
|
||||
panel.on_started("p9_d1", "bash", {"command": "echo a"})
|
||||
panel.on_finished("p9_d1", "bash", True, "a")
|
||||
panel.on_started("p9_d2", "bash", {"command": "echo b"})
|
||||
panel.on_finished("p9_d2", "bash", True, "b")
|
||||
settle(300)
|
||||
|
||||
sr, sd = panel.sec_running, panel.sec_done
|
||||
check("P9.0 两栏都有内容(运行中1 / 已完成2)",
|
||||
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2,
|
||||
f"{panel.layer_ids('running')} / {panel.layer_ids('done')}")
|
||||
|
||||
head_h = sr.head_height()
|
||||
total0 = sum(panel.splitter.sizes())
|
||||
check("P9.1 头部行高 = 24px 量级(收起/展开恒定的那一行)",
|
||||
16 <= head_h <= 40, f"head_h={head_h}")
|
||||
check("P9.2 最小高已被重写为「只剩头部行」(bug 根因解除)",
|
||||
sr.minimumSizeHint().height() == head_h and sd.minimumSizeHint().height() == sd.head_height(),
|
||||
f"{sr.minimumSizeHint().height()} vs head {head_h}")
|
||||
|
||||
h_run_before = sr.height()
|
||||
h_done_before = sd.height()
|
||||
|
||||
# ---- 收起「运行中」-----
|
||||
sr.head.clicked.emit()
|
||||
settle(120) # 只等一半 → 应处于动画中间态
|
||||
mid_run = sr.height()
|
||||
settle(400)
|
||||
check("P9.3 收起「运行中」→ 该栏只剩头部高(空白归零)",
|
||||
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
|
||||
check("P9.4 splitter sizes[0] == 头部高",
|
||||
abs(panel.splitter.sizes()[0] - head_h) <= 2, str(panel.splitter.sizes()))
|
||||
check("P9.5 「已完成」栏吃掉让出的空间",
|
||||
sd.height() > h_done_before + 50, f"{h_done_before} -> {sd.height()}")
|
||||
check("P9.6 让出空间 = 两栏高度之和不变",
|
||||
abs((sr.height() + sd.height()) - (h_run_before + h_done_before)) <= 2,
|
||||
f"{h_run_before}+{h_done_before} vs {sr.height()}+{sd.height()}")
|
||||
check("P9.7 动画是渐变(中间帧介于首末之间)",
|
||||
head_h < mid_run < h_run_before, f"mid={mid_run} ({head_h}, {h_run_before})")
|
||||
check("P9.8 收起后内容区不可见(scroll.isVisible() == False)",
|
||||
not sr.scroll.isVisible() and not sr.body.isVisible())
|
||||
check("P9.9 收起后头部行仍可见且高度不变",
|
||||
sr.head.isVisible() and abs(sr.head.height() - head_h) <= 1, f"head={sr.head.height()}")
|
||||
check("P9.10 收起态:栏头箭头变 ▸", sr.chev.text() == "▸")
|
||||
check("P9.11 收起态:中间拖动分隔条被禁用",
|
||||
panel.splitter.handle(1).isEnabled() is False)
|
||||
|
||||
# ---- 再展开 ----
|
||||
sr.head.clicked.emit()
|
||||
settle(120)
|
||||
mid2 = sr.height()
|
||||
settle(400)
|
||||
check("P9.12 再展开 → 恢复原高度(±3)",
|
||||
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
|
||||
check("P9.13 展开也是渐变", head_h <= mid2 <= h_run_before, f"mid={mid2}")
|
||||
check("P9.14 展开后内容可见 / 箭头回 ▾ / 分隔条恢复可拖",
|
||||
sr.scroll.isVisible() and sr.chev.text() == "▾" and panel.splitter.handle(1).isEnabled())
|
||||
check("P9.15 折叠不影响层数据与实时缓冲",
|
||||
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2
|
||||
and panel._layers["p9_run"].ok is None)
|
||||
|
||||
# ---- 「已完成」栏单独收起同样生效 ----
|
||||
sd.head.clicked.emit()
|
||||
settle(500)
|
||||
check("P9.16 收起「已完成」→ 该栏只剩头部高",
|
||||
abs(sd.height() - sd.head_height()) <= 2, f"h={sd.height()} head={sd.head_height()}")
|
||||
# 🆕 新口径:收起「已完成」不再让「运行中」吃掉空间,而是保持它的高度、
|
||||
# 余量进底部留白(栏头紧跟上方内容,不被顶到底部)
|
||||
check("P9.17 收起「已完成」→ 「运行中」高度保持不变(不再吃空间)",
|
||||
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
|
||||
sd.head.clicked.emit()
|
||||
settle(500)
|
||||
check("P9.18 展开恢复", abs(sd.height() - h_done_before) <= 3, f"{sd.height()} vs {h_done_before}")
|
||||
|
||||
# ---- 两栏都收起 → 两个头部相邻在顶部 ----
|
||||
sr.head.clicked.emit(); settle(400)
|
||||
sd.head.clicked.emit(); settle(400)
|
||||
sizes2 = panel.splitter.sizes()
|
||||
check("P9.19 两栏都收起 → sizes = [头部高, 头部高, 余量]",
|
||||
abs(sizes2[0] - head_h) <= 2 and abs(sizes2[1] - head_h) <= 2
|
||||
and abs(sum(sizes2) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
|
||||
str(sizes2))
|
||||
check("P9.20 两栏都收起时两个栏头相邻(间隔 = 分隔条宽)",
|
||||
abs(sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
|
||||
- (sr.mapTo(panel, QtCore.QPoint(0, 0)).y() + sr.height())
|
||||
- panel.splitter.handleWidth()) <= 2,
|
||||
f"sec2_y={sd.mapTo(panel, QtCore.QPoint(0, 0)).y()} sec1_y={sr.mapTo(panel, QtCore.QPoint(0, 0)).y()}")
|
||||
|
||||
# ---- 连点 3 次 → 收敛到终态 ----
|
||||
for _s in (sr, sd): # 先确保两栏都展开
|
||||
if _s.folded:
|
||||
_s.head.clicked.emit()
|
||||
settle(500)
|
||||
h_unfolded = sr.height()
|
||||
sr.head.clicked.emit(); sr.head.clicked.emit(); sr.head.clicked.emit()
|
||||
settle(700)
|
||||
check("P9.21 未收起时连点 3 次 → 终态为收起,且只剩头部高",
|
||||
sr.folded is True and abs(sr.height() - head_h) <= 2,
|
||||
f"folded={sr.folded} h={sr.height()}")
|
||||
sr.head.clicked.emit()
|
||||
settle(700)
|
||||
check("P9.22 连点后仍能正常展开并恢复原高度",
|
||||
sr.folded is False and abs(sr.height() - h_unfolded) <= 3,
|
||||
f"h={sr.height()} vs {h_unfolded}")
|
||||
|
||||
# ---- 面板尺寸变化后仍钉在头部高 ----
|
||||
sr.head.clicked.emit()
|
||||
settle(500)
|
||||
sd_h_before = sd.head_height()
|
||||
sizes_now = panel.splitter.sizes()
|
||||
panel.splitter.setSizes([sizes_now[1], sizes_now[0]]) # 模拟被外部拉大/拉小
|
||||
settle(120)
|
||||
panel._repin_fold() # resizeEvent 里的重钉
|
||||
settle(120)
|
||||
check("P9.23 尺寸变化后收起栏仍只占头部高(_repin_fold 生效)",
|
||||
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
|
||||
sr.head.clicked.emit()
|
||||
settle(500)
|
||||
check("P9.24 收尾:两栏均展开,头部行高不变",
|
||||
(not sr.folded) and (not sd.folded)
|
||||
and sr.head_height() == head_h and sd.head_height() == sd_h_before,
|
||||
f"{sr.head_height()}/{sd.head_height()} vs {head_h}/{sd_h_before}")
|
||||
|
||||
# ======================================================================
|
||||
# 10) 🆕 两个实测 bug 的回归测试
|
||||
# bug1: 收起后 head 被拉伸成巨条(实测 793px)
|
||||
# bug2: 收起「已完成」→ 栏头被顶到面板最底部
|
||||
# ======================================================================
|
||||
def sec_y(s):
|
||||
return s.mapTo(panel, QtCore.QPoint(0, 0)).y()
|
||||
|
||||
# ---- bug1: 头部行高【硬固定】(任意组合下都不被拉伸)----
|
||||
combos = [(False, False), (True, False), (False, True), (True, True)]
|
||||
bad = []
|
||||
for want_r, want_d in combos:
|
||||
for s, want in ((sr, want_r), (sd, want_d)):
|
||||
if s.folded != want:
|
||||
s.head.clicked.emit()
|
||||
settle(450)
|
||||
if sr.head.height() != sr.head_height() or sd.head.height() != sd.head_height():
|
||||
bad.append((want_r, want_d, sr.head.height(), sd.head.height()))
|
||||
check("P9.25 四种折叠组合下 head.height() 恒等于 head_height()(不再被拉伸)",
|
||||
not bad, str(bad))
|
||||
check("P9.26 两栏都收起时「已完成」head 高 == 24(修复前实测 793)",
|
||||
sr.folded and sd.folded and sd.head.height() == sd.head_height() == head_h,
|
||||
f"folded=({sr.folded},{sd.folded}) head={sd.head.height()}")
|
||||
_sp = panel.splitter.sizes()
|
||||
check("P9.27 两栏都收起 → 余量进底部留白(spacer)",
|
||||
_sp[2] > 100 and abs(sum(_sp) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
|
||||
str(_sp))
|
||||
check("P9.28 spacer 上方的分隔条不可拖且透明",
|
||||
panel.splitter.handle(2).isEnabled() is False
|
||||
and "transparent" in (panel.splitter.handle(2).styleSheet() or ""),
|
||||
panel.splitter.handle(2).styleSheet())
|
||||
|
||||
# ---- bug2: 收起「已完成」→ 栏头紧跟「运行中」内容,而不是落到面板底部 ----
|
||||
for s in (sr, sd):
|
||||
if s.folded:
|
||||
s.head.clicked.emit()
|
||||
settle(450)
|
||||
h_run_keep = sr.height()
|
||||
sd.head.clicked.emit()
|
||||
settle(500)
|
||||
sp_y = sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
|
||||
check("P9.29 收起「已完成」→ 栏头紧跟在「运行中」下方(不是面板底部)",
|
||||
abs(sp_y - (sec_y(sr) + sr.height() + panel.splitter.handleWidth())) <= 2,
|
||||
f"done_y={sp_y} run_bottom={sec_y(sr) + sr.height()}")
|
||||
check("P9.30 收起「已完成」→ 栏头远离面板底部(留白在它下面)",
|
||||
sp_y < panel.height() - 100, f"done_y={sp_y} panel_h={panel.height()}")
|
||||
check("P9.31 收起「已完成」→ 「运行中」高度保持记录值",
|
||||
abs(sr.height() - h_run_keep) <= 3, f"{sr.height()} vs {h_run_keep}")
|
||||
check("P9.32 收起「已完成」→ spacer 吸收余量",
|
||||
panel.splitter.sizes()[2] > 100, str(panel.splitter.sizes()))
|
||||
|
||||
# ---- 展开恢复 ----
|
||||
sd.head.clicked.emit()
|
||||
settle(550)
|
||||
check("P9.33 展开「已完成」→ 恢复记录高度",
|
||||
abs(sd.height() - h_done_before) <= 4, f"{sd.height()} vs {h_done_before}")
|
||||
check("P9.34 展开后 spacer 归零(已完成重新填满到底部)",
|
||||
panel.splitter.sizes()[2] <= 2, str(panel.splitter.sizes()))
|
||||
|
||||
# ---- 不变量:任何折叠态下 sizes 之和 == 可用高度 ----
|
||||
ok_sum = True
|
||||
for want_r, want_d in combos:
|
||||
for s, want in ((sr, want_r), (sd, want_d)):
|
||||
if s.folded != want:
|
||||
s.head.clicked.emit()
|
||||
settle(450)
|
||||
avail = panel.splitter.height() - 2 * panel.splitter.handleWidth()
|
||||
if abs(sum(panel.splitter.sizes()) - avail) > 3:
|
||||
ok_sum = False
|
||||
check("P9.35 不变量:任意折叠态下 sizes 之和 == splitter 可用高度", ok_sum)
|
||||
for s in (sr, sd):
|
||||
if s.folded:
|
||||
s.head.clicked.emit()
|
||||
settle(450)
|
||||
check("P9.36 收尾:两栏均展开", (not sr.folded) and (not sd.folded))
|
||||
|
||||
# ======================================================================
|
||||
# 11) 🆕 P2-01:两栏按【启动顺序倒序】显示 + 重排复用实例/状态保持
|
||||
# · 排序键恒为启动序号(绝不用完成时间重排)
|
||||
# · 重排复用同一批 BashLayer:展开态、实时输出、代码框滚动、栏滚动位置保持
|
||||
# ======================================================================
|
||||
panel.clear_all()
|
||||
settle(120)
|
||||
|
||||
# ---- 11.1 运行中栏:最新启动在第一项 ----
|
||||
panel.on_started("s1", "bash", {"command": "cmd-s1"})
|
||||
panel.on_started("s2", "bash", {"command": "cmd-s2"})
|
||||
panel.on_started("s3", "bash", {"command": "cmd-s3"})
|
||||
settle(150)
|
||||
check("P11.1 运行中栏按启动倒序(s3 最新在第一项)",
|
||||
panel.layer_ids("running") == ["s3", "s2", "s1"],
|
||||
str(panel.layer_ids("running")))
|
||||
_rendered_run = [w for w in (panel.sec_running.lay.itemAt(i).widget()
|
||||
for i in range(panel.sec_running.lay.count()))
|
||||
if w is not None]
|
||||
check("P11.2 实际布局顺序与显示顺序一致(widget 复用、顺序反转)",
|
||||
[w.call_id for w in _rendered_run] == ["s3", "s2", "s1"],
|
||||
str([w.call_id for w in _rendered_run]))
|
||||
|
||||
# ---- 11.2 新任务触发重排:实例/展开态/实时输出/代码框滚动 全部保持 ----
|
||||
lay2 = panel._layers["s2"]
|
||||
lay2.toggle() # 展开 s2
|
||||
panel.on_output("s2", "x" * 2000 + "\n") # 实时输出(NoWrap → 有水平滚动范围)
|
||||
settle(120) # 等布局落定(真实用户滚动必然在渲染后;
|
||||
# 否则首帧布局 flush 会把水平滚动归零——测试时序伪影,非产品 bug)
|
||||
hbar = lay2.out_box.horizontalScrollBar()
|
||||
hbar.setValue(500)
|
||||
v0 = hbar.value()
|
||||
dv0 = panel.sec_done.scroll.verticalScrollBar().value()
|
||||
check("P11.3 前置:水平滚动处于非 0 位置", v0 > 0, f"v0={v0}")
|
||||
panel.on_started("s4", "bash", {"command": "cmd-s4"}) # 新任务 → 两栏重排
|
||||
settle(150)
|
||||
lay2b = panel._layers["s2"]
|
||||
check("P11.4 重排后 s2 仍是同一个 BashLayer 实例(不重建)", lay2b is lay2)
|
||||
check("P11.5 展开/折叠状态保持", lay2b.expanded is True and not lay2b.body.isHidden())
|
||||
check("P11.6 实时输出保持(未因重排丢失/重渲染)", lay2b._live == "x" * 2000 + "\n",
|
||||
repr(lay2b._live[:20]))
|
||||
check("P11.7 代码框水平滚动值保持", hbar.value() == v0, f"{hbar.value()} vs {v0}")
|
||||
check("P11.8 已完成栏 section 滚动位置保持",
|
||||
panel.sec_done.scroll.verticalScrollBar().value() == dv0)
|
||||
check("P11.9 运行中栏重排后仍启动倒序(s4 顶到第一项)",
|
||||
panel.layer_ids("running") == ["s4", "s3", "s2", "s1"],
|
||||
str(panel.layer_ids("running")))
|
||||
|
||||
# ---- 11.3 完成时间【不是】排序键:s2 启动更晚却先完成 → 仍在 s1 之上 ----
|
||||
panel.on_finished("s2", "bash", True, "$ cmd-s2\nok2\n[exit 0] (0.1s)")
|
||||
panel.on_finished("s1", "bash", True, "$ cmd-s1\nok1\n[exit 0] (0.2s)")
|
||||
settle(150)
|
||||
check("P11.10 已完成栏按启动倒序(s2 先完成仍在第一项,不按完成时间)",
|
||||
panel.layer_ids("done") == ["s2", "s1"], str(panel.layer_ids("done")))
|
||||
check("P11.11 完成后从运行中栏消失(启动位置不变,仅换栏)",
|
||||
panel.layer_ids("running") == ["s4", "s3"], str(panel.layer_ids("running")))
|
||||
|
||||
# ---- 11.4 运行中→已完成 仍占原启动位置(晚完成不顶到最上) ----
|
||||
panel.on_started("s6", "bash", {"command": "cmd-s6"})
|
||||
panel.on_finished("s6", "bash", True, "$ cmd-s6\nok6\n[exit 0] (0.1s)")
|
||||
check("P11.12 s6 最后启动 → 顶到已完成栏第一项",
|
||||
panel.layer_ids("done") == ["s6", "s2", "s1"], str(panel.layer_ids("done")))
|
||||
panel.on_finished("s3", "bash", True, "$ cmd-s3\nok3\n[exit 0] (0.3s)") # s3 最后完成
|
||||
settle(150)
|
||||
check("P11.13 s3 最后完成但按启动位置插入第二项(不顶到最上)",
|
||||
panel.layer_ids("done") == ["s6", "s3", "s2", "s1"], str(panel.layer_ids("done")))
|
||||
|
||||
# ---- 11.4b 已完成栏 section 滚动位置保持(有实际滚动范围时) ----
|
||||
_long = "\n".join(f"line-{j}" for j in range(20)) # 20 行 → 撑满 out_box 230px 上限
|
||||
panel._layers["s6"].set_finished(True, f"$ cmd-s6\n{_long}\n[exit 0] (0.1s)")
|
||||
panel._layers["s3"].set_finished(True, f"$ cmd-s3\n{_long}\n[exit 0] (0.2s)")
|
||||
panel._layers["s6"].toggle() # 展开两层 → 内容必然超出栏高,产生真实滚动范围
|
||||
panel._layers["s3"].toggle()
|
||||
settle(150)
|
||||
sbar = panel.sec_done.scroll.verticalScrollBar()
|
||||
sbar.setValue(30)
|
||||
sv0 = sbar.value()
|
||||
check("P11.13b 前置:已完成栏存在真实滚动范围(v>0)", sv0 > 0, f"sv0={sv0} max={sbar.maximum()}")
|
||||
panel.on_started("t0", "bash", {"command": "cmd-t0"}) # 新任务 → 两栏重排
|
||||
panel.on_finished("t0", "bash", True, "$ cmd-t0\nok\n[exit 0] (0.1s)")
|
||||
settle(150)
|
||||
check("P11.13c 重排后已完成栏 section 滚动值保持", sbar.value() == sv0,
|
||||
f"{sbar.value()} vs {sv0}")
|
||||
check("P11.13d 展开态在重排后仍保持", panel._layers["s6"].expanded is True
|
||||
and panel._layers["s3"].expanded is True)
|
||||
|
||||
# ---- 11.5 限量窗口:窗口成员不变,仅显示顺序反转(顶层 = 最新启动) ----
|
||||
for i in range(1, 32):
|
||||
cid = f"t{i}"
|
||||
panel.on_started(cid, "bash", {"command": f"cmd-{cid}"})
|
||||
panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)")
|
||||
settle(200)
|
||||
check("P11.14 内部仍保留全部 36 个已完成(5 手工 + 31 批量)", len(panel.layer_ids("done")) == 36,
|
||||
str(len(panel.layer_ids("done"))))
|
||||
check("P11.15 限量显示仍为 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT),
|
||||
panel.sec_done.count.text())
|
||||
check("P11.16 「仅显示最近 N 层」提示保留", "仅显示最近 30 层" in panel.sec_done.hint.text(),
|
||||
panel.sec_done.hint.text())
|
||||
_rendered_done = [w for w in (panel.sec_done.lay.itemAt(i).widget()
|
||||
for i in range(panel.sec_done.lay.count()))
|
||||
if w is not None]
|
||||
check("P11.17 渲染窗口 = 最近启动的 30 个(不含最早启动的 s1/s2/s3/s6/t0/t1)",
|
||||
[w.call_id for w in _rendered_done][0] == "t31"
|
||||
and "s1" not in [w.call_id for w in _rendered_done]
|
||||
and "s2" not in [w.call_id for w in _rendered_done]
|
||||
and len(_rendered_done) == 30,
|
||||
str([w.call_id for w in _rendered_done][:5]))
|
||||
check("P11.18 已完成栏顶层 = 最新启动(t31),底层 = 窗口内最早(t2)",
|
||||
[w.call_id for w in _rendered_done][0] == "t31"
|
||||
and [w.call_id for w in _rendered_done][-1] == "t2",
|
||||
str([w.call_id for w in _rendered_done][:2] + [w.call_id for w in _rendered_done][-1:]))
|
||||
|
||||
# ---- 11.6 DB 重建(切换会话):启动序号 = 消息链顺序 + 时间线内顺序 → 显示倒序 ----
|
||||
sid5 = window.db.create_session("P2-01 启动倒序")["id"]
|
||||
leaf5 = window.db.get_session_leaf(sid5)
|
||||
tl5 = json.dumps([{"t": "tool", "id": f"db{i}", "name": "bash",
|
||||
"args": json.dumps({"command": f"db-cmd{i}"}),
|
||||
"ok": True, "result": f"$ db-cmd{i}\nok\n[exit 0] (0.1s)"}
|
||||
for i in range(3)], ensure_ascii=False)
|
||||
window.db.add_message(sid5, "assistant", "a", leaf5, timeline=tl5)
|
||||
window.load_messages_to_web(sid5)
|
||||
settle(300)
|
||||
check("P11.19 DB 重建后已完成栏按启动倒序(db2 顶层、db0 底层)",
|
||||
panel.layer_ids("done") == ["db2", "db1", "db0"], str(panel.layer_ids("done")))
|
||||
check("P11.20 「all」仍返回原始启动序号(正序,调试口径不变)",
|
||||
panel.layer_ids() == ["db0", "db1", "db2"], str(panel.layer_ids()))
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
if os.path.exists(_DB_TMP):
|
||||
os.remove(_DB_TMP)
|
||||
if os.path.exists(_CFG_TMP):
|
||||
os.remove(_CFG_TMP)
|
||||
# offscreen 铁律:QtWebEngine 渲染/GPU 子进程在解释器退出时可能不回收 → 挂起;
|
||||
# 与 smoke_offscreen 等 offscreen harness 一致,用 os._exit 强制收尾(stdout 已 flush)
|
||||
os._exit(0 if ok else 1)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""会话复制 · UI 入口端到端(offscreen)
|
||||
链路:弹窗「📋 复制」按钮 → action_triggered("copy") → on_session_action →
|
||||
DBManager.copy_session → 侧边栏重建 + 自动切到副本
|
||||
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_copy_session.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
# 铁律:测试不得污染真实 data/chat_history.db → 默认 DB 路径重定向到临时文件
|
||||
import core.db_manager as _dbm # noqa: E402
|
||||
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_copy_ui_{os.getpid()}.db")
|
||||
if os.path.exists(_DB_TMP):
|
||||
os.remove(_DB_TMP)
|
||||
_dbm._DEFAULT_DB = _DB_TMP
|
||||
|
||||
_FS_TMP = tempfile.mkdtemp(prefix="hocode_copy_ui_files_") # 临时附件根
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QPushButton # noqa: E402
|
||||
from PyQt6 import QtCore # noqa: E402
|
||||
from ui.views.main_window import MainWindow, SessionContextPopup # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
def chain_sig(sid):
|
||||
return [(m["role"], m["content"]) for m in window.db.get_message_chain(sid)]
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
window.db.files_root = _FS_TMP
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 1) 准备一个带内容的源会话(直接走 DB 层,不触发真实 API 请求)
|
||||
# ======================================================================
|
||||
window.on_new_chat_clicked()
|
||||
src = window.current_session_id
|
||||
for role, text in [("user", "帮我看看这个文件"), ("assistant", "好的,我先读一下")]:
|
||||
window.db.add_message(src, role, text, window.db.get_session_leaf(src))
|
||||
window.db.mark_session_has_messages(src)
|
||||
window.rebuild_sidebar()
|
||||
src_title = [s for s in window.db.get_all_sessions() if s["id"] == src][0]["title"]
|
||||
check("源会话已就绪(2 条消息)", len(window.db.get_message_chain(src)) == 3,
|
||||
str(len(window.db.get_message_chain(src))))
|
||||
|
||||
# 图片附件(验证 UI 链路里附件也被深拷贝)
|
||||
os.makedirs(os.path.join(_FS_TMP, "data", "attachments"), exist_ok=True)
|
||||
png = os.path.join(_FS_TMP, "data", "attachments", "ui_src.png")
|
||||
with open(png, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\nUI-IMAGE-BYTES")
|
||||
meta = ('{"user_text":"看图","attachments":[{"type":"image","size_kb":0.1,'
|
||||
'"local_path":"data/attachments/ui_src.png"}]}')
|
||||
window.db.add_message(src, "user", "以及这张图", window.db.get_session_leaf(src),
|
||||
attachment_metadata=meta)
|
||||
|
||||
# ======================================================================
|
||||
# 2) 弹窗里存在「复制」按钮,且点击后发出 ("copy", sid)
|
||||
# ======================================================================
|
||||
popup = SessionContextPopup(src, False, window)
|
||||
btns = popup.findChildren(QPushButton)
|
||||
texts = [b.text() for b in btns]
|
||||
check("弹窗含「📋 复制」按钮", any("复制" in t for t in texts), str(texts))
|
||||
check("按钮顺序 编辑/星标/复制/删除",
|
||||
len(texts) == 4 and "编辑" in texts[0] and "星标" in texts[1]
|
||||
and "复制" in texts[2] and "删除" in texts[3], str(texts))
|
||||
|
||||
got = []
|
||||
popup.action_triggered.connect(lambda a, s: got.append((a, s)))
|
||||
copy_btn = [b for b in btns if "复制" in b.text()][0]
|
||||
copy_btn.click()
|
||||
check("点击复制按钮发出 copy 动作", got == [("copy", src)], str(got))
|
||||
|
||||
# ======================================================================
|
||||
# 3) on_session_action("copy") → 真实克隆 + 侧边栏刷新 + 切到副本
|
||||
# ======================================================================
|
||||
n_before = len(window.db.get_all_sessions())
|
||||
window.on_session_action("copy", src)
|
||||
sessions = window.db.get_all_sessions()
|
||||
check("会话数 +1", len(sessions) == n_before + 1, f"{n_before}→{len(sessions)}")
|
||||
|
||||
rows = [s for s in sessions if s["title"] == src_title + " (副本)"]
|
||||
check("副本标题 = 源标题 + ' (副本)'", len(rows) == 1, str([s["title"] for s in sessions]))
|
||||
copy_id = rows[0]["id"] if rows else None
|
||||
|
||||
if copy_id:
|
||||
check("副本不是源(ID 不同)", copy_id != src)
|
||||
check("消息链完全一致", chain_sig(src) == chain_sig(copy_id))
|
||||
check("已自动切到副本", window.current_session_id == copy_id, str(window.current_session_id))
|
||||
check("侧边栏含副本项",
|
||||
any(window.history_list.item(i).data(QtCore.Qt.ItemDataRole.UserRole) == copy_id
|
||||
for i in range(window.history_list.count())))
|
||||
check("副本排在列表顶部(sort_order 最小)",
|
||||
sessions[0]["id"] == copy_id, sessions[0]["title"])
|
||||
check("副本不带星标", rows[0]["is_starred"] == 0)
|
||||
check("副本 mode 跟随源(未锁定为 None)",
|
||||
rows[0]["mode"] == window.db.get_session_mode(src))
|
||||
|
||||
# 附件深拷贝:新旧文件同时存在,且副本 metadata 指向新文件
|
||||
import json as _json
|
||||
c_rows = [m for m in window.db.get_message_chain(copy_id) if m.get("attachment_metadata")]
|
||||
c_meta = _json.loads(c_rows[-1]["attachment_metadata"])
|
||||
new_rel = c_meta["attachments"][0]["local_path"]
|
||||
check("副本附件已改名", new_rel != "data/attachments/ui_src.png", new_rel)
|
||||
check("新旧图片文件同时存在",
|
||||
os.path.isfile(png) and os.path.isfile(os.path.join(_FS_TMP, new_rel)))
|
||||
check("副本图片内容一致",
|
||||
open(os.path.join(_FS_TMP, new_rel), "rb").read()
|
||||
== open(png, "rb").read())
|
||||
|
||||
# 删副本后源完好(UI 链路下的删除隔离)
|
||||
window.db.delete_session(copy_id)
|
||||
check("删副本后源消息链不变", chain_sig(src) == [
|
||||
("system", "你是一个优秀的助手!"), ("user", "帮我看看这个文件"),
|
||||
("assistant", "好的,我先读一下"), ("user", "以及这张图")])
|
||||
check("删副本后源图片仍在", os.path.isfile(png))
|
||||
|
||||
# ======================================================================
|
||||
# 4) 生成中拒绝复制
|
||||
# ======================================================================
|
||||
n_before2 = len(window.db.get_all_sessions())
|
||||
window._active_streams[src] = {"worker": None}
|
||||
window.on_session_action("copy", src)
|
||||
check("会话生成中 → 拒绝复制(无新会话)",
|
||||
len(window.db.get_all_sessions()) == n_before2)
|
||||
window._active_streams.pop(src, None)
|
||||
|
||||
# 5) 源不存在 → 静默失败,不崩
|
||||
try:
|
||||
window.on_session_action("copy", "sess_not_exist_zzz")
|
||||
check("源不存在时不抛异常", True)
|
||||
except Exception as e:
|
||||
check("源不存在时不抛异常", False, str(e))
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
|
||||
shutil.rmtree(_FS_TMP, ignore_errors=True)
|
||||
if os.path.exists(_DB_TMP):
|
||||
os.remove(_DB_TMP)
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,280 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""四项 UI 增强回归测试(真实 WebEngine 页面):
|
||||
1. rAF 卡死模拟(AMD 核显/合成器不出帧)→ 40ms 兜底仍实时渲染流式正文
|
||||
2. 深度思考默认收起 + .streaming-think 蓝色动画类;收尾后标签还原
|
||||
3. bash chip 耗时徽章([exit 0] (1.2s))+ 超时徽章(命令超时(>120s))
|
||||
4. 长结果:默认尾部 4000 字 + 展开按钮;点击展开全文/再点收起;
|
||||
收起状态下 finish 后结果仍可见(修复「结束后展开为空」)
|
||||
运行: python tests/smoke_live_guard.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
|
||||
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)
|
||||
window = None
|
||||
results = []
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
# Phase 0: rAF + 所有页面定时器全灭(极端节流)→
|
||||
# 内容必须上屏:同步通道(token 内)+ Qt 看门狗(200ms,forceRenderNow)
|
||||
JS_PHASE_0A = r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
window.__realRAF = window.requestAnimationFrame;
|
||||
window.__realCAF = window.cancelAnimationFrame;
|
||||
window.__realTO = window.setTimeout;
|
||||
window.__realCTO = window.clearTimeout;
|
||||
window.__realSI = window.setInterval;
|
||||
window.__realCSI = window.clearInterval;
|
||||
window.__fakeRafId = 0;
|
||||
window.__fakeTimerId = 1000;
|
||||
window.requestAnimationFrame = function() { return ++window.__fakeRafId; };
|
||||
window.cancelAnimationFrame = function(){};
|
||||
window.setTimeout = function() { return ++window.__fakeTimerId; };
|
||||
window.clearTimeout = function(){};
|
||||
window.setInterval = function() { return ++window.__fakeTimerId; };
|
||||
window.clearInterval = function(){};
|
||||
|
||||
var m0 = 'lg-sync-' + Date.now();
|
||||
window.__lgM0 = m0;
|
||||
createMessage(m0, 'assistant', '', 'LG');
|
||||
appendReasoning(m0, 'sync think ');
|
||||
appendToken(m0, 'sync text one ');
|
||||
appendToken(m0, 'two');
|
||||
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
|
||||
var t = '';
|
||||
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
|
||||
out.textAtBurst = t;
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
JS_PHASE_0B = r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
var m0 = window.__lgM0;
|
||||
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
|
||||
var t = '';
|
||||
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
|
||||
var tcs = document.querySelectorAll('#' + m0 + ' .think-content');
|
||||
var th = '';
|
||||
for (var i = 0; i < tcs.length; i++) th += tcs[i].textContent || '';
|
||||
out.text = t;
|
||||
out.think = th;
|
||||
// 恢复页面定时器
|
||||
window.requestAnimationFrame = window.__realRAF;
|
||||
window.cancelAnimationFrame = window.__realCAF;
|
||||
window.setTimeout = window.__realTO;
|
||||
window.clearTimeout = window.__realCTO;
|
||||
window.setInterval = window.__realSI;
|
||||
window.clearInterval = window.__realCSI;
|
||||
finishMessage(m0);
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
# Phase A: 模拟 rAF 永不触发 + 流式 token;断言此刻 DOM 尚空(卡死生效)
|
||||
JS_PHASE_A = r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
window.__realRAF = window.requestAnimationFrame;
|
||||
window.__realCAF = window.cancelAnimationFrame;
|
||||
// 模拟真实卡死:rAF 返回真实挂起 ID(truthy)但回调永不被调用
|
||||
window.__fakeRafId = 0;
|
||||
window.requestAnimationFrame = function(cb) { window.__rafCalls = (window.__rafCalls||0)+1; return ++window.__fakeRafId; };
|
||||
window.cancelAnimationFrame = function(){};
|
||||
|
||||
var mid = 'lg-' + Date.now();
|
||||
window.__lgMid = mid;
|
||||
createMessage(mid, 'assistant', '', 'LG');
|
||||
appendReasoning(mid, 'deep ');
|
||||
appendReasoning(mid, 'thinking...');
|
||||
appendToken(mid, 'live text one ');
|
||||
appendToken(mid, 'two ');
|
||||
appendToken(mid, 'three');
|
||||
|
||||
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
|
||||
var domLen = 0;
|
||||
for (var i = 0; i < segs.length; i++)
|
||||
domLen += (segs[i].textContent || '').length;
|
||||
out.rafCalls = window.__rafCalls || 0;
|
||||
out.domLenBeforeTimers = domLen;
|
||||
|
||||
// 思考块状态(默认收起 + 蓝色动画类)
|
||||
var block = document.querySelector('#' + mid + ' .think-block');
|
||||
out.thinkOpen = block ? block.open : null;
|
||||
out.thinkStreaming = block ? block.classList.contains('streaming-think') : false;
|
||||
out.thinkLabel = block ? (block.querySelector('.think-label')||{}).textContent : null;
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
# Phase B(timers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性
|
||||
JS_PHASE_B = r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
var mid = window.__lgMid;
|
||||
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
|
||||
var domText = '';
|
||||
for (var i = 0; i < segs.length; i++) domText += segs[i].textContent || '';
|
||||
out.liveTextRendered = domText.indexOf('live text one two three') !== -1;
|
||||
|
||||
// 收尾:思考块标签还原 + 动画类移除
|
||||
finishMessage(mid);
|
||||
var block = document.querySelector('#' + mid + ' .think-block');
|
||||
out.thinkLabelAfter = block ? (block.querySelector('.think-label')||{}).textContent : null;
|
||||
out.thinkStreamingAfter = block ? block.classList.contains('streaming-think') : false;
|
||||
// 恢复 rAF
|
||||
window.requestAnimationFrame = window.__realRAF;
|
||||
window.cancelAnimationFrame = window.__realCAF;
|
||||
|
||||
// ---- 耗时 / 超时徽章 ----
|
||||
var mid2 = 'lg-timing-' + Date.now();
|
||||
createMessage(mid2, 'assistant', '', 'LG');
|
||||
toolExecutionStarted(mid2, 'call-ok', 'bash', JSON.stringify({command:'echo hi'}));
|
||||
toolExecutionFinished(mid2, 'call-ok', 'bash', true, '$ echo hi\nhi\n[exit 0] (1.2s)');
|
||||
toolExecutionStarted(mid2, 'call-tmo', 'bash', JSON.stringify({command:'sleep 999'}));
|
||||
toolExecutionFinished(mid2, 'call-tmo', 'bash', false, '命令超时(>120s)已终止');
|
||||
var w2 = document.getElementById(mid2);
|
||||
var chipOk = w2.querySelector('[data-call-id="call-ok"]');
|
||||
var chipTmo = w2.querySelector('[data-call-id="call-tmo"]');
|
||||
out.durBadge = chipOk ? (chipOk.querySelector('.tool-chip-time')||{}).textContent : null;
|
||||
out.tmoBadge = chipTmo ? (chipTmo.querySelector('.tool-chip-timeout')||{}).textContent : null;
|
||||
|
||||
// ---- 收起状态下 finish:结果必须已写入(展开可见) ----
|
||||
var livePre = chipOk ? chipOk.querySelector('.tool-chip-live') : null;
|
||||
out.resultWhileCollapsed = livePre ? (livePre.textContent || '').indexOf('hi') !== -1 : false;
|
||||
|
||||
// ---- 长结果:尾部预览 + 展开/收起 ----
|
||||
var longRes = 'L'.repeat(1000) + ' MIDDLE-MARKER ' + 'R'.repeat(5000); // 6015 字,标记在头部(预览窗外)
|
||||
toolExecutionStarted(mid2, 'call-long', 'read', JSON.stringify({path:'/big.txt'}));
|
||||
toolExecutionFinished(mid2, 'call-long', 'read', true, longRes);
|
||||
var chipLong = w2.querySelector('[data-call-id="call-long"]');
|
||||
var pre = chipLong ? chipLong.querySelector('.tool-chip-live') : null;
|
||||
var btn = chipLong ? chipLong.querySelector('.tool-expand-btn') : null;
|
||||
out.longHasBtn = !!btn;
|
||||
out.longPreviewLen = pre ? (pre.textContent || '').length : 0;
|
||||
out.longPreviewIsTail = pre ? (pre.textContent || '').lastIndexOf('R') > (pre.textContent||'').lastIndexOf('MIDDLE-MARKER') : false;
|
||||
out.longPreviewHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : true;
|
||||
if (btn) {
|
||||
btn.click();
|
||||
out.expandedHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : false;
|
||||
out.expandedLen = pre ? (pre.textContent || '').length : 0;
|
||||
out.fullClass = pre ? pre.classList.contains('tool-chip-full') : false;
|
||||
btn.click();
|
||||
out.collapseBackLen = pre ? (pre.textContent || '').length : 0;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
def step1():
|
||||
global window
|
||||
print("[step1] creating MainWindow...", flush=True)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
print("[step1] shown, scheduling step2", flush=True)
|
||||
QTimer.singleShot(3000, step2)
|
||||
|
||||
|
||||
def step2():
|
||||
print("[step2] phase 0: kill all page timers + burst tokens", flush=True)
|
||||
# 注册为当前会话的活跃流 → Qt 看门狗(200ms)会对它 forceRenderNow
|
||||
window.current_session_id = "fake-sync-sid"
|
||||
window._active_streams["fake-sync-sid"] = {
|
||||
"msg_id": None, "content": "pending", "reasoning": "",
|
||||
"timeline": [], "tl_kind": None, "parent_id": None,
|
||||
"branch_info": None, "worker": None, "previous_leaf_id": None,
|
||||
}
|
||||
window.browser.page().runJavaScript(JS_PHASE_0A, on_p0a)
|
||||
|
||||
|
||||
def on_p0a(res):
|
||||
a = json.loads(str(res))
|
||||
print(f" phase0 burst 瞬间: text={a['textAtBurst']!r}")
|
||||
# 把 JS 里生成的 mid 告诉看门狗(msg_id 需一致才会 forceRenderNow)
|
||||
# —— 简化:直接改 JS 端记录的 mid 对应的 buffer 即可,这里用 runJavaScript 对齐
|
||||
window.browser.page().runJavaScript(
|
||||
"window.__lgM0", lambda r: _align_and_wait(str(r)))
|
||||
|
||||
|
||||
def _align_and_wait(mid):
|
||||
mid = mid.strip().strip('"')
|
||||
window._active_streams["fake-sync-sid"]["msg_id"] = mid
|
||||
print("[phase0] 等待 Qt 看门狗渲染 (400ms) ...", flush=True)
|
||||
QTimer.singleShot(400, on_p0b)
|
||||
|
||||
|
||||
def on_p0b():
|
||||
window.browser.page().runJavaScript(JS_PHASE_0B, on_p0)
|
||||
|
||||
|
||||
def on_p0(res):
|
||||
p = json.loads(str(res))
|
||||
print(f" phase0 400ms 后: text={p['text']!r} think={p['think']!r}")
|
||||
check("定时器全灭: burst 内思考同步上屏", "sync think" in p["think"], str(p))
|
||||
check("定时器全灭: 正文经同步通道/看门狗上屏",
|
||||
"sync text one two" in p["text"], str(p))
|
||||
window._active_streams.pop("fake-sync-sid", None)
|
||||
window.browser.page().runJavaScript(JS_PHASE_A, on_a)
|
||||
|
||||
|
||||
def on_a(res):
|
||||
print("[on_a] got result:", str(res)[:120], flush=True)
|
||||
a = json.loads(str(res))
|
||||
print(f" phaseA: rafCalls={a['rafCalls']} domLenBefore={a['domLenBeforeTimers']}")
|
||||
# v7 起:首 token 插入前已同步渲染(:empty 布局失效修复的副作用——首字必现),
|
||||
# 故此刻 DOM 可能已有首 token 内容;本断言只验证 rAF 被调用但从未触发。
|
||||
check("rAF 被卡死(0 帧触发)", a["rafCalls"] > 0, str(a))
|
||||
check("思考块默认收起", a["thinkOpen"] is False)
|
||||
check("思考块带蓝色动画类 streaming-think", a["thinkStreaming"])
|
||||
QTimer.singleShot(300, step3)
|
||||
|
||||
|
||||
def step3():
|
||||
window.browser.page().runJavaScript(JS_PHASE_B, on_b)
|
||||
|
||||
|
||||
def on_b(res):
|
||||
b = json.loads(str(res))
|
||||
check("rAF 卡死时兜底仍实时渲染正文", b["liveTextRendered"], str(b))
|
||||
check("收尾后思考标签还原为『已完成深度思考』",
|
||||
b["thinkLabelAfter"] == "已完成深度思考", str(b["thinkLabelAfter"]))
|
||||
check("收尾后移除 streaming-think 动画类", b["thinkStreamingAfter"] is False)
|
||||
check("耗时徽章 ⏱ 1.2s", b["durBadge"] == "⏱ 1.2s", str(b["durBadge"]))
|
||||
check("超时徽章 ⏱ 超时 120s", b["tmoBadge"] == "⏱ 超时 120s", str(b["tmoBadge"]))
|
||||
check("收起状态下 finish 后结果仍可见(修复展开为空)", b["resultWhileCollapsed"])
|
||||
check("长结果有展开按钮", b["longHasBtn"])
|
||||
check("长结果默认只显示尾部 4002 字", b["longPreviewLen"] == 4002,
|
||||
str(b["longPreviewLen"]))
|
||||
check("尾部预览不含中段标记(标记在头部)", b["longPreviewIsTail"]
|
||||
and b["longPreviewHasMarker"] is False)
|
||||
check("点击展开全文(含中段标记 + full 类)",
|
||||
b["expandedHasMarker"] and b["fullClass"]
|
||||
and b["expandedLen"] > 5000, str(b))
|
||||
check("再点收回到尾部预览", b["collapseBackLen"] == 4002,
|
||||
str(b["collapseBackLen"]))
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, step1)
|
||||
app.exec()
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证人肉 debug 埋点链路:
|
||||
Python [正文]/[思考] 打印 + JS console 桥 → [JS] 打印 全部出现在控制台
|
||||
"""
|
||||
import os, sys
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import PyQt6.QtWebEngineWidgets # noqa
|
||||
from PyQt6.QtCore import QTimer
|
||||
from ui.views.main_window import MainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.resize(1280, 800)
|
||||
window.show()
|
||||
|
||||
MID = "manual-debug-test"
|
||||
|
||||
def step1():
|
||||
# 模拟一次真实的 send→token→finish 生命周期
|
||||
window.chat_bridge.create_message(MID, "assistant", "", "Test")
|
||||
window.chat_bridge.append_reasoning(MID, "这是思考内容A")
|
||||
window.chat_bridge.append_reasoning(MID, "思考B")
|
||||
window.chat_bridge.append_token(MID, "这是正文第一段。")
|
||||
window.chat_bridge.append_token(MID, "正文第二段来了。")
|
||||
window.chat_bridge.tool_execution_started(MID, "call-xyz", "bash", "echo hi")
|
||||
window.chat_bridge.tool_execution_finished(MID, "call-xyz", "bash", True, "hi\n[exit 0] (0.1s)")
|
||||
QTimer.singleShot(800, step2)
|
||||
|
||||
def step2():
|
||||
window.chat_bridge.finish_message(MID)
|
||||
QTimer.singleShot(1200, done)
|
||||
|
||||
def done():
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(2000, step1)
|
||||
app.exec()
|
||||
print("===== 测试结束(上方应看到 [生命周期]/[JS] 打印)=====")
|
||||
@@ -0,0 +1,203 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""中途切会话回归:agent 流式进行中切走 → 切回 → 时间线必须完整
|
||||
(用户报告:切走再切回,正文/思考那一段显示不出来)
|
||||
运行: python tests/smoke_midswitch.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
|
||||
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
_TMP = isolate("midswitch") # noqa: E402
|
||||
|
||||
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)
|
||||
window = None
|
||||
results = []
|
||||
S = {} # 测试状态
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
CHECK_JS = r"""
|
||||
(function() {
|
||||
var mid = window.__msMid;
|
||||
var w = document.getElementById(mid);
|
||||
if (!w) return JSON.stringify({error: 'wrapper missing'});
|
||||
var tl = w.querySelector('.reply-content');
|
||||
var blocks = Array.prototype.map.call(tl.children, function(el) {
|
||||
return el.className.split(' ')[0];
|
||||
});
|
||||
var texts = Array.prototype.map.call(
|
||||
w.querySelectorAll('.md-segment'),
|
||||
function(s) { return s.textContent || ''; });
|
||||
var thinks = Array.prototype.map.call(
|
||||
w.querySelectorAll('.think-content'),
|
||||
function(s) { return s.textContent || ''; });
|
||||
return JSON.stringify({blocks: blocks, texts: texts, thinks: thinks,
|
||||
streaming: w.classList.contains('streaming')});
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
def fire(session, fn, *args):
|
||||
"""模拟 worker 信号到达(UI 线程直接调 handler)"""
|
||||
getattr(window, fn)(*([session] + list(args)))
|
||||
|
||||
|
||||
def step1():
|
||||
global window
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
QTimer.singleShot(3000, step2)
|
||||
|
||||
|
||||
def step2():
|
||||
db = window.db
|
||||
sessA = db.create_session("切走测试A")
|
||||
sessB = db.create_session("切走测试B")
|
||||
A, B = sessA["id"], sessB["id"]
|
||||
db.add_message(B, "user", "B 的问题", None)
|
||||
|
||||
# ---- 会话 A 开始流式(手动模拟事件到达)----
|
||||
user_row = db.add_message(A, "user", "A 的问题", None)
|
||||
mid = "msg-midswitch"
|
||||
window.current_session_id = A
|
||||
window.chat_bridge.create_message(mid, "assistant", "", "SW")
|
||||
window._active_streams[A] = {
|
||||
"msg_id": mid, "content": "", "reasoning": "",
|
||||
"timeline": [], "tl_kind": None,
|
||||
"parent_id": user_row["id"], "branch_info": None, "worker": None,
|
||||
"previous_leaf_id": user_row["id"],
|
||||
}
|
||||
S.update(A=A, B=B, mid=mid)
|
||||
window.browser.page().runJavaScript(f"window.__msMid = '{mid}';")
|
||||
|
||||
# 阶段 1(A 前台):think1 + text1 + 工具
|
||||
fire(A, "on_reasoning_received", "思考第一")
|
||||
fire(A, "on_reasoning_received", "段内容")
|
||||
fire(A, "on_chunk_received", "工具前正文")
|
||||
fire(A, "_on_tool_started", "call-ms", "bash", '{"command":"echo ms"}')
|
||||
|
||||
QTimer.singleShot(800, switch_away)
|
||||
|
||||
|
||||
def switch_away():
|
||||
"""切到 B:A 的流继续在后台跑"""
|
||||
print("[test] 切换到 B ...", flush=True)
|
||||
window.load_messages_to_web(S["B"])
|
||||
# 阶段 2(A 后台):think2 + text2(只应累积,不进 JS)
|
||||
QTimer.singleShot(600, lambda: (
|
||||
fire(S["A"], "on_reasoning_received", "思考第二"),
|
||||
fire(S["A"], "on_chunk_received", "工具后正文"),
|
||||
print("[test] 后台 token 已累积", flush=True),
|
||||
QTimer.singleShot(600, switch_back)
|
||||
))
|
||||
|
||||
|
||||
def switch_back():
|
||||
"""切回 A:应重建 wrapper + 恢复时间线 + 后续无缝续流"""
|
||||
print("[test] 切回 A ...", flush=True)
|
||||
window.load_messages_to_web(S["A"])
|
||||
# ★ 切回后立即检查:恢复的正文/思考必须已同步上屏(不等 rAF/timer)
|
||||
def check_right_away():
|
||||
window.browser.page().runJavaScript(CHECK_JS, lambda res: (
|
||||
print("[切回后立即]", str(res), flush=True),
|
||||
check("切回后立即: 后台正文已同步上屏",
|
||||
"工具后正文" in json.loads(str(res)).get("texts", [""] + [json.loads(str(res))["texts"][0] if json.loads(str(res)).get("texts") else ""])[0] or "工具后正文" in "".join(json.loads(str(res)).get("texts", []))),
|
||||
QTimer.singleShot(300, phase3)
|
||||
))
|
||||
def phase3():
|
||||
fire(S["A"], "on_chunk_received", " 续流正文")
|
||||
QTimer.singleShot(600, finish_it)
|
||||
QTimer.singleShot(400, check_right_away)
|
||||
|
||||
|
||||
def finish_it():
|
||||
window.on_reply_finished(S["A"])
|
||||
QTimer.singleShot(400, step_check)
|
||||
|
||||
|
||||
DIAG_JS = r"""
|
||||
(function() {
|
||||
var wrappers = Array.prototype.map.call(
|
||||
document.querySelectorAll('.message-wrapper'),
|
||||
function(w) { return w.id + ':' + w.className.split(' ')[1]; });
|
||||
return JSON.stringify({wrappers: wrappers,
|
||||
bufKeys: Object.keys(window.messageBuffer || {})});
|
||||
})()
|
||||
"""
|
||||
|
||||
def step_check():
|
||||
def diag(res):
|
||||
print("[diag]", str(res), flush=True)
|
||||
window.browser.page().runJavaScript(CHECK_JS, on_check)
|
||||
window.browser.page().runJavaScript(DIAG_JS, diag)
|
||||
|
||||
|
||||
def on_check(res):
|
||||
d = json.loads(str(res))
|
||||
if "error" in d:
|
||||
check("切回后 wrapper 存在", False, d["error"])
|
||||
done()
|
||||
return
|
||||
blocks = d["blocks"]
|
||||
texts = "".join(d["texts"])
|
||||
thinks = "".join(d["thinks"])
|
||||
print(f" blocks = {blocks}")
|
||||
print(f" texts = {d['texts']}")
|
||||
print(f" thinks = {d['thinks']}")
|
||||
check("切回后 wrapper 存在", True)
|
||||
check("块顺序与事件顺序一致: think→text→chip→think→text",
|
||||
[b for b in blocks if b in
|
||||
("think-block", "tool-chip", "md-segment")] ==
|
||||
["think-block", "md-segment", "tool-chip", "think-block", "md-segment"],
|
||||
str(blocks))
|
||||
check("正文完整(切走前+后台+切回后)",
|
||||
"工具前正文" in texts and "工具后正文" in texts
|
||||
and "续流正文" in texts, str(d["texts"]))
|
||||
check("思考完整(切走前+后台)",
|
||||
"思考第一段内容" in thinks and "思考第二" in thinks,
|
||||
str(d["thinks"]))
|
||||
check("streaming 已收尾", not d["streaming"])
|
||||
# 入库验证
|
||||
row = window.db.get_message_chain(S["A"])
|
||||
asst = [m for m in row if m["role"] == "assistant"]
|
||||
ok_db = False
|
||||
if asst:
|
||||
tl = asst[-1].get("timeline")
|
||||
if tl:
|
||||
tl_list = json.loads(tl)
|
||||
joined = "".join(e.get("text", "") for e in tl_list if e["t"] == "text")
|
||||
ok_db = ("工具前正文" in joined and "工具后正文" in joined
|
||||
and "续流正文" in joined)
|
||||
check("DB 时间线完整(三段正文都在)", ok_db)
|
||||
done()
|
||||
|
||||
|
||||
def done():
|
||||
try:
|
||||
window.db.delete_session(S["A"])
|
||||
window.db.delete_session(S["B"])
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, step1)
|
||||
app.exec()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""offscreen 模式切换功能测试:DB 持久化 + 首条消息锁定 + worker 分派
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_mode.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
|
||||
import tempfile as _tf # noqa: E402
|
||||
import core.db_manager as _dbm # noqa: E402
|
||||
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_mode_{os.getpid()}.db")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 须先于 QApplication import 完成)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
|
||||
# 本套件验证「默认锁定」语义:显式置 mode_switch=false,不受本机 data/config.json 影响
|
||||
# (仅内存,不会写回配置文件)
|
||||
window.config_data["mode_switch"] = False
|
||||
window._refresh_mode_button()
|
||||
|
||||
# 1) 新建会话 → 未锁定
|
||||
window.on_new_chat_clicked()
|
||||
sid = window.current_session_id
|
||||
check("新会话未锁定 (mode=None)", window._get_current_mode() is None)
|
||||
check("按钮显示待选 Chat", "Chat" in window.btn_mode.text() and "🔒" not in window.btn_mode.text())
|
||||
|
||||
# 2) 浮动弹窗开/关 + 选择
|
||||
window.show_mode_popup()
|
||||
check("弹窗打开", window.mode_popup.isVisible())
|
||||
window._select_mode("worker")
|
||||
check("选 worker 后按钮更新", "Worker" in window.btn_mode.text())
|
||||
check("选择后弹窗自动关闭", not window.mode_popup.isVisible())
|
||||
|
||||
# 3) 首条发送 → 锁定
|
||||
window._lock_session_mode()
|
||||
check("发送后锁定 worker", window.db.get_session_mode(sid) == "worker")
|
||||
check("按钮显示锁定", "🔒" in window.btn_mode.text())
|
||||
|
||||
# 4) 锁定后不可改
|
||||
window._select_mode("chat")
|
||||
check("锁定后选择无效", window.db.get_session_mode(sid) == "worker")
|
||||
|
||||
# 5) worker 分派
|
||||
w1 = window._create_stream_worker([], "worker")
|
||||
w2 = window._create_stream_worker([], "chat")
|
||||
from core.llm_engine import AgentWorker, ChatWorker # noqa: E402
|
||||
check("worker 模式 → AgentWorker", isinstance(w1, AgentWorker))
|
||||
check("chat 模式 → ChatWorker", isinstance(w2, ChatWorker))
|
||||
check("ChatWorker 有基础信号", all(hasattr(w2, s) for s in
|
||||
("chunk_received", "reasoning_received", "error_occurred")))
|
||||
check("ChatWorker 无工具信号(chat 模式不暴露)",
|
||||
not hasattr(w2, "tool_execution_started"))
|
||||
|
||||
# 6) 切回新会话 → 解锁
|
||||
window.on_new_chat_clicked()
|
||||
check("新会话恢复可选", window._get_current_mode() is None and "🔒" not in window.btn_mode.text())
|
||||
|
||||
# 7) 旧会话(无 mode 列值)→ 默认 chat 路径
|
||||
window.load_messages_to_web(sid)
|
||||
check("切回已锁定会话显示锁定", window._get_current_mode() == "worker" and "🔒" in window.btn_mode.text())
|
||||
|
||||
# 8) mode_switch=true → 中途切换放行(旧锁定会话解锁 + 选择立即落库)
|
||||
window.config_data["mode_switch"] = True
|
||||
window._refresh_mode_button()
|
||||
check("开关开启后旧会话解锁显示", "🔒" not in window.btn_mode.text())
|
||||
window._select_mode("chat")
|
||||
check("开关开启后中途切换生效", window.db.get_session_mode(sid) == "chat")
|
||||
window.config_data["mode_switch"] = False
|
||||
|
||||
try:
|
||||
window.close()
|
||||
except Exception:
|
||||
pass
|
||||
app.quit()
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====")
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""offscreen 冒烟测试:主窗口实例化 + 核心链路(不启动真实 LLM)
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
|
||||
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" # 绕过 AMD 核显 context lost # 软渲染,离屏最稳
|
||||
|
||||
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
|
||||
import tempfile as _tf # noqa: E402
|
||||
import core.db_manager as _dbm # noqa: E402
|
||||
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db")
|
||||
|
||||
# P1-03:QtWebEngine 独立 profile 重定向到临时目录(不占项目 data/webengine/)
|
||||
os.environ.setdefault(
|
||||
"HAOCODE_WEBENGINE_PROFILE_DIR",
|
||||
os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}_profile"))
|
||||
|
||||
import ctypes # noqa: E402
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtCore import QTimer # noqa: E402
|
||||
|
||||
# QtWebEngine 必须在 QApplication 创建前 import
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
def check(name, fn):
|
||||
try:
|
||||
fn()
|
||||
print(f" PASS {name}")
|
||||
results.append(True)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f" FAIL {name}: {e}")
|
||||
results.append(False)
|
||||
|
||||
|
||||
window = None
|
||||
page_ready = {"ok": False}
|
||||
|
||||
|
||||
def on_page_load_progress(v):
|
||||
pass
|
||||
|
||||
|
||||
def on_js_console(level, msg, line, src):
|
||||
pass
|
||||
|
||||
|
||||
def try_load():
|
||||
global window
|
||||
try:
|
||||
window = MainWindow()
|
||||
page_ready["ok"] = True
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finish(False)
|
||||
return
|
||||
# MainWindow 构造完成时 HTML/JS 已就绪(loadFinished 可能早于连接)
|
||||
QTimer.singleShot(6000, run_checks) # 等 JS 引擎 + 历史渲染完成
|
||||
|
||||
|
||||
def run_checks():
|
||||
# 1) 窗口已创建
|
||||
check("MainWindow 实例化", lambda: (_ for _ in ()).throw(AssertionError("no window")) if window is None else None)
|
||||
|
||||
# 2) DB 链路
|
||||
def db_chain():
|
||||
assert window.db is not None
|
||||
sessions = window.db.get_all_sessions()
|
||||
assert isinstance(sessions, list)
|
||||
|
||||
check("DB 会话列表", db_chain)
|
||||
|
||||
# 3) build_api_context(P0 修复验证:不再有 reasoning 字段)
|
||||
def ctx_build():
|
||||
sid = window.current_session_id
|
||||
if not sid:
|
||||
return
|
||||
msgs = window.build_api_context(sid)
|
||||
assert isinstance(msgs, list)
|
||||
for m in msgs:
|
||||
assert "reasoning" not in m, f"reasoning 字段仍在 API payload 里: {m.get('role')}"
|
||||
|
||||
check("build_api_context 无 reasoning 字段", ctx_build)
|
||||
|
||||
# 4) CJK token 估算
|
||||
def est():
|
||||
n = window._estimate_token_count([
|
||||
{"role": "user", "content": "你好,世界!这是一段中文测试。"},
|
||||
{"role": "assistant", "content": "hello world " * 20},
|
||||
])
|
||||
assert isinstance(n, int) and n > 0
|
||||
|
||||
check("CJK token 估算", est)
|
||||
|
||||
# 5) AgentWorker 可构造 + 信号齐全
|
||||
def worker():
|
||||
from core.llm_engine import AgentWorker, TitleWorker
|
||||
w = AgentWorker(window.current_provider, window.current_model,
|
||||
[{"role": "user", "content": "x"}])
|
||||
for sig in ("chunk_received", "reasoning_received", "error_occurred",
|
||||
"tool_execution_started", "tool_execution_updated",
|
||||
"tool_execution_finished", "context_compacted"):
|
||||
assert hasattr(w, sig), sig
|
||||
t = TitleWorker(window.current_provider, window.current_model,
|
||||
[{"role": "user", "content": "x"}])
|
||||
assert hasattr(t, "chunk_received")
|
||||
|
||||
check("AgentWorker/TitleWorker 构造", worker)
|
||||
|
||||
# 6) bridge 工具方法
|
||||
def bridge():
|
||||
b = window.chat_bridge
|
||||
for m in ("tool_execution_started", "tool_execution_updated",
|
||||
"tool_execution_finished", "show_note"):
|
||||
assert hasattr(b, m), m
|
||||
|
||||
check("ChatBridge 工具事件方法", bridge)
|
||||
|
||||
# 7) 离屏渲染 JS 就绪
|
||||
check("Web 页面加载完成", lambda: (_ for _ in ()).throw(AssertionError("page not ready")) if not page_ready["ok"] else None)
|
||||
|
||||
# 8) 🌟 KaTeX 公式渲染(真实页面上下文:资源加载 + [...] 供应商格式 + 行内 $)
|
||||
def _run_js(js, timeout_s=10):
|
||||
result = {"val": None, "done": False}
|
||||
|
||||
def on_ret(val):
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
val = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
result["val"] = val
|
||||
result["done"] = True
|
||||
|
||||
if hasattr(window.browser, "execute_js_async"): # WebView2 路径(cb 收 JSON 字符串)
|
||||
window.browser.execute_js_async(js, on_ret)
|
||||
else: # QtWebEngine 路径(cb 收 Python 对象)
|
||||
window.browser.page().runJavaScript(js, on_ret)
|
||||
t0 = time.time()
|
||||
while not result["done"] and time.time() - t0 < timeout_s:
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
assert result["done"], "JS 执行超时"
|
||||
return result["val"]
|
||||
|
||||
def katex_render():
|
||||
# 等页面 JS 就绪(WV2 冷启动可能慢;app.js 就绪时置 window.jsReady=true)
|
||||
for _ in range(30):
|
||||
if _run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise AssertionError("页面 JS 未就绪(jsReady)")
|
||||
ver = _run_js("typeof katex !== 'undefined' ? katex.version : null")
|
||||
assert ver, "katex 全局缺失(资源加载失败?)"
|
||||
js = ("(function(){var s = '结果:" + chr(92) + "n[" + chr(92) + "nP_4=" + chr(92)*2 + "operatorname{BRF}(M_4,M_5)" + chr(92) + "n]" + chr(92) + "n" + chr(92) + "n行内 $E=mc^2$ 结束。';"
|
||||
"var html = safeHtml(marked.parse(s));return {"
|
||||
"has: html.indexOf('katex') !== -1,"
|
||||
"disp: (html.match(/katex-display/g) || []).length,"
|
||||
'inline: (html.match(/class=\"katex\"/g) || []).length,'
|
||||
"leaked: html.indexOf('@@K') !== -1,"
|
||||
"vis: html.indexOf('katex-html') !== -1,"
|
||||
"tex: html.indexOf('\\operatorname{BRF}') !== -1};})()")
|
||||
r = _run_js(js)
|
||||
assert isinstance(r, dict), f"意外返回: {r!r}"
|
||||
assert r.get("has"), "未产生 KaTeX HTML"
|
||||
assert r.get("disp", 0) >= 1, "块公式未渲染为 katex-display"
|
||||
assert r.get("inline", 0) >= 1, "行内公式未渲染"
|
||||
assert not r.get("leaked"), "占位符泄漏"
|
||||
assert r.get("vis"), "缺少 katex-html 可视层(KaTeX 未真正渲染)"
|
||||
assert r.get("tex"), "tex 未正确传入(转义错误)"
|
||||
print(f" [info] KaTeX {ver} | display={r.get('disp')} inline={r.get('inline')}")
|
||||
|
||||
check("KaTeX 公式渲染([...] 供应商格式 + 行内 $)", katex_render)
|
||||
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
|
||||
try:
|
||||
window.close()
|
||||
except Exception:
|
||||
pass
|
||||
app.quit()
|
||||
|
||||
|
||||
def finish(ok=None):
|
||||
app.quit()
|
||||
|
||||
|
||||
QTimer.singleShot(500, try_load)
|
||||
QTimer.singleShot(90000, finish) # 总超时
|
||||
app.exec()
|
||||
sys.exit(0 if all(results) else 1)
|
||||
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""时间线持久化冒烟测试(真实 DB + 真实 WebEngine 页面):
|
||||
1. 流式事件 → stream_state 时间线累积(思考/文本/工具 按序)
|
||||
2. 入库(timeline 列)→ 切会话重载 → DOM 时间线还原(工具气泡不丢)
|
||||
3. build_api_context 从时间线重建完整 API 链(assistant+tool_calls+tool)
|
||||
4. 切回进行中的会话:restoreStreamingTimeline 续流
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_persist.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
|
||||
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 = []
|
||||
window = None
|
||||
test = {"sid": None, "mid": None, "done": False}
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
def finish():
|
||||
if test["done"]:
|
||||
return
|
||||
test["done"] = True
|
||||
# 清理测试会话
|
||||
try:
|
||||
if test["sid"] and window:
|
||||
window.db.delete_session(test["sid"])
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
# JS: 校验重载后的时间线 DOM
|
||||
JS_VERIFY_RELOAD = r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
try {
|
||||
var mid = '__MID__';
|
||||
var wrapper = document.getElementById(mid);
|
||||
if (!wrapper) return JSON.stringify({error: 'wrapper missing'});
|
||||
var tl = wrapper.querySelector('.reply-content');
|
||||
out.blocks = Array.prototype.map.call(tl.children, function(el) {
|
||||
return el.className.split(' ')[0];
|
||||
});
|
||||
var chip = wrapper.querySelector('.tool-chip');
|
||||
var st = chip ? chip.querySelector('.tool-chip-status') : null;
|
||||
out.chipStatus = st ? st.textContent : null;
|
||||
out.chipOk = st ? st.classList.contains('ok') : false;
|
||||
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
|
||||
var segs = wrapper.querySelectorAll('.md-segment');
|
||||
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
|
||||
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
|
||||
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
|
||||
} catch (e) {
|
||||
out.error = String(e);
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
# JS: 续流测试
|
||||
JS_RESUME_A = r"""
|
||||
(function() {
|
||||
try {
|
||||
var mid = '__MID__';
|
||||
createMessage(mid, 'assistant', '', 'Resume');
|
||||
restoreStreamingTimeline(mid, '__TLJSON__');
|
||||
// 续流:接着最后一段文本写;思考新开一段
|
||||
appendToken(mid, '(续流文本)');
|
||||
appendReasoning(mid, '续思考内容');
|
||||
return 'resumeA-done';
|
||||
} catch (e) {
|
||||
return 'resumeA-err:' + String(e);
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
JS_RESUME_B = r"""
|
||||
(function() {
|
||||
try {
|
||||
var mid = '__MID__';
|
||||
finishMessage(mid);
|
||||
var wrapper = document.getElementById(mid);
|
||||
var tl = wrapper.querySelector('.reply-content');
|
||||
var out = {};
|
||||
out.blocks = Array.prototype.map.call(tl.children, function(el) {
|
||||
return el.className.split(' ')[0];
|
||||
});
|
||||
var segs = wrapper.querySelectorAll('.md-segment');
|
||||
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
|
||||
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
|
||||
out.chipCount = wrapper.querySelectorAll('.tool-chip').length;
|
||||
wrapper.parentNode.removeChild(wrapper);
|
||||
return JSON.stringify(out);
|
||||
} catch (e) {
|
||||
return 'resumeB-err:' + String(e);
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
|
||||
def run_checks():
|
||||
db = window.db
|
||||
|
||||
# ---------- 建测试会话 + 模拟流式事件累积 ----------
|
||||
sess = db.create_session("持久化测试会话")
|
||||
sid = sess["id"]
|
||||
test["sid"] = sid
|
||||
mid = "msg-persist-test"
|
||||
test["mid"] = mid
|
||||
|
||||
user_row = db.add_message(sid, "user", "测试问题", None)
|
||||
user_id = user_row["id"]
|
||||
|
||||
window.current_session_id = sid
|
||||
window.chat_bridge.create_message(mid, "assistant", "", "PersistTest")
|
||||
|
||||
st = {
|
||||
"msg_id": mid, "content": "", "reasoning": "",
|
||||
"timeline": [], "tl_kind": None,
|
||||
"parent_id": user_id, "branch_info": None, "worker": None,
|
||||
"previous_leaf_id": user_id,
|
||||
}
|
||||
window._active_streams[sid] = st
|
||||
|
||||
# 事件序列:思考 → 文本 → 工具 → 思考 → 文本
|
||||
window.on_reasoning_received(sid, "先看一下")
|
||||
window.on_reasoning_received(sid, "目录结构。")
|
||||
window.on_chunk_received(sid, "我来执行")
|
||||
window._on_tool_started(sid, "call-p1", "bash", '{"command": "echo hi"}')
|
||||
window._on_tool_updated(sid, "call-p1", "hi\n")
|
||||
window._on_tool_finished(sid, "call-p1", "bash", True,
|
||||
"$ echo hi\nhi\n[exit 0]")
|
||||
window.on_reasoning_received(sid, "输出正常。")
|
||||
window.on_chunk_received(sid, "任务完成。")
|
||||
|
||||
tl = st["timeline"]
|
||||
check("时间线累积: 5 段", len(tl) == 5, str(tl))
|
||||
check("时间线类型序 think/text/tool/think/text",
|
||||
[e["t"] for e in tl] == ["think", "text", "tool", "think", "text"],
|
||||
str([e["t"] for e in tl]))
|
||||
tool_e = tl[2] if len(tl) > 2 else {}
|
||||
check("工具条目定格 ok+result",
|
||||
tool_e.get("ok") is True and "[exit 0]" in tool_e.get("result", ""),
|
||||
str(tool_e))
|
||||
check("聚合 content/reasoning 正确",
|
||||
st["content"] == "我来执行任务完成。"
|
||||
and st["reasoning"] == "先看一下目录结构。输出正常。",
|
||||
f"{st['content']!r} / {st['reasoning']!r}")
|
||||
|
||||
tl_json = json.dumps(tl, ensure_ascii=False)
|
||||
|
||||
# ---------- 入库 ----------
|
||||
row = db.add_message(sid, "assistant", st["content"], user_id,
|
||||
reasoning=st["reasoning"], msg_id=mid,
|
||||
timeline=tl_json)
|
||||
check("DB timeline 列写入", row.get("timeline") == tl_json)
|
||||
|
||||
# ---------- 切走再切回(重载) ----------
|
||||
del window._active_streams[sid] # 模拟流已结束(先于重载,避免 live-restore 重复渲染)
|
||||
window.load_messages_to_web(sid, show_loading=False)
|
||||
|
||||
def verify_reload(res):
|
||||
data = json.loads(str(res))
|
||||
if "error" in data:
|
||||
check("重载 DOM 校验", False, data["error"])
|
||||
finish()
|
||||
return
|
||||
expected = ["think-block", "md-segment", "tool-chip",
|
||||
"think-block", "md-segment"]
|
||||
check("重载: 时间线块序还原", data["blocks"] == expected,
|
||||
str(data["blocks"]))
|
||||
check("重载: 工具气泡保留且成功",
|
||||
data["chipOk"] and data["chipStatus"] == "✓ 完成"
|
||||
and data["chipCallId"] == "call-p1",
|
||||
f"{data['chipStatus']} {data['chipCallId']}")
|
||||
check("重载: 思考块 x2 + 末段文本",
|
||||
data["thinkCount"] == 2 and "任务完成。" in data["lastSegText"],
|
||||
f"think={data['thinkCount']} seg={data['lastSegText']!r}")
|
||||
check("重载: SVG 箭头", data["thinkChevron"])
|
||||
|
||||
# ---------- build_api_context 重建 ----------
|
||||
api = window.build_api_context(sid)
|
||||
roles = [m["role"] for m in api]
|
||||
check("API 链: user→assistant→tool→assistant",
|
||||
roles == ["user", "assistant", "tool", "assistant"],
|
||||
str(roles))
|
||||
asst1 = api[1] if len(api) > 1 else {}
|
||||
check("API: 首条 assistant 带 tool_calls",
|
||||
asst1.get("content") == "我来执行"
|
||||
and len(asst1.get("tool_calls", [])) == 1
|
||||
and asst1["tool_calls"][0]["function"]["name"] == "bash",
|
||||
str(asst1)[:200])
|
||||
check("API: tool 消息 tool_call_id 对齐",
|
||||
len(api) > 2 and api[2].get("tool_call_id") == "call-p1"
|
||||
and "[exit 0]" in api[2].get("content", ""),
|
||||
str(api[2] if len(api) > 2 else {})[:200])
|
||||
check("API: 末条 assistant = 最终回答",
|
||||
len(api) > 3 and api[3].get("content") == "任务完成。")
|
||||
check("API: 无 reasoning 字段",
|
||||
all("reasoning" not in m for m in api))
|
||||
|
||||
# ---------- 切回进行中会话: 续流 ----------
|
||||
tljs = json.dumps(tl_json)
|
||||
js_a = (JS_RESUME_A
|
||||
.replace("'__MID__'", "'msg-resume-test'")
|
||||
.replace("restoreStreamingTimeline(mid, '__TLJSON__')",
|
||||
f"restoreStreamingTimeline(mid, {tljs})"))
|
||||
js_b = JS_RESUME_B.replace("'__MID__'", "'msg-resume-test'")
|
||||
|
||||
def verify_resume_data(d2):
|
||||
if "error" in d2:
|
||||
check("续流 DOM 校验", False, d2["error"])
|
||||
finish()
|
||||
return
|
||||
expected2 = ["think-block", "md-segment", "tool-chip",
|
||||
"think-block", "md-segment", "think-block"]
|
||||
check("续流: 块序正确", d2["blocks"] == expected2,
|
||||
str(d2["blocks"]))
|
||||
check("续流: 文本并入末段",
|
||||
"任务完成。(续流文本)" in d2["lastSegText"],
|
||||
d2["lastSegText"])
|
||||
check("续流: 思考 x3 + 工具 x1",
|
||||
d2["thinkCount"] == 3 and d2["chipCount"] == 1,
|
||||
f"think={d2['thinkCount']} chip={d2['chipCount']}")
|
||||
finish()
|
||||
|
||||
def run_b():
|
||||
def got_b(v):
|
||||
vstr = "" if v is None else str(v)
|
||||
print(f" [resume B] {vstr[:120]}")
|
||||
if vstr.startswith("resumeB-err"):
|
||||
check("续流 finishMessage", False, vstr)
|
||||
finish()
|
||||
return
|
||||
try:
|
||||
d2 = json.loads(vstr)
|
||||
except Exception:
|
||||
check("续流 DOM 校验", False, repr(vstr)[:120])
|
||||
finish()
|
||||
return
|
||||
verify_resume_data(d2)
|
||||
window.browser.page().runJavaScript(js_b, got_b)
|
||||
|
||||
def run_a():
|
||||
def got_a(v):
|
||||
vstr = "" if v is None else str(v)
|
||||
print(f" [resume A] {vstr[:120]}")
|
||||
if vstr.startswith("resumeA-err"):
|
||||
check("续流 restore", False, vstr)
|
||||
finish()
|
||||
return
|
||||
QTimer.singleShot(300, run_b)
|
||||
window.browser.page().runJavaScript(js_a, got_a)
|
||||
|
||||
QTimer.singleShot(300, run_a)
|
||||
|
||||
|
||||
|
||||
window.browser.page().runJavaScript(
|
||||
JS_VERIFY_RELOAD.replace("'__MID__'", f"'{mid}'"), verify_reload)
|
||||
|
||||
|
||||
def main():
|
||||
global window
|
||||
try:
|
||||
window = MainWindow()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
app.quit()
|
||||
return
|
||||
QTimer.singleShot(6000, run_checks)
|
||||
QTimer.singleShot(45000, lambda: (check("超时", False, "45s 未完成"),
|
||||
finish()) if not test["done"] else None)
|
||||
app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证 probeStream 探针在真实 WebEngine 页返回正确数据"""
|
||||
import os, sys
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import PyQt6.QtWebEngineWidgets # noqa
|
||||
from PyQt6.QtCore import QTimer
|
||||
from ui.views.main_window import MainWindow
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {detail}")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.resize(1280, 800)
|
||||
window.show()
|
||||
|
||||
PROBE_JS = r"""
|
||||
(function() {
|
||||
var m = 'probe-test-' + Date.now();
|
||||
createMessage(m, 'assistant', '', 'LG');
|
||||
appendReasoning(m, '思考探针内容');
|
||||
appendToken(m, '正文探针 ');
|
||||
appendToken(m, '第二段');
|
||||
// 同步强制渲染
|
||||
if (typeof forceRenderNow === 'function') forceRenderNow(m);
|
||||
var res = probeStream(m);
|
||||
finishMessage(m);
|
||||
return res;
|
||||
})()
|
||||
"""
|
||||
|
||||
def step1():
|
||||
window.browser.page().runJavaScript(PROBE_JS, on_probe)
|
||||
|
||||
def on_probe(res):
|
||||
import json
|
||||
d = json.loads(str(res))
|
||||
print(" probe:", str(d)[:300])
|
||||
check("probe 返回缓冲段", len(d.get("segs", [])) >= 1, str(d))
|
||||
if d.get("segs"):
|
||||
s0 = d["segs"][0]
|
||||
check("缓冲长度>0", s0["b"] > 0, str(s0))
|
||||
check("DOM 已写入", s0["d"] > 0, str(s0))
|
||||
check("思考段探针", len(d.get("thinks", [])) >= 1, str(d))
|
||||
check("无 JS 异常", "err" not in d, str(d))
|
||||
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(1500, step1)
|
||||
app.exec()
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用 DB 里用户真实消息内容回放流式渲染,验证新路径是否正确。
|
||||
每个 chunk 走独立 runJavaScript(模拟 Python 逐 token 推送的真实路径)。"""
|
||||
import os, sys, json, sqlite3
|
||||
# 不加 --disable-gpu:复现真实应用的 GPU 渲染环境
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import PyQt6.QtWebEngineWidgets # noqa
|
||||
from PyQt6.QtCore import QTimer
|
||||
from ui.views.main_window import MainWindow
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {detail}")
|
||||
|
||||
def esc(s):
|
||||
return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||
|
||||
# 取用户真实消息
|
||||
conn = sqlite3.connect(os.path.join(ROOT, "data", "chat_history.db"))
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT content, timeline FROM messages WHERE id LIKE 'msg-99235c06%' "
|
||||
"ORDER BY created_at DESC LIMIT 1").fetchone()
|
||||
assert row, "DB 中找不到测试消息"
|
||||
timeline = json.loads(row["timeline"] or "[]")
|
||||
print(f"消息: content={len(row['content'])}c, timeline={len(timeline)} 条")
|
||||
|
||||
MID = "repro-real"
|
||||
# 构造回放脚本序列:think/text 按 20 字符切块,每批 20 个 chunk 一次 JS 调用
|
||||
CHUNK = 20
|
||||
BATCH = 20
|
||||
steps = [] # JS 片段列表
|
||||
for e in timeline:
|
||||
t = e.get("t")
|
||||
txt = e.get("text", "")
|
||||
if t in ("think", "text") and txt:
|
||||
for i in range(0, len(txt), CHUNK):
|
||||
fn = "appendReasoning" if t == "think" else "appendToken"
|
||||
steps.append(f"{fn}('{MID}', '{esc(txt[i:i+CHUNK])}');")
|
||||
steps_batched = [chr(10).join(steps[i:i+BATCH]) for i in range(0, len(steps), BATCH)]
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.resize(1280, 800)
|
||||
window.show()
|
||||
|
||||
state = {"idx": 0, "probed": False}
|
||||
|
||||
def next_batch():
|
||||
"""一次送一批(20 个 chunk 拼接在一个 JS 任务里)"""
|
||||
if state["idx"] >= len(steps_batched):
|
||||
QTimer.singleShot(300, mid_probe)
|
||||
return
|
||||
bi = state["idx"]
|
||||
js = steps_batched[bi]
|
||||
total = len(steps_batched)
|
||||
|
||||
def done(r, bi=bi):
|
||||
state["idx"] = bi + 1
|
||||
# 过半时探针一次
|
||||
if not state["probed"] and bi + 1 >= total // 2:
|
||||
state["probed"] = True
|
||||
window.browser.page().runJavaScript(
|
||||
f"probeStream('{MID}')", on_mid_probe)
|
||||
else:
|
||||
QTimer.singleShot(0, next_batch)
|
||||
window.browser.page().runJavaScript(js, done)
|
||||
|
||||
def on_mid_probe(res):
|
||||
d = json.loads(str(res))
|
||||
print(" [过半探针]", str(d)[:400])
|
||||
check("中途: 缓冲已累积", any(s["b"] > 0 for s in d.get("segs", [])), str(d))
|
||||
check("中途: DOM 已写入部分正文",
|
||||
any(s["d"] > 0 for s in d.get("segs", [])), str(d))
|
||||
check("中途: 思考 DOM 已写入",
|
||||
any(t["d"] > 0 for t in d.get("thinks", [])), str(d))
|
||||
finish_seq()
|
||||
|
||||
def mid_probe():
|
||||
window.browser.page().runJavaScript(f"probeStream('{MID}')", on_mid_probe)
|
||||
|
||||
def finish_seq():
|
||||
window.browser.page().runJavaScript(
|
||||
f"finishMessage('{MID}'); probeStream('{MID}')", on_final)
|
||||
|
||||
def on_final(res):
|
||||
d = json.loads(str(res))
|
||||
print(" [最终探针]", str(d)[:400])
|
||||
tot_b = sum(s["b"] for s in d.get("segs", []))
|
||||
tot_d = sum(s["d"] for s in d.get("segs", []))
|
||||
check("最终: 正文缓冲完整", tot_b > 500, f"b={tot_b}")
|
||||
check("最终: 正文 DOM 完整", tot_d > 500, f"d={tot_d}")
|
||||
check("最终: 思考 DOM 完整",
|
||||
sum(t["d"] for t in d.get("thinks", [])) > 500,
|
||||
str([t['d'] for t in d.get('thinks', [])]))
|
||||
# DOM 实际包含关键子串
|
||||
window.browser.page().runJavaScript(
|
||||
"(function(){ var w = document.getElementById('" + MID + "');"
|
||||
" return w ? w.textContent.length : -1; })()", on_text)
|
||||
|
||||
def on_text(res):
|
||||
n = int(str(res) or 0)
|
||||
check("DOM 总文本量正常", n > 1000, f"total={n}")
|
||||
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
|
||||
app.quit()
|
||||
|
||||
def start():
|
||||
window.browser.page().runJavaScript(
|
||||
f"createMessage('{MID}', 'assistant', '', 'Real');",
|
||||
lambda r: QTimer.singleShot(200, next_batch))
|
||||
|
||||
QTimer.singleShot(1500, start)
|
||||
app.exec()
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""时间线 DOM 冒烟测试(真实 WebEngine 页面内执行):
|
||||
1. 流式事件序列 → 思考/正文/工具按事件顺序穿插
|
||||
2. 工具 chip 按 call_id 对号入座 + ok 状态(修复反转 bug)
|
||||
3. SVG 箭头存在、流式光标移除
|
||||
4. 历史消息静态路径不受影响
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_timeline.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
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" # 绕过 AMD 核显 context lost
|
||||
|
||||
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
_TMP = isolate("timeline") # noqa: E402
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtCore import QTimer # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
results = []
|
||||
window = None
|
||||
done = {"ok": False}
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else ""))
|
||||
results.append(ok)
|
||||
|
||||
|
||||
def js(s):
|
||||
return s
|
||||
|
||||
|
||||
JS_SIM = js(r"""
|
||||
(function() {
|
||||
var out = {};
|
||||
try {
|
||||
// ---- 1. 流式时间线 ----
|
||||
var mid = 'tl-test-' + Date.now();
|
||||
createMessage(mid, 'assistant', '', 'TL');
|
||||
var wrapper = document.getElementById(mid);
|
||||
appendReasoning(mid, 'think part 1 ');
|
||||
appendReasoning(mid, 'more thinking');
|
||||
appendToken(mid, 'before tool text ');
|
||||
toolExecutionStarted(mid, 'call-1', 'bash', JSON.stringify({command: 'echo hi'}));
|
||||
toolExecutionUpdated(mid, 'call-1', 'hi\n');
|
||||
toolExecutionFinished(mid, 'call-1', 'bash', true, '$ echo hi\nhi\n[exit 0]');
|
||||
appendToken(mid, ' after tool');
|
||||
appendReasoning(mid, ' second thinking');
|
||||
appendToken(mid, ' final answer');
|
||||
finishMessage(mid);
|
||||
|
||||
var tl = wrapper.querySelector('.reply-content');
|
||||
out.blocks = Array.prototype.map.call(tl.children, function(el) {
|
||||
return el.className.split(' ')[0];
|
||||
});
|
||||
var chip = wrapper.querySelector('.tool-chip');
|
||||
var status = chip ? chip.querySelector('.tool-chip-status') : null;
|
||||
out.chipStatusText = status ? status.textContent : null;
|
||||
out.chipOk = status ? status.classList.contains('ok') : false;
|
||||
out.chipBrief = chip ? (chip.querySelector('.tool-chip-brief') || {}).textContent : null;
|
||||
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
|
||||
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
|
||||
out.chipChevron = !!wrapper.querySelector('.tool-chip .chev');
|
||||
out.typingRemoved = !wrapper.querySelector('.streaming-typing');
|
||||
out.streamClassOff = !wrapper.classList.contains('streaming');
|
||||
out.thinkBlocks = wrapper.querySelectorAll('.think-block').length;
|
||||
out.mdSegs = wrapper.querySelectorAll('.md-segment').length;
|
||||
|
||||
// ---- 2. 历史消息静态路径 ----
|
||||
var mid2 = 'tl-hist-' + Date.now();
|
||||
createMessage(mid2, 'assistant', 'hello **world**', 'TL');
|
||||
insertThinkBlock(mid2, 'history thinking');
|
||||
finishMessage(mid2);
|
||||
var w2 = document.getElementById(mid2);
|
||||
var content2 = w2.querySelector('.message-content');
|
||||
out.histOrder = Array.prototype.map.call(content2.children, function(el) {
|
||||
return el.className.split(' ')[0];
|
||||
});
|
||||
out.histBold = w2.querySelector('.reply-content strong') !== null;
|
||||
out.histThinkChevron = !!w2.querySelector('.think-block .chev');
|
||||
out.histThinkLabel = (w2.querySelector('.think-label') || {}).textContent;
|
||||
|
||||
// ---- 3. 失败工具 chip(反转修复验证)----
|
||||
var mid3 = 'tl-fail-' + Date.now();
|
||||
createMessage(mid3, 'assistant', '', 'TL');
|
||||
toolExecutionStarted(mid3, 'call-x', 'bash', '{"command":"false"}');
|
||||
toolExecutionFinished(mid3, 'call-x', 'bash', false, 'boom');
|
||||
var w3 = document.getElementById(mid3);
|
||||
var st3 = w3.querySelector('.tool-chip-status');
|
||||
out.failChipFail = st3 ? st3.classList.contains('fail') : false;
|
||||
out.failChipText = st3 ? st3.textContent : null;
|
||||
finishMessage(mid3);
|
||||
|
||||
// 清理
|
||||
[mid, mid2, mid3].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.parentNode.removeChild(el);
|
||||
});
|
||||
} catch (e) {
|
||||
out.error = String(e) + ' | ' + (e.stack || '').split('\n')[1];
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
""")
|
||||
|
||||
|
||||
def run_js_check():
|
||||
page = window.browser.page() if hasattr(window, "browser") else None
|
||||
if page is None:
|
||||
# 找 QWebEnginePage
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
views = window.findChildren(QWebEngineView)
|
||||
page = views[0].page() if views else None
|
||||
if page is None:
|
||||
check("页面句柄", False, "no page")
|
||||
finish()
|
||||
return
|
||||
|
||||
def on_result(res):
|
||||
try:
|
||||
import json
|
||||
data = json.loads(str(res))
|
||||
except Exception as e:
|
||||
check("JS 执行", False, f"bad json: {res} ({e})")
|
||||
finish()
|
||||
return
|
||||
if "error" in data:
|
||||
check("JS 执行", False, data["error"])
|
||||
finish()
|
||||
return
|
||||
|
||||
# 期望时间线: think → text → chip → text → think → text
|
||||
expected = ["think-block", "md-segment", "tool-chip",
|
||||
"md-segment", "think-block", "md-segment"]
|
||||
check("时间线块顺序(思考/正文/工具穿插)", data["blocks"] == expected,
|
||||
str(data["blocks"]))
|
||||
check("两个思考段 + 三个正文段",
|
||||
data["thinkBlocks"] == 2 and data["mdSegs"] == 3,
|
||||
f"think={data['thinkBlocks']} segs={data['mdSegs']}")
|
||||
check("工具 chip 成功状态(非反转)",
|
||||
data["chipOk"] and data["chipStatusText"] == "✓ 完成",
|
||||
f"{data['chipStatusText']} ok={data['chipOk']}")
|
||||
check("chip 摘要=结果首行", data["chipBrief"] == "$ echo hi",
|
||||
str(data["chipBrief"]))
|
||||
check("chip call_id 贯通", data["chipCallId"] == "call-1",
|
||||
str(data["chipCallId"]))
|
||||
check("SVG 箭头(思考+工具)", data["thinkChevron"] and data["chipChevron"])
|
||||
check("流式光标已移除 + streaming 类移除",
|
||||
data["typingRemoved"] and data["streamClassOff"])
|
||||
check("失败 chip 标 ✗", data["failChipFail"] and data["failChipText"] == "✗ 失败",
|
||||
f"{data['failChipText']}")
|
||||
check("历史消息: 思考在正文前",
|
||||
data["histOrder"].index("think-block") < data["histOrder"].index("reply-content"),
|
||||
str(data["histOrder"]))
|
||||
check("历史消息: markdown 正常解析", data["histBold"])
|
||||
check("历史消息: 思考块带 SVG 箭头+标签",
|
||||
data["histThinkChevron"] and data["histThinkLabel"] == "已完成深度思考",
|
||||
str(data["histThinkLabel"]))
|
||||
finish()
|
||||
|
||||
page.runJavaScript(JS_SIM, on_result)
|
||||
|
||||
|
||||
def finish():
|
||||
done["ok"] = True
|
||||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||||
f"{sum(results)}/{len(results)} =====")
|
||||
app.quit()
|
||||
|
||||
|
||||
def main():
|
||||
global window
|
||||
try:
|
||||
window = MainWindow()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
app.quit()
|
||||
return
|
||||
QTimer.singleShot(6000, run_js_check)
|
||||
QTimer.singleShot(25000, lambda: (check("超时", False, "25s 未完成"), finish())
|
||||
if not done["ok"] else None)
|
||||
app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tool_bash 实时输出(增量流)单测 —— 纯函数级,无 UI 依赖
|
||||
|
||||
本轮改动核心:communicate(timeout=1) 阻塞式收集
|
||||
→ stdout/stderr reader 线程 + 队列 + 主循环抽干回调 on_update
|
||||
本测试锁死「改动没有破坏任何既有语义」+「输出真的是实时的」。
|
||||
|
||||
覆盖:
|
||||
T1 增量流:on_update 被多次调用(不是结束时一次性给)
|
||||
T2 实时性:第一块输出到达时刻 << 总耗时(证明是流式而非收尾补发)
|
||||
T3 上下文文本格式完全不变($ cmd / stdout / [stderr] / [exit N] (Ns))
|
||||
T4 stderr 实时流带 [stderr] 分隔标记,且最终文本仍有 [stderr] 段
|
||||
T5 每 1 秒读秒回调 on_timer 仍然工作
|
||||
T6 超时:杀进程树 + 「命令超时(>Ns)已终止」+ 实际耗时远小于命令时长
|
||||
T7 中止:AbortSignal.aborted → 「操作已中止」
|
||||
T8 50KB 截断仍然生效
|
||||
T9 退出非 0 → is_error,且 exit code 写入 details
|
||||
T10 空命令 → 参数校验错误
|
||||
T11 无输出命令:不产生 on_update,但读秒正常
|
||||
T12 最终文本 = 全部 stdout 拼接(不丢不重)
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_bash_stream.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.agent.tools import tool_bash # noqa: E402
|
||||
from core.agent.types import AbortSignal # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="hocode_bashstream_")
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
def run(script=None, timeout=None, abort_after=None, shell_script=None):
|
||||
"""把脚本写成临时文件再执行(避开 cmd 引号地狱)。
|
||||
返回 (result, updates, first_t, dur, timer_calls)"""
|
||||
path = None
|
||||
if script is not None:
|
||||
path = os.path.join(_TMP, f"s_{int(time.time() * 1000000) % 10**9}.py")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
cmd = f'"{PY}" -u "{path}"'
|
||||
else:
|
||||
cmd = shell_script
|
||||
|
||||
updates = []
|
||||
first_t = [None]
|
||||
t0 = time.time()
|
||||
|
||||
def on_update(text):
|
||||
if first_t[0] is None:
|
||||
first_t[0] = time.time() - t0
|
||||
updates.append(text)
|
||||
|
||||
timers = []
|
||||
|
||||
def on_timer(elapsed, total):
|
||||
timers.append((elapsed, total))
|
||||
|
||||
sig = AbortSignal()
|
||||
if abort_after is not None:
|
||||
import threading
|
||||
|
||||
def _aborter():
|
||||
time.sleep(abort_after)
|
||||
sig.abort("test")
|
||||
threading.Thread(target=_aborter, daemon=True).start()
|
||||
|
||||
args = {"command": cmd}
|
||||
if timeout is not None:
|
||||
args["timeout"] = timeout
|
||||
res = tool_bash("call_test", args, sig, on_update,
|
||||
{"cwd": _TMP, "shell": True, "on_timer": on_timer})
|
||||
dur = time.time() - t0
|
||||
return res, updates, first_t[0], dur, timers
|
||||
|
||||
|
||||
def text_of(res):
|
||||
c = res.content
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
|
||||
|
||||
|
||||
# ======================================================================
|
||||
try:
|
||||
# ---------- T1/T2/T3/T12: 流式 + 上下文格式 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import sys, time\n"
|
||||
"for i in range(3):\n"
|
||||
" print('LINE%d' % i, flush=True)\n"
|
||||
" time.sleep(0.5)\n", timeout=30)
|
||||
txt = text_of(res)
|
||||
check("T1.1 增量流被多次回调(>=3 块)", len(ups) >= 3, f"updates={len(ups)} {ups!r}")
|
||||
check("T1.2 每块都是本次输出的一部分", all(u.strip() for u in ups), repr(ups[:3]))
|
||||
check("T2.1 第一块在 1s 内到达(实时,不是收尾补发)",
|
||||
first_t is not None and first_t < 1.0, f"first_t={first_t}")
|
||||
check("T2.2 第一块明显早于总耗时",
|
||||
first_t is not None and (dur - first_t) > 0.6, f"first={first_t} dur={dur:.2f}")
|
||||
check("T3.1 上下文文本以 $ 命令开头", txt.startswith("$ "), repr(txt[:60]))
|
||||
check("T3.2 含 [exit 0] 与耗时",
|
||||
re.search(r"\[exit 0\] \(\d+\.\d+s\)", txt) is not None, repr(txt[-60:]))
|
||||
check("T12.1 三行输出全部进入最终文本,不丢不重",
|
||||
all(f"LINE{i}" in txt for i in range(3)) and txt.count("LINE0") == 1, repr(txt))
|
||||
check("T3.3 is_error=False", res.is_error is False)
|
||||
check("T3.4 details 带 exit_code", res.details.get("exit_code") == 0, str(res.details))
|
||||
|
||||
# ---------- T4: stderr 实时 + 最终段 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import sys\nprint('OUT1', flush=True)\nprint('ERR1', file=sys.stderr, flush=True)\n",
|
||||
timeout=30)
|
||||
txt = text_of(res)
|
||||
joined = "".join(ups)
|
||||
check("T4.1 实时流里有 [stderr] 分隔标记", "[stderr]" in joined, repr(joined))
|
||||
check("T4.2 最终文本含 [stderr] 段与内容",
|
||||
"[stderr]" in txt and "ERR1" in txt, repr(txt))
|
||||
check("T4.3 stdout 内容也在最终文本里", "OUT1" in txt, repr(txt))
|
||||
|
||||
# ---------- T5/T11: 读秒 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import time\ntime.sleep(2.6)\nprint('done', flush=True)\n", timeout=30)
|
||||
check("T5.1 长命令期间读秒回调(>=2 次)", len(timers) >= 2, f"timers={timers}")
|
||||
check("T5.2 读秒总量正确", all(t[1] == 30 for t in timers), str(timers))
|
||||
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import time\ntime.sleep(1.5)\n", timeout=30)
|
||||
check("T11.1 无输出命令不产生 on_update", len(ups) == 0, repr(ups))
|
||||
check("T11.2 无输出命令仍有读秒", len(timers) >= 1, str(timers))
|
||||
|
||||
# ---------- T6: 超时杀树 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import time\nprint('start', flush=True)\ntime.sleep(30)\n", timeout=2)
|
||||
txt = text_of(res)
|
||||
check("T6.1 超时文案", "命令超时(>2s)已终止" in txt, repr(txt))
|
||||
check("T6.2 真的提前返回(耗时应 <10s)", dur < 10, f"dur={dur:.2f}")
|
||||
check("T6.3 超时结果是 is_error", res.is_error is True)
|
||||
|
||||
# ---------- T7: 中止 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import time\ntime.sleep(30)\n", timeout=60, abort_after=1.5)
|
||||
txt = text_of(res)
|
||||
check("T7.1 中止文案", "操作已中止" in txt, repr(txt))
|
||||
check("T7.2 中止后迅速返回(<8s)", dur < 8, f"dur={dur:.2f}")
|
||||
|
||||
# ---------- T8: 50KB 截断 ----------
|
||||
res, ups, first_t, dur, timers = run(
|
||||
"import sys\n"
|
||||
"line = 'X' * 100 + '\\n'\n"
|
||||
"for _ in range(1200):\n"
|
||||
" sys.stdout.write(line)\n"
|
||||
"sys.stdout.flush()\n", timeout=30)
|
||||
txt = text_of(res)
|
||||
check("T8.1 超过 50KB 触发截断标记", "[输出超过 50KB 已截断]" in txt, repr(txt[-80:]))
|
||||
check("T8.2 截断后字节数 <= 50KB+余量",
|
||||
len(txt.encode("utf-8")) <= 50 * 1024 + 200, str(len(txt.encode("utf-8"))))
|
||||
|
||||
# ---------- T9: 非 0 退出 ----------
|
||||
res, ups, first_t, dur, timers = run("import sys\nsys.exit(3)\n", timeout=30)
|
||||
txt = text_of(res)
|
||||
check("T9.1 exit 3 写入文本", "[exit 3]" in txt, repr(txt))
|
||||
check("T9.2 is_error=True", res.is_error is True)
|
||||
check("T9.3 details.exit_code=3", res.details.get("exit_code") == 3, str(res.details))
|
||||
|
||||
# ---------- T10: 空命令 ----------
|
||||
res = tool_bash("c", {"command": " "}, AbortSignal(), None,
|
||||
{"cwd": _TMP, "shell": True})
|
||||
check("T10.1 空命令被拒", "command 不能为空" in text_of(res), repr(text_of(res)))
|
||||
|
||||
# ---------- T13: 秒级短命令仍正常(回归:不因线程化而变慢/丢输出) ----------
|
||||
res, ups, first_t, dur, timers = run(shell_script="echo hello_from_cmd")
|
||||
txt = text_of(res)
|
||||
check("T13.1 短命令输出正确", "hello_from_cmd" in txt, repr(txt))
|
||||
check("T13.2 短命令耗时 <3s", dur < 3, f"dur={dur:.2f}")
|
||||
check("T13.3 短命令实时流也拿到了输出", "hello_from_cmd" in "".join(ups), repr(ups))
|
||||
|
||||
finally:
|
||||
shutil.rmtree(_TMP, ignore_errors=True)
|
||||
|
||||
failed = [n for n, ok in RESULTS if not ok]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
离屏验证:压缩持久化(链上标记点)
|
||||
背景:压缩此前只改内存 agent.state.messages,下一轮提问从 DB 全量重建历史
|
||||
→「压缩等于没有压缩」。现:压缩成功后在链上插标记行(role="compaction",
|
||||
is_ignored=1),保留首条改指到标记;build_api_context 在最后一个标记处截断、
|
||||
以摘要(user 消息)替代之前一切。历史行全部保留,前端切会话渲染不受影响。
|
||||
"""
|
||||
import os, sys, json, tempfile, types
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
from core.agent.types import AgentMessage, AgentConfig, ModelConfig # noqa: E402
|
||||
from core.agent.stream_fn import from_openai_messages, to_openai_messages # noqa: E402
|
||||
from core.agent.compaction import prepare_compaction, CompactionSettings # noqa: E402
|
||||
from core.agent.context import estimate_context_tokens # noqa: E402
|
||||
from core.agent.recovery import AgentRunner, _cut_ids_of # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402 纯方法 unbound 调用,不实例化
|
||||
|
||||
RESULTS = []
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
TMP = os.path.join(tempfile.gettempdir(), f"haocode_compact_persist_{os.getpid()}.db")
|
||||
if os.path.exists(TMP):
|
||||
os.remove(TMP)
|
||||
db = DBManager(TMP)
|
||||
sess = db.create_session("压缩持久化测试")
|
||||
sid = sess["id"]
|
||||
|
||||
def add(role, content, parent):
|
||||
return db.add_message(session_id=sid, role=role, content=content, parent_id=parent)
|
||||
|
||||
# 链:r1(user 旧) → r2(assistant 旧) → r3(user 旧) → r4(assistant 保留) → r5(user 保留)
|
||||
r1 = add("user", "旧问题1", None)
|
||||
r2 = add("assistant", "旧回答1", r1["id"])
|
||||
r3 = add("user", "旧问题2", r2["id"])
|
||||
r4 = add("assistant", "保留回答", r3["id"])
|
||||
r5 = add("user", "保留问题", r4["id"])
|
||||
leaf_before = db.get_session_leaf(sid)
|
||||
check("T0.初始叶子=最后一条", leaf_before == r5["id"])
|
||||
|
||||
MARK_SUMMARY = "## 摘要\n上方对话已摘要:用户问了两个旧问题。"
|
||||
|
||||
# ---------- T1:标记插入(原子,不动叶子) ----------
|
||||
mid = db.insert_compaction_mark(sid, MARK_SUMMARY, r3["id"], r4["id"],
|
||||
json.dumps({"path": "pre_prompt"}, ensure_ascii=False))
|
||||
chain = db.get_message_chain(sid)
|
||||
check("T1.标记行已插入链上", mid is not None and any(m["id"] == mid for m in chain))
|
||||
check("T1.链=6行且顺序正确",
|
||||
[m["id"] for m in chain] == [r1["id"], r2["id"], r3["id"], mid, r4["id"], r5["id"]],
|
||||
[m["id"] for m in chain])
|
||||
check("T1.叶子指针不变", db.get_session_leaf(sid) == leaf_before)
|
||||
check("T1.保留首条改指标记", chain[4]["parent_id"] == mid)
|
||||
mark_row = next(m for m in chain if m["id"] == mid)
|
||||
check("T1.标记 role/is_ignored", mark_row["role"] == "compaction" and mark_row["is_ignored"] == 1)
|
||||
|
||||
# ---------- T5:分支一致性(无幻影分支) ----------
|
||||
check("T5.旧N唯一子=标记", [m["id"] for m in db.get_branch_info(r3["id"])] == [mid])
|
||||
check("T5.标记唯一子=保留首条", [m["id"] for m in db.get_branch_info(mid)] == [r4["id"]])
|
||||
|
||||
# ---------- T2:build_api_context 截断 ----------
|
||||
class _Dummy:
|
||||
db = db
|
||||
api = MainWindow.build_api_context(_Dummy(), sid)
|
||||
api_text = json.dumps([m.get("content") for m in api], ensure_ascii=False)
|
||||
check("T2.首条=摘要user消息(带_kind)",
|
||||
api and api[0]["role"] == "user" and api[0]["content"] == MARK_SUMMARY
|
||||
and api[0].get("_kind") == "compaction_summary", api[:1])
|
||||
check("T2.保留行仍在", "保留回答" in api_text and "保留问题" in api_text)
|
||||
check("T2.切点前行消失",
|
||||
"旧问题1" not in api_text and "旧回答1" not in api_text and "旧问题2" not in api_text)
|
||||
check("T2.共3条(摘要+2保留)", len(api) == 3, len(api))
|
||||
|
||||
# ---------- T3:kind 传播 → 二次压缩走迭代摘要 ----------
|
||||
msgs = from_openai_messages(api)
|
||||
check("T3.摘要 kind 还原", msgs and msgs[0].kind == "compaction_summary",
|
||||
[m.kind for m in msgs])
|
||||
prep = prepare_compaction(msgs, CompactionSettings(reserve_tokens=1000,
|
||||
keep_recent_tokens=2))
|
||||
check("T3.走 previous_summary 迭代路径",
|
||||
prep is not None and prep.previous_summary == MARK_SUMMARY)
|
||||
|
||||
# ---------- T4:双标记取最后一个 ----------
|
||||
mid2 = db.insert_compaction_mark(sid, "摘要2", r4["id"], r5["id"])
|
||||
api2 = MainWindow.build_api_context(_Dummy(), sid)
|
||||
api2_text = json.dumps([m.get("content") for m in api2], ensure_ascii=False)
|
||||
check("T4.取最后一个标记", api2[0]["content"] == "摘要2" and len(api2) == 2, len(api2))
|
||||
check("T4.只留第二个切点之后", "保留问题" in api2_text and "保留回答" not in api2_text)
|
||||
|
||||
# ---------- T6:_cut_ids_of 切点计算(单元) ----------
|
||||
A = AgentMessage(role="user", content="x", db_msg_id="a")
|
||||
B = AgentMessage(role="assistant", content="y", db_msg_id="")
|
||||
C = AgentMessage(role="toolResult", content="z", db_msg_id="")
|
||||
S = AgentMessage(role="user", content="SUM", kind="compaction_summary")
|
||||
check("T6.尾巴首条无DB行→first为空(UI跳过插标记)",
|
||||
_cut_ids_of([A, B, C], [S, B, C]) == ("a", ""))
|
||||
B2 = AgentMessage(role="assistant", content="y", db_msg_id="b")
|
||||
check("T6.正常切点", _cut_ids_of([A, B2, C], [S, B2, C]) == ("a", "b"))
|
||||
D = AgentMessage(role="assistant", content="d", db_msg_id="d")
|
||||
check("T6.尾巴不在旧列表→不插", _cut_ids_of([A, B2, C], [S, D]) == ("", ""))
|
||||
check("T6.无之前消息→无切点", _cut_ids_of([B2, C], [S, B2, C]) == ("", ""))
|
||||
|
||||
# T6c:⚠️ 同 id 回放序列(timeline 多条目共享一行 id)—— 曾导致 DB 自环卡死
|
||||
Pm = AgentMessage(role="user", content="p", db_msg_id="p")
|
||||
A1 = AgentMessage(role="assistant", content="a1", db_msg_id="a")
|
||||
A2 = AgentMessage(role="assistant", content="a2", db_msg_id="a")
|
||||
A3 = AgentMessage(role="toolResult", content="a3", db_msg_id="a")
|
||||
Qm = AgentMessage(role="user", content="q", db_msg_id="q")
|
||||
cut = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A2, A3, Qm])
|
||||
check("T6c.同id回放切点回退到行头(cut_before≠first_retained)",
|
||||
cut == ("p", "a"), cut)
|
||||
cut2 = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A3, Qm])
|
||||
check("T6c.尾巴起点更深也回退到行头", cut2 == ("p", "a"), cut2)
|
||||
|
||||
# ---------- T10:insert_compaction_mark 自环守卫 ----------
|
||||
bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"])
|
||||
check("T10.cut==retained 拒绝插入(防自环)", bad is None)
|
||||
|
||||
# ---------- T11:get_message_chain 环守卫(手工造 2 环) ----------
|
||||
sid2 = db.create_session("环守卫")["id"]
|
||||
x1 = db.add_message(session_id=sid2, role="user", content="x1", parent_id=None)
|
||||
x2 = db.add_message(session_id=sid2, role="assistant", content="x2", parent_id=x1["id"])
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x1["id"], x2["id"]))
|
||||
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x2["id"], x1["id"]))
|
||||
conn.commit()
|
||||
import time as _t
|
||||
t0 = _t.time()
|
||||
chain11 = db.get_message_chain(sid2)
|
||||
dur = _t.time() - t0
|
||||
check("T11.手工2环不死循环(<2s返回)", dur < 2 and 1 <= len(chain11) <= 3, (dur, len(chain11)))
|
||||
|
||||
# ---------- T7:pre_prompt_compaction 事件 payload 端到端 ----------
|
||||
model = ModelConfig(provider="t", name="t", context_window=500,
|
||||
max_tokens=100, api_key="k", base_url="http://x")
|
||||
cfg = AgentConfig(model=model, system_prompt="s", tools=[], tool_context={},
|
||||
compaction_reserve=100, compaction_keep_recent=100)
|
||||
from core.agent.agent import Agent # noqa: E402
|
||||
agent = Agent(cfg)
|
||||
old_msgs = [
|
||||
AgentMessage(role="user", content="旧旧问题", db_msg_id="m1"),
|
||||
AgentMessage(role="assistant", content="旧旧回答", db_msg_id="m2"),
|
||||
AgentMessage(role="user", content="旧上下文 " * 200, db_msg_id="m3"),
|
||||
AgentMessage(role="assistant", content="新回答", db_msg_id="m4"),
|
||||
AgentMessage(role="user", content="新问题", db_msg_id="m5"),
|
||||
]
|
||||
agent.state.messages = old_msgs
|
||||
runner = AgentRunner(agent, summarize_fn=lambda p, s, mt: "摘要X")
|
||||
ok = runner.pre_prompt_compaction()
|
||||
check("T7.压缩被触发", ok is True)
|
||||
ev = runner.compaction_events[-1]
|
||||
new_msgs = agent.state.messages
|
||||
first = new_msgs[1]
|
||||
idx = next(i for i, m in enumerate(old_msgs) if m is first)
|
||||
check("T7.payload cut_before_id=切点前DB行",
|
||||
ev.get("cut_before_id") == old_msgs[idx - 1].db_msg_id, ev)
|
||||
check("T7.payload first_retained_id=尾巴首条DB行",
|
||||
ev.get("first_retained_id") == first.db_msg_id, ev)
|
||||
check("T7.新消息=摘要+尾巴", new_msgs[0].kind == "compaction_summary"
|
||||
and new_msgs[1:] == old_msgs[idx:])
|
||||
|
||||
# ---------- T8:_key 不泄漏到 API ----------
|
||||
api_out = to_openai_messages(old_msgs)
|
||||
check("T8.下划线内部字段不进API",
|
||||
all(not any(k.startswith("_") for k in m.keys()) for m in api_out))
|
||||
|
||||
# ---------- T9:UI 渲染过滤排除 compaction(静态) ----------
|
||||
_mw_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"ui", "views", "main_window.py")
|
||||
with open(_mw_path, "r", encoding="utf-8") as f:
|
||||
_src = f.read()
|
||||
# 注意:探针与循环变量名解耦(源码列表推导式用 `m` 或 `msg` 均可),
|
||||
# 只锁死“渲染过滤排除 system/compaction”这一语义。
|
||||
check("T9.渲染过滤含 compaction", '["role"] not in ("system", "compaction")' in _src)
|
||||
|
||||
# ---------- T12:Fix A — 摘要条目 timestamp → P0 失效过期锚点 ----------
|
||||
# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照;
|
||||
# 若摘要条目不带 timestamp,显示/压缩判定会锚到过期值(92.7k),而真实
|
||||
# 下一轮输入只有摘要+保留行(~11k)。
|
||||
sid3 = db.create_session("过期锚点失效")["id"]
|
||||
u1 = db.add_message(session_id=sid3, role="user", content="u" * 2000, parent_id=None)
|
||||
a1 = db.add_message(session_id=sid3, role="assistant", content="A" * 40000,
|
||||
parent_id=u1["id"],
|
||||
usage=json.dumps({"input": 92700, "output": 500,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
u2 = db.add_message(session_id=sid3, role="user", content="v" * 2000, parent_id=a1["id"])
|
||||
db.add_message(session_id=sid3, role="assistant", content="B" * 40000, parent_id=u2["id"],
|
||||
usage=json.dumps({"input": 92700, "output": 500,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
# 把全部行时间戳拨到 3000s 前(模拟它们早于压缩发生)
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("UPDATE messages SET created_at=? WHERE session_id=?",
|
||||
(int(_t.time()) - 3000, sid3))
|
||||
conn.commit()
|
||||
api_ns = MainWindow.build_api_context(_Dummy(), sid3)
|
||||
est_ns = estimate_context_tokens(from_openai_messages(api_ns))
|
||||
check("T12.对照:无标记→锚定入库usage", est_ns.usage_tokens > 0, est_ns.usage_tokens)
|
||||
mid3 = db.insert_compaction_mark(sid3, "摘要", a1["id"], u2["id"])
|
||||
api3 = MainWindow.build_api_context(_Dummy(), sid3)
|
||||
check("T12.摘要条目带timestamp且晚于保留行",
|
||||
api3 and api3[0].get("timestamp", 0) > 0
|
||||
and all(api3[0]["timestamp"] > m.get("timestamp", 0) for m in api3[1:]))
|
||||
est3 = estimate_context_tokens(from_openai_messages(api3))
|
||||
check("T12.有标记→过期锚点失效(usage_tokens=0)", est3.usage_tokens == 0,
|
||||
est3.usage_tokens)
|
||||
check("T12.估算=全量公式(远离过期93.2k)", est3.tokens < 40000, est3.tokens)
|
||||
|
||||
# ---------- T13:Fix B + G1 — 工具行显示不锚点(G1 行块规则单元化) ----------
|
||||
# 场景(任务流会话 40.5k→80k):工具行的入库 usage 是本轮最后一个子请求的
|
||||
# 快照(工具输出未截断),下一轮真实输入=4k 截断回放。🆕 G1 后不再需要
|
||||
# 手动清锚:锚点选择器自动失效含工具行的 usage → 估算=全量公式。
|
||||
sid4 = db.create_session("工具行不锚点")["id"]
|
||||
p1 = db.add_message(session_id=sid4, role="user", content="prompt", parent_id=None)
|
||||
tl = json.dumps([
|
||||
{"t": "text", "text": "code" * 50000},
|
||||
{"t": "tool", "id": "c1", "name": "bash", "args": "{}", "result": "ok"},
|
||||
{"t": "text", "text": "done"},
|
||||
], ensure_ascii=False)
|
||||
db.add_message(session_id=sid4, role="assistant", content="x", parent_id=p1["id"],
|
||||
timeline=tl,
|
||||
usage=json.dumps({"input": 32517, "output": 7999,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api4 = MainWindow.build_api_context(_Dummy(), sid4)
|
||||
check("T13.回放含tool条目(规则条件成立)",
|
||||
any(m.get("role") == "tool" for m in api4))
|
||||
_est4 = estimate_context_tokens(from_openai_messages(api4))
|
||||
check("T13.G1:工具行入库usage自动失效锚点(无需手动清锚)",
|
||||
_est4.usage_tokens == 0, _est4.usage_tokens)
|
||||
check("T13.估算=全量公式(>旧锚点40516,度量截断回放)",
|
||||
_est4.tokens > 40516, _est4.tokens)
|
||||
# G1 精确性:仅失效「含工具活动的行」的 usage,其后的纯文本行锚点保留
|
||||
sid4b = db.create_session("工具行后纯文本行锚点保留")["id"]
|
||||
p1b = db.add_message(session_id=sid4b, role="user", content="prompt", parent_id=None)
|
||||
t1b = db.add_message(session_id=sid4b, role="assistant", content="x", parent_id=p1b["id"],
|
||||
timeline=tl,
|
||||
usage=json.dumps({"input": 32517, "output": 7999,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
u1b = db.add_message(session_id=sid4b, role="user", content="q2", parent_id=t1b["id"])
|
||||
_a2b = db.add_message(session_id=sid4b, role="assistant", content="y", parent_id=u1b["id"],
|
||||
usage=json.dumps({"input": 90000, "output": 100,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api4b = MainWindow.build_api_context(_Dummy(), sid4b)
|
||||
_est4b = estimate_context_tokens(from_openai_messages(api4b))
|
||||
check("T13.G1:工具行(旧)usage失效、其后纯文本行(新)usage保留锚点",
|
||||
_est4b.usage_tokens > 0 and _est4b.usage_tokens == 90100,
|
||||
_est4b.usage_tokens)
|
||||
# 纯文本会话:规则条件不成立 → 保留精确锚点(provider 实测值更准)
|
||||
sid5 = db.create_session("文本行锚点")["id"]
|
||||
p2 = db.add_message(session_id=sid5, role="user", content="hi", parent_id=None)
|
||||
db.add_message(session_id=sid5, role="assistant", content="hello", parent_id=p2["id"],
|
||||
usage=json.dumps({"input": 100, "output": 50,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api5 = MainWindow.build_api_context(_Dummy(), sid5)
|
||||
check("T13.纯文本行→无tool条目(保留锚点)",
|
||||
not any(m.get("role") == "tool" for m in api5)
|
||||
and estimate_context_tokens(from_openai_messages(api5)).usage_tokens > 0)
|
||||
|
||||
# ---------- T14:G2 — should_compact 透传 system/tools(无锚点分支度量下一请求) ----------
|
||||
from core.agent.context import should_compact
|
||||
_mc14 = ModelConfig(name="t", context_window=100000)
|
||||
_msgs14 = from_openai_messages(api4) # 工具行 → G1 无锚点
|
||||
_sh_no, _tok_no = should_compact(_msgs14, _mc14, 16384)
|
||||
_sh_yes, _tok_yes = should_compact(_msgs14, _mc14, 16384,
|
||||
system_prompt="S" * 2000,
|
||||
tools=[types.SimpleNamespace(
|
||||
name="bash", description="d" * 1000,
|
||||
parameters={})])
|
||||
check("T14.传入system/tools后估算增大(无锚点分支)",
|
||||
_tok_yes > _tok_no, (_tok_no, _tok_yes))
|
||||
# 阈值边缘:同样内容,带上 system/tools 后跨过阈值 → 触发判定变化
|
||||
_mc_edge = ModelConfig(name="t2", context_window=_tok_no + 16384 + 5)
|
||||
_sh_edge_no, _ = should_compact(_msgs14, _mc_edge, 16384)
|
||||
_sh_edge_yes, _ = should_compact(_msgs14, _mc_edge, 16384,
|
||||
system_prompt="S" * 2000,
|
||||
tools=[types.SimpleNamespace(
|
||||
name="bash", description="d" * 1000,
|
||||
parameters={})])
|
||||
check("T14.阈值边缘:计入system/tools才触发(防低估漏压缩)",
|
||||
_sh_edge_no is False and _sh_edge_yes is True,
|
||||
(_sh_edge_no, _sh_edge_yes))
|
||||
|
||||
failed = [n for n, okk in RESULTS if not okk]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归
|
||||
|
||||
运行: python tests/test_config_isolation.py
|
||||
(仓库惯例:无 pytest 依赖,独立可跑;GUI 部分自动走 offscreen)
|
||||
|
||||
完成证据(REPAIR_BACKLOG.md P0-01):
|
||||
1. 打开路径拦截器记录到的配置打开路径全部位于临时目录,真实配置路径从未被打开;
|
||||
该断言只用路径字符串比较,不读取、不散列真实配置文件;
|
||||
2. 临时配置读写用例通过,临时数据库之外没有数据库写入;
|
||||
3. 缺失配置与损坏配置各有一个回归用例:日志含明确警告、返回安全默认值、无异常;
|
||||
4. 静态扫描:源码中不存在绕过统一路径解析(core/config_paths)的运行时配置读取。
|
||||
"""
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_TESTS_DIR)
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
from tests._test_env import isolate, default_config # noqa: E402
|
||||
|
||||
_TMP = isolate("cfgiso") # 必须在 import MainWindow 之前
|
||||
_REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开
|
||||
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name +
|
||||
("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容)
|
||||
# ======================================================================
|
||||
class OpenInterceptor:
|
||||
def __init__(self):
|
||||
self.ops = [] # (path, mode)
|
||||
self._real_open = None
|
||||
|
||||
def __enter__(self):
|
||||
self._real_open = __builtins__["open"] if isinstance(
|
||||
__builtins__, dict) else __builtins__.open
|
||||
import builtins
|
||||
builtins.open = self._open
|
||||
return self
|
||||
|
||||
def _open(self, file, mode="r", *args, **kwargs):
|
||||
try:
|
||||
p = os.fspath(file)
|
||||
except TypeError:
|
||||
p = None
|
||||
if p is not None:
|
||||
self.ops.append((os.path.abspath(str(p)), str(mode)))
|
||||
return self._real_open(file, mode, *args, **kwargs)
|
||||
|
||||
def __exit__(self, *exc):
|
||||
import builtins
|
||||
builtins.open = self._real_open
|
||||
return False
|
||||
|
||||
# ---- 断言辅助 ----
|
||||
def config_ops(self):
|
||||
# 含原子写的临时文件 config.json.tmp
|
||||
return [(p, m) for p, m in self.ops
|
||||
if os.path.basename(p).startswith("config.json")]
|
||||
|
||||
def real_config_opened(self):
|
||||
return [op for op in self.ops
|
||||
if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)]
|
||||
|
||||
|
||||
class SqliteInterceptor:
|
||||
def __init__(self):
|
||||
self.paths = []
|
||||
self._real_connect = None
|
||||
|
||||
def __enter__(self):
|
||||
self._real_connect = sqlite3.connect
|
||||
sqlite3.connect = self._connect
|
||||
return self
|
||||
|
||||
def _connect(self, database, *args, **kwargs):
|
||||
self.paths.append(str(database))
|
||||
return self._real_connect(database, *args, **kwargs)
|
||||
|
||||
def __exit__(self, *exc):
|
||||
sqlite3.connect = self._real_connect
|
||||
return False
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 1) 静态扫描:不得存在绕过统一路径解析的配置读取
|
||||
# (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外;
|
||||
# 文档字符串与注释不计)
|
||||
# ======================================================================
|
||||
def _docstring_values(tree) -> set:
|
||||
vals = set()
|
||||
|
||||
def _first_expr_body(body):
|
||||
if body and isinstance(body[0], ast.Expr) and \
|
||||
isinstance(body[0].value, ast.Constant) and \
|
||||
isinstance(body[0].value.value, str):
|
||||
vals.add(body[0].value.value)
|
||||
|
||||
_first_expr_body(tree.body)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
_first_expr_body(node.body)
|
||||
return vals
|
||||
|
||||
|
||||
def static_scan():
|
||||
allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py"))
|
||||
bad = []
|
||||
scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")]
|
||||
scan_files = [os.path.join(_ROOT, "main.py")]
|
||||
targets = list(scan_files)
|
||||
for d in scan_dirs:
|
||||
for dirpath, _dirnames, filenames in os.walk(d):
|
||||
if "__pycache__" in dirpath:
|
||||
continue
|
||||
for fn in filenames:
|
||||
if fn.endswith(".py"):
|
||||
targets.append(os.path.join(dirpath, fn))
|
||||
for path in targets:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
tree = ast.parse(src, filename=path)
|
||||
docstrings = _docstring_values(tree)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str) \
|
||||
and "config.json" in node.value \
|
||||
and node.value not in docstrings:
|
||||
if os.path.normcase(path) != allowed:
|
||||
bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}")
|
||||
return bad
|
||||
|
||||
|
||||
bad_literal = static_scan()
|
||||
check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)",
|
||||
not bad_literal, f"发现: {bad_literal}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常
|
||||
# ======================================================================
|
||||
from core.config_paths import config_path, load_config # noqa: E402
|
||||
import core.llm_engine as le # noqa: E402
|
||||
|
||||
saved_env = os.environ.get("HAOCODE_CONFIG_FILE")
|
||||
|
||||
_missing = os.path.join(_TMP["base"], "no_such_config.json")
|
||||
os.environ["HAOCODE_CONFIG_FILE"] = _missing
|
||||
_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(_buf):
|
||||
_res = load_config()
|
||||
_log = _buf.getvalue()
|
||||
check("缺失配置:返回安全空 dict", _res == {})
|
||||
check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log)
|
||||
|
||||
_corrupt = os.path.join(_TMP["base"], "corrupt.json")
|
||||
with open(_corrupt, "w", encoding="utf-8") as f:
|
||||
f.write("{ this is not valid json")
|
||||
os.environ["HAOCODE_CONFIG_FILE"] = _corrupt
|
||||
_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(_buf):
|
||||
_res = load_config()
|
||||
_log = _buf.getvalue()
|
||||
check("损坏配置:返回安全空 dict", _res == {})
|
||||
check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log)
|
||||
|
||||
_notdict = os.path.join(_TMP["base"], "notdict.json")
|
||||
with open(_notdict, "w", encoding="utf-8") as f:
|
||||
f.write("[1, 2, 3]")
|
||||
os.environ["HAOCODE_CONFIG_FILE"] = _notdict
|
||||
_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(_buf):
|
||||
_res = load_config()
|
||||
_log = _buf.getvalue()
|
||||
check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log)
|
||||
|
||||
os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置
|
||||
check("环境变量恢复:config_path 回到临时目录",
|
||||
os.path.normcase(config_path()) == os.path.normcase(_TMP["config"]))
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 3) 临时配置读取(llm_engine 统一入口)
|
||||
# ======================================================================
|
||||
_cfg_now = le._load_config()
|
||||
check("llm_engine._load_config 读取临时配置(含测试 provider)",
|
||||
"testprov" in _cfg_now.get("providers", {}))
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录
|
||||
# ======================================================================
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
import ui.views.bash_panel as bp # noqa: E402
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
oi = OpenInterceptor()
|
||||
si = SqliteInterceptor()
|
||||
with oi, si:
|
||||
win = MainWindow()
|
||||
# 宽度记录写回(config.json 写路径)
|
||||
bp.save_panel_width(340)
|
||||
win.close()
|
||||
win.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
check("MainWindow 构造期间真实配置从未被打开",
|
||||
not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}")
|
||||
_cfg_ops = oi.config_ops()
|
||||
check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)",
|
||||
len(_cfg_ops) >= 1, f"记录: {_cfg_ops}")
|
||||
_check_base = os.path.normcase(os.path.abspath(_TMP["base"]))
|
||||
_outside = [op for op in _cfg_ops
|
||||
if not os.path.normcase(op[0]).startswith(_check_base)]
|
||||
check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}")
|
||||
_write_ops = [op for op in _cfg_ops if "w" in op[1]]
|
||||
check("配置写路径(bash_panel 宽度)落在临时目录",
|
||||
bool(_write_ops) and not [op for op in _write_ops
|
||||
if not os.path.normcase(op[0]).startswith(_check_base)],
|
||||
f"写记录: {_write_ops}")
|
||||
check("save_panel_width 写回可被读回", bp.load_panel_width() == 340)
|
||||
|
||||
_db_outside = [p for p in si.paths
|
||||
if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base)
|
||||
or p in (":memory:", ""))]
|
||||
check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}")
|
||||
check("MainWindow.config_data 来自临时配置(providers 无真实凭据)",
|
||||
set(win.config_data.get("providers", {}).keys()) == {"testprov"})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 汇总
|
||||
# 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误,
|
||||
# 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。
|
||||
# ======================================================================
|
||||
print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True)
|
||||
os._exit(0 if ok else 1)
|
||||
@@ -0,0 +1,336 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
会话复制(DBManager.copy_session)单测
|
||||
—— 全程临时 DB + 临时附件目录,绝不触碰真实 data/chat_history.db
|
||||
|
||||
覆盖:
|
||||
T1 线性会话复制(消息数/顺序/内容一致,ID 全新)
|
||||
T2 parent_id 链重建正确(首条 parent 为 None,链走通)
|
||||
T3 分叉会话整树复制(分支数一致)
|
||||
T4 压缩标记原样复制(数量/内容/切点关系/时间戳晚于全部保留行)
|
||||
T5 current_leaf_msg_id 重映射且真实存在
|
||||
T6 源会话零改动(全列快照比对)
|
||||
T7 删副本 → 源完好;删源 → 副本完好
|
||||
T8 图片附件物理复制到新路径(新旧文件同时存在、内容相同)
|
||||
T9 文本附件原样保留(不产生新文件、metadata 逐字节相同)
|
||||
T10 title / is_starred / mode / has_messages / sort_order 语义
|
||||
T11 不存在的 session_id → None,且无残留
|
||||
T12 副本再复制 → 标题 (副本 2)
|
||||
T13 外键无违规 + 无孤儿 parent_id
|
||||
T14 get_message_chain(源) 与 副本 的 (role, content, is_ignored) 序列完全相同
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_copy_session.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="hocode_copysess_")
|
||||
_ATT = os.path.join(_TMP, "data", "attachments")
|
||||
os.makedirs(_ATT, exist_ok=True)
|
||||
|
||||
db = DBManager(os.path.join(_TMP, "data", "chat_history.db"))
|
||||
db.files_root = _TMP # 附件根指向临时目录
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 工具函数
|
||||
# ======================================================================
|
||||
def mk_session(title, msgs, mode=None, starred=0, has_messages=1, att_meta=None):
|
||||
"""建一个线性会话。msgs=[(role, content)];att_meta={index: json_string}
|
||||
返回 (session_id, [msg_id])"""
|
||||
sid = "sess_" + uuid.uuid4().hex[:12]
|
||||
now = int(time.time())
|
||||
with db.get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO sessions (id,title,created_at,updated_at,has_messages,sort_order,"
|
||||
"is_starred,current_leaf_msg_id,mode) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(sid, title, now, now, has_messages, db.get_min_sort_order(), starred, None, mode))
|
||||
prev, ids = None, []
|
||||
for i, (role, content) in enumerate(msgs):
|
||||
mid = ("comp_" if role == "compaction" else "msg_") + uuid.uuid4().hex[:16]
|
||||
meta = (att_meta or {}).get(i)
|
||||
cur.execute(
|
||||
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
|
||||
"created_at,attachment_metadata,parent_id,timeline,usage) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(mid, sid, role, content, "思考内容", 0, now - 1000 + i, meta, prev,
|
||||
'{"t":1}', '{"input":10,"output":5}'))
|
||||
ids.append(mid)
|
||||
prev = mid
|
||||
cur.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev, sid))
|
||||
conn.commit()
|
||||
return sid, ids
|
||||
|
||||
|
||||
def add_child(session_id, parent_id, role, content, ts):
|
||||
"""在指定 parent 下挂一个子消息(构造分叉用)"""
|
||||
mid = "msg_" + uuid.uuid4().hex[:16]
|
||||
with db.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
|
||||
"created_at,attachment_metadata,parent_id,timeline,usage) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(mid, session_id, role, content, "", 0, ts, None, parent_id, None, None))
|
||||
conn.commit()
|
||||
return mid
|
||||
|
||||
|
||||
def rows_of(sid):
|
||||
with db.get_connection() as conn:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||
(sid,)).fetchall()]
|
||||
|
||||
|
||||
def sess_of(sid):
|
||||
with db.get_connection() as conn:
|
||||
r = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def sess_count():
|
||||
with db.get_connection() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
|
||||
|
||||
def chain_sig(sid):
|
||||
"""(role, content, is_ignored) 序列"""
|
||||
return [(m["role"], m["content"], m["is_ignored"])
|
||||
for m in db.get_message_chain(sid)]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
try:
|
||||
# ---------------- T1/T2: 线性会话 ----------------
|
||||
src, src_ids = mk_session("线性会话", [
|
||||
("system", "sys"), ("user", "u1"), ("assistant", "a1"),
|
||||
("user", "u2"), ("assistant", "a2")])
|
||||
n_before = sess_count()
|
||||
cpy = db.copy_session(src)
|
||||
check("T1.1 返回新会话 dict 且 id 不同", cpy and cpy["id"] != src)
|
||||
check("T1.2 会话数 +1", sess_count() == n_before + 1, f"{n_before}->{sess_count()}")
|
||||
|
||||
s_rows, c_rows = rows_of(src), rows_of(cpy["id"])
|
||||
check("T1.3 消息数一致", len(s_rows) == len(c_rows) == 5, f"{len(s_rows)}/{len(c_rows)}")
|
||||
check("T1.4 ID 全新无交集",
|
||||
not (set(r["id"] for r in s_rows) & set(r["id"] for r in c_rows)))
|
||||
check("T1.5 role+content 序列一致",
|
||||
[(r["role"], r["content"]) for r in s_rows] ==
|
||||
[(r["role"], r["content"]) for r in c_rows])
|
||||
check("T1.6 reasoning/timeline/usage 原样复制",
|
||||
all(s["reasoning"] == c["reasoning"] and s["timeline"] == c["timeline"]
|
||||
and s["usage"] == c["usage"] for s, c in zip(s_rows, c_rows)))
|
||||
|
||||
# T2: parent 链重建
|
||||
idmap = {s["id"]: c["id"] for s, c in zip(s_rows, c_rows)}
|
||||
ok_chain = c_rows[0]["parent_id"] is None
|
||||
for s, c in list(zip(s_rows, c_rows))[1:]:
|
||||
ok_chain = ok_chain and c["parent_id"] == idmap.get(s["parent_id"])
|
||||
check("T2.1 parent_id 全量重映射", ok_chain)
|
||||
check("T2.2 副本链首 parent 为 None(未指回源 ID)", c_rows[0]["parent_id"] is None)
|
||||
check("T2.3 副本链里不含任何源 ID",
|
||||
not (set(idmap.keys()) & set(r["parent_id"] for r in c_rows if r["parent_id"])))
|
||||
|
||||
# ---------------- T3: 分叉会话整树复制 ----------------
|
||||
sid3, ids3 = mk_session("分叉会话", [("system", "s"), ("user", "q")])
|
||||
base = ids3[-1]
|
||||
add_child(sid3, base, "assistant", "br1", int(time.time()) + 10)
|
||||
add_child(sid3, base, "assistant", "br2", int(time.time()) + 11)
|
||||
copy3 = db.copy_session(sid3)
|
||||
c3 = rows_of(copy3["id"])
|
||||
src_branches = len(db.get_branch_info(base))
|
||||
c_base = [r for r in c3 if r["role"] == "user"][0]["id"]
|
||||
check("T3.1 源有 2 个分支", src_branches == 2, str(src_branches))
|
||||
check("T3.2 副本分支数一致", len(db.get_branch_info(c_base)) == 2,
|
||||
str(len(db.get_branch_info(c_base))))
|
||||
check("T3.3 副本消息总数一致", len(c3) == len(rows_of(sid3)) == 4)
|
||||
check("T3.4 分支内容一致",
|
||||
sorted(b["content"] for b in db.get_branch_info(c_base)) == ["br1", "br2"])
|
||||
|
||||
# ---------------- T4: 压缩标记 ----------------
|
||||
sid4, ids4 = mk_session("压缩会话", [
|
||||
("system", "s"), ("user", "u1"), ("assistant", "a1"),
|
||||
("user", "u2"), ("assistant", "a2")])
|
||||
mark = db.insert_compaction_mark(sid4, "【摘要】前面聊了 u1/a1",
|
||||
cut_before_id=ids4[2], first_retained_id=ids4[3])
|
||||
copy4 = db.copy_session(sid4)
|
||||
c4 = rows_of(copy4["id"])
|
||||
s4 = rows_of(sid4)
|
||||
c_marks = [r for r in c4 if r["role"] == "compaction"]
|
||||
s_marks = [r for r in s4 if r["role"] == "compaction"]
|
||||
check("T4.1 标记存在且已复制", len(s_marks) == 1 and len(c_marks) == 1)
|
||||
check("T4.2 摘要内容一致", c_marks and c_marks[0]["content"] == s_marks[0]["content"])
|
||||
m4 = {s["id"]: c["id"] for s, c in zip(s4, c4)}
|
||||
check("T4.3 标记 parent 指向副本的 cut_before",
|
||||
c_marks[0]["parent_id"] == m4.get(ids4[2]))
|
||||
check("T4.4 first_retained 的 parent 指向副本标记",
|
||||
[r for r in c4 if r["id"] == m4.get(ids4[3])][0]["parent_id"] == c_marks[0]["id"])
|
||||
check("T4.5 标记 id 带 comp_ 前缀", c_marks[0]["id"].startswith("comp_"))
|
||||
retained = [r for r in c4 if r["created_at"] is not None
|
||||
and r["id"] != c_marks[0]["id"]]
|
||||
check("T4.6 标记时间戳晚于全部其它行",
|
||||
all(c_marks[0]["created_at"] >= r["created_at"] for r in retained))
|
||||
check("T4.7 标记 is_ignored 保持 1", c_marks[0]["is_ignored"] == 1)
|
||||
# created_at 相对次序必须与源完全一致(兄弟排序依赖它)
|
||||
s_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in s4]),
|
||||
key=lambda x: x[1])]
|
||||
c_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in c4]),
|
||||
key=lambda x: x[1])]
|
||||
check("T4.8 created_at 相对次序与源一致", s_order == c_order, f"{s_order} vs {c_order}")
|
||||
|
||||
# ---------------- T5: 叶子重映射 ----------------
|
||||
check("T5.1 副本叶子非空且不是源叶子",
|
||||
cpy["current_leaf_msg_id"] and cpy["current_leaf_msg_id"] != sess_of(src)["current_leaf_msg_id"])
|
||||
check("T5.2 副本叶子真实存在于副本",
|
||||
cpy["current_leaf_msg_id"] in [r["id"] for r in c_rows])
|
||||
check("T5.3 副本叶子对应源叶子的内容",
|
||||
[r for r in c_rows if r["id"] == cpy["current_leaf_msg_id"]][0]["content"]
|
||||
== s_rows[-1]["content"])
|
||||
|
||||
# ---------------- T6: 源会话零改动 ----------------
|
||||
src_snapshot = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
|
||||
_ = db.copy_session(src)
|
||||
src_after = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
|
||||
check("T6.1 复制两次后源 sessions 行不变", src_snapshot[0] == src_after[0])
|
||||
check("T6.2 复制两次后源 messages 全列不变", src_snapshot[1] == src_after[1])
|
||||
|
||||
# ---------------- T7: 双向删除隔离 ----------------
|
||||
sid7, _ = mk_session("隔离A", [("system", "s"), ("user", "x")])
|
||||
c7 = db.copy_session(sid7)
|
||||
db.delete_session(c7["id"])
|
||||
check("T7.1 删副本后源仍在且消息完整",
|
||||
sess_of(sid7) is not None and len(rows_of(sid7)) == 2)
|
||||
# 方向二另起一对,避免与方向一互相干扰
|
||||
sid7b, _ = mk_session("隔离B", [("system", "s"), ("user", "y")])
|
||||
c7b = db.copy_session(sid7b)
|
||||
db.delete_session(sid7b)
|
||||
check("T7.2 删源后副本仍在且消息完整",
|
||||
sess_of(c7b["id"]) is not None and len(rows_of(c7b["id"])) == 2,
|
||||
f'cpy sess={sess_of(c7b["id"]) is not None} rows={len(rows_of(c7b["id"]))}')
|
||||
|
||||
# ---------------- T8: 图片附件物理复制 ----------------
|
||||
png_src = os.path.join(_ATT, "img_src_test.png")
|
||||
with open(png_src, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + b"FAKEIMAGEDATA" * 8)
|
||||
img_meta = json.dumps({"user_text": "看看这张图",
|
||||
"attachments": [{"type": "image", "size_kb": 0.2,
|
||||
"local_path": "data/attachments/img_src_test.png"}]},
|
||||
ensure_ascii=False)
|
||||
sid8, _ = mk_session("图片会话", [("system", "s"), ("user", "带图")],
|
||||
att_meta={1: img_meta})
|
||||
c8 = db.copy_session(sid8)
|
||||
m8 = [r for r in rows_of(c8["id"]) if r["attachment_metadata"]][0]
|
||||
meta8 = json.loads(m8["attachment_metadata"])
|
||||
new_rel = meta8["attachments"][0]["local_path"]
|
||||
new_abs = os.path.join(_TMP, new_rel)
|
||||
check("T8.1 local_path 已改写为新文件",
|
||||
new_rel != "data/attachments/img_src_test.png", new_rel)
|
||||
check("T8.2 新旧文件同时存在",
|
||||
os.path.isfile(png_src) and os.path.isfile(new_abs))
|
||||
check("T8.3 新文件内容与源一致",
|
||||
open(new_abs, "rb").read() == open(png_src, "rb").read())
|
||||
check("T8.4 user_text 等其它字段保留", meta8["user_text"] == "看看这张图")
|
||||
check("T8.5 源 metadata 未被改动",
|
||||
json.loads([r for r in rows_of(sid8) if r["attachment_metadata"]][0]
|
||||
["attachment_metadata"])["attachments"][0]["local_path"]
|
||||
== "data/attachments/img_src_test.png")
|
||||
|
||||
# 缺文件容错:metadata 保留原路径,不抛异常
|
||||
bad_meta = json.dumps({"user_text": "x", "attachments": [
|
||||
{"type": "image", "local_path": "data/attachments/does_not_exist.png"}]},
|
||||
ensure_ascii=False)
|
||||
sid8b, _ = mk_session("缺文件", [("system", "s"), ("user", "y")], att_meta={1: bad_meta})
|
||||
c8b = db.copy_session(sid8b)
|
||||
bad_after = [r for r in rows_of(c8b["id"]) if r["attachment_metadata"]][0]
|
||||
check("T8.6 源文件缺失时保留原路径且不失败",
|
||||
json.loads(bad_after["attachment_metadata"])["attachments"][0]["local_path"]
|
||||
== "data/attachments/does_not_exist.png")
|
||||
|
||||
# ---------------- T9: 文本附件原样 ----------------
|
||||
txt_meta = json.dumps({"user_text": "", "attachments": [
|
||||
{"type": "text", "size_kb": 1.0, "lines": 3, "content": "aaa\nbbb\nccc"}]},
|
||||
ensure_ascii=False)
|
||||
sid9, _ = mk_session("文本附件", [("system", "s"), ("user", "t")], att_meta={1: txt_meta})
|
||||
n_files_before = len(os.listdir(_ATT))
|
||||
c9 = db.copy_session(sid9)
|
||||
t9 = [r for r in rows_of(c9["id"]) if r["attachment_metadata"]][0]
|
||||
check("T9.1 文本附件 metadata 逐字节相同",
|
||||
t9["attachment_metadata"] == txt_meta, t9["attachment_metadata"][:60])
|
||||
check("T9.2 文本附件不产生新文件", len(os.listdir(_ATT)) == n_files_before)
|
||||
|
||||
# ---------------- T10: 字段语义 ----------------
|
||||
sid10, _ = mk_session("语义检查", [("system", "s"), ("user", "m")],
|
||||
mode="worker", starred=1)
|
||||
min_order_before = db.get_min_sort_order()
|
||||
c10 = db.copy_session(sid10)
|
||||
check("T10.1 标题 = 原名 + ' (副本)'", c10["title"] == "语义检查 (副本)", c10["title"])
|
||||
check("T10.2 副本不带星标", c10["is_starred"] == 0, str(c10["is_starred"]))
|
||||
check("T10.3 mode 跟随源", c10["mode"] == "worker", str(c10["mode"]))
|
||||
check("T10.4 has_messages 跟随源", c10["has_messages"] == sess_of(sid10)["has_messages"])
|
||||
check("T10.5 sort_order 置顶", c10["sort_order"] == min_order_before,
|
||||
f'{c10["sort_order"]} vs {min_order_before}')
|
||||
check("T10.6 源仍是星标且未被改标题",
|
||||
sess_of(sid10)["is_starred"] == 1 and sess_of(sid10)["title"] == "语义检查")
|
||||
check("T10.7 created_at/updated_at = 当前时间",
|
||||
abs(c10["updated_at"] - int(time.time())) <= 5)
|
||||
|
||||
# 无消息的会话(只有 system 行)也能复制
|
||||
sid10b, ids10b = mk_session("孤儿", [("system", "only")], has_messages=0)
|
||||
c10b = db.copy_session(sid10b)
|
||||
check("T10.8 仅 system 行的会话可复制",
|
||||
c10b and len(rows_of(c10b["id"])) == 1 and c10b["current_leaf_msg_id"] is not None)
|
||||
|
||||
# ---------------- T12: 副本再复制 → (副本 2) ----------------
|
||||
c12 = db.copy_session(c10["id"])
|
||||
check("T12.1 再复制标题递增为 (副本 2)", c12["title"] == "语义检查 (副本 2)", c12["title"])
|
||||
c12b = db.copy_session(c12["id"])
|
||||
check("T12.2 第三次复制为 (副本 3)", c12b["title"] == "语义检查 (副本 3)", c12b["title"])
|
||||
c12c = db.copy_session(sid10, new_title="自定义标题")
|
||||
check("T12.3 可显式指定标题", c12c["title"] == "自定义标题", c12c["title"])
|
||||
|
||||
# ---------------- T11: 不存在的会话 ----------------
|
||||
n11 = sess_count()
|
||||
check("T11.1 不存在 → None", db.copy_session("sess_not_exist_xxx") is None)
|
||||
check("T11.2 无残留(会话数不变)", sess_count() == n11)
|
||||
|
||||
# ---------------- T13: 外键 / 孤儿 ----------------
|
||||
bad_fk = 0
|
||||
orphans = 0
|
||||
with db.get_connection() as conn:
|
||||
bad_fk = len(conn.execute("PRAGMA foreign_key_check").fetchall())
|
||||
for r in rows_of(cpy["id"]):
|
||||
if r["parent_id"] and r["parent_id"] not in [x["id"] for x in c_rows]:
|
||||
orphans += 1
|
||||
check("T13.1 外键无违规", bad_fk == 0, str(bad_fk))
|
||||
check("T13.2 无孤儿 parent_id", orphans == 0, str(orphans))
|
||||
|
||||
# ---------------- T14: 链签名一致(端到端) ----------------
|
||||
check("T14.1 线性会话链签名一致", chain_sig(src) == chain_sig(cpy["id"]))
|
||||
check("T14.2 分叉会话链签名一致", chain_sig(sid3) == chain_sig(copy3["id"]))
|
||||
check("T14.3 压缩会话链签名一致(含标记行)", chain_sig(sid4) == chain_sig(copy4["id"]))
|
||||
|
||||
finally:
|
||||
shutil.rmtree(_TMP, ignore_errors=True)
|
||||
|
||||
failed = [n for n, ok in RESULTS if not ok]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,253 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
P1-02 跨平台 shell 契约单测(纯函数级 + 真实进程级,无 UI 依赖)
|
||||
|
||||
覆盖:
|
||||
A. 平台参数:Windows `cmd.exe /d /c` argv / Linux `/bin/bash -lc` argv;
|
||||
Linux Popen 独立进程组(start_new_session),Windows 无该参数
|
||||
B. 提示词平台段:通用正文完全相同、只插入对应平台段、互不串段;
|
||||
load_system_prompt() 与文件内容一致(当前平台)
|
||||
C. 进程树(真实进程,父+孙):
|
||||
- 当前平台:超时 → 父与孙都不存在
|
||||
- 当前平台:主动中止 → 父与孙都不存在
|
||||
- Linux 专属:独立进程组整组终止(Windows 上跳过)
|
||||
D. kill_process_tree 安全边界:已退出的进程 / 无独立组 → 不抛异常、不误杀
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_cross_platform_shell.py
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core import platform_shell # noqa: E402
|
||||
from core.agent.tools import tool_bash # noqa: E402
|
||||
from core.agent.types import AbortSignal # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
def text_of(res):
|
||||
c = res.content
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
|
||||
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="haocode_crossshell_")
|
||||
PY = sys.executable
|
||||
IS_WIN = os.name == "nt"
|
||||
|
||||
# ======================================================================
|
||||
# A. 平台参数(mock 平台,纯逻辑)
|
||||
# ======================================================================
|
||||
_orig_is_win = platform_shell.is_windows
|
||||
|
||||
|
||||
def _mock_win(v):
|
||||
platform_shell.is_windows = lambda: v
|
||||
|
||||
|
||||
try:
|
||||
_mock_win(True)
|
||||
check("A1 Windows 命令 = cmd.exe /d /s /c \"<cmd>\"",
|
||||
platform_shell.shell_command("echo hi")
|
||||
== 'cmd.exe /d /s /c "echo hi"',
|
||||
repr(platform_shell.shell_command("echo hi")))
|
||||
check("A2 Windows popen_flags 无进程组参数",
|
||||
platform_shell.popen_flags() == {}, repr(platform_shell.popen_flags()))
|
||||
|
||||
_mock_win(False)
|
||||
check("A3 Linux argv = /bin/bash -lc <cmd>",
|
||||
platform_shell.shell_command("echo hi") == ["/bin/bash", "-lc", "echo hi"],
|
||||
repr(platform_shell.shell_command("echo hi")))
|
||||
check("A4 Linux popen_flags 独立进程组",
|
||||
platform_shell.popen_flags() == {"start_new_session": True},
|
||||
repr(platform_shell.popen_flags()))
|
||||
finally:
|
||||
platform_shell.is_windows = _orig_is_win
|
||||
|
||||
# ======================================================================
|
||||
# B. 提示词平台段
|
||||
# ======================================================================
|
||||
_PROMPT_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"SYSTEM_PROMPT.md")
|
||||
with open(_PROMPT_FILE, "r", encoding="utf-8") as f:
|
||||
_PROMPT_TEXT = f.read().strip()
|
||||
|
||||
check("B1 通用正文含占位符",
|
||||
platform_shell.SHELL_SECTION_PLACEHOLDER in _PROMPT_TEXT)
|
||||
|
||||
_GENERIC_NO_LEAK = ("cmd.exe" not in _PROMPT_TEXT
|
||||
and "/bin/bash" not in _PROMPT_TEXT)
|
||||
check("B2 通用正文无平台泄漏(无 cmd.exe / 无 /bin/bash)", _GENERIC_NO_LEAK)
|
||||
|
||||
try:
|
||||
_mock_win(True)
|
||||
full_win = platform_shell.apply_platform_section(_PROMPT_TEXT)
|
||||
sec_win = platform_shell.shell_prompt_section()
|
||||
_mock_win(False)
|
||||
full_lin = platform_shell.apply_platform_section(_PROMPT_TEXT)
|
||||
sec_lin = platform_shell.shell_prompt_section()
|
||||
finally:
|
||||
platform_shell.is_windows = _orig_is_win
|
||||
|
||||
check("B3 Windows 段含 cmd.exe、无 /bin/bash",
|
||||
"cmd.exe" in sec_win and "/bin/bash" not in sec_win)
|
||||
check("B4 Linux 段含 /bin/bash -lc、无 cmd.exe",
|
||||
"/bin/bash -lc" in sec_lin and "cmd.exe" not in sec_lin)
|
||||
check("B5 只有对应平台段被插入",
|
||||
full_win == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_win)
|
||||
and full_lin == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_lin))
|
||||
check("B6 通用正文两平台完全相同",
|
||||
full_win.replace(sec_win, "§SEC§") == full_lin.replace(sec_lin, "§SEC§"))
|
||||
check("B7 无占位符文本原样返回",
|
||||
platform_shell.apply_platform_section("无占位符的兜底文本") == "无占位符的兜底文本")
|
||||
|
||||
# load_system_prompt()(当前平台,真实文件)
|
||||
from core import llm_engine # noqa: E402 (PyQt6/openai 已装,无需 QApplication)
|
||||
check("B8 load_system_prompt = 文件内容 + 当前平台段",
|
||||
llm_engine.load_system_prompt()
|
||||
== platform_shell.apply_platform_section(_PROMPT_TEXT))
|
||||
|
||||
# ======================================================================
|
||||
# C. 进程树(真实进程:父 + 孙,心跳文件证明生死)
|
||||
# ======================================================================
|
||||
_CHILD = os.path.join(_TMP, "child.py")
|
||||
with open(_CHILD, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"import sys, time\n"
|
||||
"hb = open(sys.argv[1], 'a', encoding='utf-8')\n"
|
||||
"t0 = time.time()\n"
|
||||
"while time.time() - t0 < 30:\n"
|
||||
" hb.write(f'{time.time():.3f}\\n')\n"
|
||||
" hb.flush()\n"
|
||||
" time.sleep(0.2)\n"
|
||||
"open(sys.argv[2], 'w').write('done')\n"
|
||||
)
|
||||
|
||||
|
||||
def _spawn_parent(hb_path, done_path, out_path):
|
||||
"""写一个父进程脚本(启动孙进程后挂 30s),返回 bash 命令字符串。"""
|
||||
parent = os.path.join(_TMP, f"parent_{os.path.basename(hb_path)}.py")
|
||||
with open(parent, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"import subprocess, sys, time\n"
|
||||
"p = subprocess.Popen([sys.executable, "
|
||||
+ repr(_CHILD) + ", sys.argv[1], sys.argv[2]], "
|
||||
"stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
|
||||
"time.sleep(30)\n"
|
||||
)
|
||||
cmd = f'"{PY}" -u "{parent}" "{hb_path}" "{done_path}"'
|
||||
return cmd
|
||||
|
||||
|
||||
def _hb_last(hb_path):
|
||||
try:
|
||||
with open(hb_path, "r", encoding="utf-8") as f:
|
||||
lines = [l.strip() for l in f if l.strip()]
|
||||
return float(lines[-1]) if lines else None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _tree_dead(hb_path, done_path, quiesce=1.2):
|
||||
"""等待静默后判断:孙进程不再有心跳、且未跑完 → 进程树已死。"""
|
||||
time.sleep(quiesce)
|
||||
last = _hb_last(hb_path)
|
||||
return last is not None and (time.time() - last) > 0.6, os.path.exists(done_path)
|
||||
|
||||
|
||||
def run_tree_case(tag, timeout=None, abort_after=None):
|
||||
hb = os.path.join(_TMP, f"hb_{tag}.txt")
|
||||
done = os.path.join(_TMP, f"done_{tag}.txt")
|
||||
out = os.path.join(_TMP, f"out_{tag}.log")
|
||||
cmd = _spawn_parent(hb, done, out)
|
||||
|
||||
updates, timers = [], []
|
||||
sig = AbortSignal()
|
||||
if abort_after is not None:
|
||||
import threading
|
||||
def _aborter():
|
||||
time.sleep(abort_after)
|
||||
sig.abort("test")
|
||||
threading.Thread(target=_aborter, daemon=True).start()
|
||||
|
||||
t0 = time.time()
|
||||
res = tool_bash(f"call_{tag}", {"command": cmd, "timeout": timeout or 60}, sig,
|
||||
lambda t: updates.append(t),
|
||||
{"cwd": _TMP, "on_timer": lambda e, tt: timers.append((e, tt))})
|
||||
dur = time.time() - t0
|
||||
return res, dur, hb, done, timers
|
||||
|
||||
|
||||
# ---- C1: 超时 → 父与孙都不存在 ----
|
||||
res, dur, hb, done, timers = run_tree_case("timeout", timeout=3)
|
||||
txt = text_of(res)
|
||||
dead, finished = _tree_dead(hb, done)
|
||||
check("C1.1 超时返回错误结果", res.is_error and "超时" in txt, f"{txt!r}")
|
||||
check("C1.2 超时快速返回(<15s,含 taskkill 缓冲)", dur < 15, f"dur={dur:.1f}s")
|
||||
check("C1.3 超时后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
|
||||
f"hb_last={_hb_last(hb)} finished={finished}")
|
||||
check("C1.4 读秒滴答仍工作", len(timers) >= 2, f"timers={timers}")
|
||||
|
||||
# ---- C2: 主动中止 → 父与孙都不存在 ----
|
||||
res, dur, hb, done, _timers2 = run_tree_case("abort", timeout=60, abort_after=1.5)
|
||||
txt = text_of(res)
|
||||
dead, finished = _tree_dead(hb, done)
|
||||
check("C2.1 中止返回错误结果", res.is_error and "中止" in txt, f"{txt!r}")
|
||||
check("C2.2 中止后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
|
||||
f"hb_last={_hb_last(hb)} finished={finished}")
|
||||
|
||||
# ---- C3: Linux 独立进程组整组终止(Windows 跳过) ----
|
||||
if not IS_WIN:
|
||||
proc = subprocess.Popen(
|
||||
platform_shell.shell_command("sleep 30"),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
**platform_shell.popen_flags())
|
||||
time.sleep(0.3)
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
check("C3.1 start_new_session 生效(pgid == 子 pid)", pgid == proc.pid,
|
||||
f"pgid={pgid} pid={proc.pid}")
|
||||
platform_shell.kill_process_tree(proc, grace_s=1.0)
|
||||
time.sleep(0.5)
|
||||
check("C3.2 整组终止后子进程不存在",
|
||||
proc.poll() is not None, f"poll={proc.poll()}")
|
||||
except Exception as e:
|
||||
try:
|
||||
platform_shell.kill_process_tree(proc, grace_s=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
check("C3 Linux 进程组用例", False, repr(e))
|
||||
else:
|
||||
print("SKIP C3 Linux 进程组用例(当前为 Windows,Windows 用例见 C1/C2)", flush=True)
|
||||
|
||||
# ======================================================================
|
||||
# D. kill_process_tree 安全边界
|
||||
# ======================================================================
|
||||
_p = subprocess.Popen([PY, "-c", "pass"])
|
||||
_p.wait(timeout=5)
|
||||
try:
|
||||
platform_shell.kill_process_tree(_p, grace_s=0.5)
|
||||
check("D1 已退出进程:不抛异常", True)
|
||||
except Exception as e:
|
||||
check("D1 已退出进程:不抛异常", False, repr(e))
|
||||
|
||||
try:
|
||||
platform_shell.kill_process_tree(None)
|
||||
check("D2 None 输入:不抛异常", True)
|
||||
except Exception as e:
|
||||
check("D2 None 输入:不抛异常", False, repr(e))
|
||||
|
||||
# ======================================================================
|
||||
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
|
||||
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""独立调试器窗口 + 调试日志协议 回归测试(离屏,临时文件,不碰真实 DB/日志)
|
||||
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/test_debug_window.py
|
||||
"""
|
||||
import sys, os, re, tempfile
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
_TMPDIR = tempfile.mkdtemp(prefix="haocode_dbg_")
|
||||
os.environ["HAOCODE_DEBUG_LOG"] = os.path.join(_TMPDIR, "debug_session.log")
|
||||
os.environ["HAOCODE_DEBUG_CMD"] = os.path.join(_TMPDIR, "debug_window.cmd")
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
|
||||
from core import debug_log as dl
|
||||
|
||||
PASS, FAIL = 0, 0
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {extra}")
|
||||
|
||||
|
||||
def read_log():
|
||||
if not os.path.exists(dl.DEBUG_LOG_PATH):
|
||||
return ""
|
||||
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
LINE_RE = re.compile(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] "
|
||||
r"\[(USER|AGENT|APP|SYS)\] .+$")
|
||||
|
||||
# ============ T1: debug_log 三方写入 + 行格式 ============
|
||||
print("T1: debug_log 行格式与三方 TAG")
|
||||
dl.debug_log("hello app", "APP")
|
||||
dl.debug_log("代理注入一条备注", "AGENT")
|
||||
dl.debug_log("用户观察到标签 40.5k", "USER")
|
||||
lines = [l for l in read_log().splitlines() if l]
|
||||
check("三行全部写入", len(lines) == 3, lines)
|
||||
check("行格式 [ts] [TAG] msg",
|
||||
all(LINE_RE.match(l) for l in lines), lines)
|
||||
check("TAG 顺序 APP/AGENT/USER",
|
||||
[re.search(r"\[(USER|AGENT|APP|SYS)\]", l).group(1) for l in lines]
|
||||
== ["APP", "AGENT", "USER"])
|
||||
|
||||
# ============ T2: poll_debug_cmd 控制协议 ============
|
||||
print("T2: poll_debug_cmd 消费语义")
|
||||
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("show\n")
|
||||
check("show 被识别", dl.poll_debug_cmd() == "show")
|
||||
check("文件被消费(再读为 None)", dl.poll_debug_cmd() is None)
|
||||
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(" HIDE ")
|
||||
check("hide 大小写/空白容忍", dl.poll_debug_cmd() == "hide")
|
||||
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("garbage")
|
||||
check("非法内容忽略", dl.poll_debug_cmd() is None)
|
||||
|
||||
# ============ T3-T6: DebugWindow 行为(离屏) ============
|
||||
print("T3: DebugWindow 用户输入 → [USER] 落盘")
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
import ui.views.debug_window as dw
|
||||
# 应用日志 tab 指向临时文件(不读真实 diag.log)
|
||||
_tmp_diag = os.path.join(_TMPDIR, "diag.log")
|
||||
dw._APP_LOGS[:] = [("DIAG", _tmp_diag)]
|
||||
|
||||
win = dw.DebugWindow()
|
||||
check("窗口标题独立", win.windowTitle() == "Haocode 调试器")
|
||||
win._input.setText(" 发送后标签跳到 80k ")
|
||||
win._on_submit()
|
||||
check("输入框被清空", win._input.text() == "")
|
||||
check("[USER] 已落盘且去首尾空白",
|
||||
any(l.endswith("[USER] 发送后标签跳到 80k") for l in read_log().splitlines()))
|
||||
|
||||
print("T4: 首次 tick 记 [SYS] 开启事件")
|
||||
win._tick()
|
||||
check("[SYS] 调试窗口开启 已写入",
|
||||
any("[SYS] 调试窗口开启" in l for l in read_log().splitlines()))
|
||||
|
||||
print("T5: 实时 tail 会话日志 + 应用日志")
|
||||
dl.debug_log("tick 前注入的 APP 事件", "APP")
|
||||
dl.debug_log("AGENT: 现在检查 compaction_diag.log", "AGENT")
|
||||
win._tick()
|
||||
txt = win._view_session.toPlainText()
|
||||
check("会话视图含 APP 事件", "tick 前注入的 APP 事件" in txt, txt[-300:])
|
||||
check("会话视图含 AGENT 注入", "AGENT: 现在检查 compaction_diag.log" in txt)
|
||||
with open(_tmp_diag, "w", encoding="utf-8") as f:
|
||||
f.write("[12:00:00.000] DIAG_TEST_LINE\n")
|
||||
win._tick()
|
||||
check("应用日志视图含 DIAG tail",
|
||||
"DIAG_TEST_LINE" in win._view_app.toPlainText())
|
||||
check("应用日志带文件头", "[DIAG]" in win._view_app.toPlainText())
|
||||
|
||||
print("T6: 暂停显示 + 截断重置")
|
||||
win._chk_pause.setChecked(True) # 暂停
|
||||
dl.debug_log("暂停期间的行不应上屏", "APP")
|
||||
win._tick()
|
||||
check("暂停期间不上屏", "暂停期间的行不应上屏"
|
||||
not in win._view_session.toPlainText())
|
||||
# 文件截断(模拟「清空会话日志」)→ 偏移重置,新行仍可读取
|
||||
win._chk_pause.setChecked(False)
|
||||
with open(dl.DEBUG_LOG_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("[2026-01-01 00:00:00.000] [SYS] 截断后新内容\n")
|
||||
win._tick()
|
||||
check("截断后偏移重置、新行上屏",
|
||||
"截断后新内容" in win._view_session.toPlainText())
|
||||
|
||||
win.close()
|
||||
|
||||
# ============ T4: 调试窗口随程序启动(autostart_debug_window) ============
|
||||
print("T4: 调试窗口随程序启动")
|
||||
p = dl.DEBUG_CMD_PATH
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
# 显式 false → 不写控制文件
|
||||
check("T4.autostart=False 不写控制文件",
|
||||
dl.autostart_debug_window({"debug_window_autostart": False}) is False
|
||||
and not os.path.exists(p))
|
||||
# 缺省(键不存在)→ 默认开
|
||||
check("T4.缺省(无键)写入 show",
|
||||
dl.autostart_debug_window({}) is True and os.path.exists(p))
|
||||
check("T4.轮询消费 show",
|
||||
dl.poll_debug_cmd() == "show" and not os.path.exists(p))
|
||||
# 显式 true
|
||||
check("T4.autostart=true 写入 show",
|
||||
dl.autostart_debug_window({"debug_window_autostart": True}) is True)
|
||||
check("T4.轮询再消费 show",
|
||||
dl.poll_debug_cmd() == "show")
|
||||
|
||||
print(f"\n===== {PASS} PASS / {FAIL} FAIL =====")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,361 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""失败轮次持久化(对照 pi:message_end 无条件入库 → 出错也写会话)+ 回放取舍。
|
||||
|
||||
核心诉求:agent 跑了 200 个工具后第 201 步出错,**已完成的工具结果不能白跑** ——
|
||||
必须入库、必须能进下次上下文,这样"接着执行最后一次"才可能。
|
||||
|
||||
覆盖矩阵:
|
||||
T1 全空失败轮 → 入库留痕(is_ignored=1),回放**跳过**
|
||||
T2 有工具/正文的失败轮 → 入库 + 回放(工具调用与结果成对)★核心
|
||||
T3 轮内重试 → 只动内存、**不入库**(不会产生一堆错误行)
|
||||
T4 孤儿工具(ok=None) → 回放注入合成结果(对照 pi insertSyntheticToolResults)
|
||||
T5 叶子前进 → 不再"时光倒流";parent_id 指向本轮提问
|
||||
T6 压缩切点交互 → 切点之后照常回放;之前 → 出上下文
|
||||
T7 旧库自动迁移 → 新列补齐,旧行 stop_reason=NULL 行为不变
|
||||
T8 copy_session → 复制错误行时携带 stop_reason/error_message/is_ignored
|
||||
|
||||
运行: QT_QPA_PLATFORM=offscreen python tests/test_error_persist.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import sqlite3
|
||||
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"
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
_TMP = isolate("errpersist", config={"providers": {}})
|
||||
_DB_TMP = _TMP["db"]
|
||||
_CFG_TMP = _TMP["config"]
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtTest import QTest # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
from core.agent.recovery import AgentRunner # noqa: E402
|
||||
from core.agent.types import (AgentConfig, AgentMessage, ModelConfig, # noqa: E402
|
||||
RetryConfig)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name +
|
||||
("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
def settle(ms=120):
|
||||
for _ in range(int(ms / 20) + 1):
|
||||
app.processEvents()
|
||||
QTest.qWait(20)
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
settle(300)
|
||||
|
||||
|
||||
def mk_failed_turn(sid, body="", reasoning="", tools=None, err="连接失败: Connection error"):
|
||||
"""模拟一次「已流出一部分 → 出错」的轮次,返回 (user_msg_id, stream_state)"""
|
||||
chain = window.db.get_message_chain(sid)
|
||||
parent = chain[-1]["id"] if chain else None
|
||||
st = {
|
||||
"msg_id": f"msg_err_{uuid.uuid4().hex[:10]}",
|
||||
"parent_id": parent,
|
||||
"previous_leaf_id": parent,
|
||||
"content": body,
|
||||
"reasoning": reasoning,
|
||||
"timeline": list(tools or []),
|
||||
"usage": {},
|
||||
"tl_kind": "text",
|
||||
"worker": None,
|
||||
}
|
||||
window.current_session_id = sid
|
||||
window._active_streams[sid] = st
|
||||
window.on_error(sid, err)
|
||||
settle(120)
|
||||
return parent, st
|
||||
|
||||
|
||||
def tool_entry(cid, name, args, ok_flag, result):
|
||||
return {"t": "tool", "id": cid, "name": name, "args": args,
|
||||
"ok": ok_flag, "result": result}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# T1 全空失败轮 → 入库留痕但回放跳过
|
||||
# ======================================================================
|
||||
sid1 = window.db.create_session("T1 全空失败")["id"]
|
||||
uid1 = window.db.add_message(session_id=sid1, role="user", content="开始吧",
|
||||
parent_id=None)["id"]
|
||||
mk_failed_turn(sid1, body="", reasoning="", tools=[])
|
||||
chain1 = window.db.get_message_chain(sid1)
|
||||
last1 = chain1[-1]
|
||||
check("T1.1 全空失败轮也入库(对照 pi 无条件 append)",
|
||||
len(chain1) == 2 and last1["role"] == "assistant", f"chain={len(chain1)}")
|
||||
check("T1.2 标记 stop_reason=error + error_message",
|
||||
last1.get("stop_reason") == "error" and "Connection error" in (last1.get("error_message") or ""),
|
||||
f"{last1.get('stop_reason')} / {last1.get('error_message')}")
|
||||
check("T1.3 全空 → is_ignored=1(只在库里留痕)", int(last1.get("is_ignored") or 0) == 1)
|
||||
check("T1.4 正文含 ⚠️ 中断说明(供历史/UI 可见)",
|
||||
"⚠️" in (last1.get("content") or "") and "本轮中断" in (last1.get("content") or ""),
|
||||
(last1.get("content") or "")[:60])
|
||||
ctx1 = window.build_api_context(sid1)
|
||||
check("T1.5 回放**跳过**全空错误行(避免空 assistant 触发服务商 400)",
|
||||
not any(m.get("role") == "assistant" for m in ctx1),
|
||||
str([m.get("role") for m in ctx1]))
|
||||
|
||||
# ======================================================================
|
||||
# T2 ★核心:200 个工具后第 201 步出错 → 工具结果必须进上下文
|
||||
# ======================================================================
|
||||
sid2 = window.db.create_session("T2 工具后出错")["id"]
|
||||
uid2 = window.db.add_message(session_id=sid2, role="user",
|
||||
content="帮我建 3 个目录", parent_id=None)["id"]
|
||||
tools2 = [tool_entry(f"c{i}", "bash", json.dumps({"command": f"mkdir d{i}"}),
|
||||
True, f"d{i} created") for i in range(1, 4)]
|
||||
tools2.append(tool_entry("c4", "bash", json.dumps({"command": "mkdir d4"}),
|
||||
None, "")) # 孤儿:开始了但没结果
|
||||
mk_failed_turn(sid2, body="正在创建目录…", reasoning="先建目录", tools=tools2)
|
||||
chain2 = window.db.get_message_chain(sid2)
|
||||
last2 = chain2[-1]
|
||||
check("T2.1 有工具/正文的失败轮入库且 is_ignored=0(会回放)",
|
||||
last2["role"] == "assistant" and int(last2.get("is_ignored") or 0) == 0,
|
||||
f"ignored={last2.get('is_ignored')}")
|
||||
check("T2.2 timeline 完整保留 4 个工具条目",
|
||||
len([e for e in json.loads(last2["timeline"] or "[]")
|
||||
if e.get("t") == "tool"]) == 4,
|
||||
str([e.get("t") for e in json.loads(last2["timeline"] or "[]")]))
|
||||
check("T2.2b timeline 尾部多一条 text 条目(中断说明,回放时才看得到)",
|
||||
(json.loads(last2["timeline"] or "[]")[-1].get("t") == "text"
|
||||
and "⚠️" in json.loads(last2["timeline"] or "[]")[-1].get("text", "")),
|
||||
str(json.loads(last2["timeline"] or "[]")[-1])[:80])
|
||||
check("T2.3 正文保留 + 尾部中断说明",
|
||||
"正在创建目录" in last2["content"] and "⚠️" in last2["content"])
|
||||
|
||||
ctx2 = window.build_api_context(sid2)
|
||||
tcs = [tc for m in ctx2 if m.get("role") == "assistant" for tc in (m.get("tool_calls") or [])]
|
||||
tool_msgs = [m for m in ctx2 if m.get("role") == "tool"]
|
||||
check("T2.4 ★回放里出现 4 个 tool_call(200 个工具的记录没丢)", len(tcs) == 4,
|
||||
f"tool_calls={len(tcs)}")
|
||||
check("T2.5 ★每个 tool_call 都有配对的 tool 结果(成对,服务商不会 400)",
|
||||
len(tool_msgs) == len(tcs) and
|
||||
{m["tool_call_id"] for m in tool_msgs} == {t["id"] for t in tcs},
|
||||
f"tool_msgs={len(tool_msgs)}")
|
||||
check("T2.6 已完成的工具结果原文进上下文",
|
||||
any("d1 created" in (m.get("content") or "") for m in tool_msgs))
|
||||
check("T2.7 错误说明文本也进上下文(模型知道上轮为何断)",
|
||||
any(m.get("role") == "assistant" and "⚠️" in (m.get("content") or "")
|
||||
for m in ctx2))
|
||||
check("T2.8 顺序正确:assistant tool_calls 在 tool 结果之前",
|
||||
[m.get("role") for m in ctx2].index("assistant") <
|
||||
[m.get("role") for m in ctx2].index("tool"))
|
||||
|
||||
# ======================================================================
|
||||
# T3 轮内重试只动内存、不入库
|
||||
# ======================================================================
|
||||
class _FakeState:
|
||||
def __init__(self, msgs):
|
||||
self.messages = msgs
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self):
|
||||
self.config = AgentConfig(
|
||||
model=ModelConfig(name="fake"),
|
||||
retry=RetryConfig(max_attempts=3, base_delay_ms=1, factor=2.0),
|
||||
)
|
||||
self.state = _FakeState([
|
||||
AgentMessage(role="user", content="hi"),
|
||||
AgentMessage(role="assistant", stop_reason="error",
|
||||
error_message="connection error"),
|
||||
])
|
||||
|
||||
|
||||
fa = _FakeAgent()
|
||||
runner = AgentRunner(fa, summarize_fn=None)
|
||||
n_before = len(window.db.get_message_chain(sid2))
|
||||
retried = runner._prepare_retry(fa.config.retry, reason="connection error")
|
||||
n_after = len(window.db.get_message_chain(sid2))
|
||||
check("T3.1 _prepare_retry 生效(可重试错误)", retried is True)
|
||||
check("T3.2 重试只从**内存**移除坏消息(轮次未结束,不该入库)",
|
||||
len(fa.state.messages) == 1 and fa.state.messages[-1].role == "user",
|
||||
str([m.role for m in fa.state.messages]))
|
||||
check("T3.3 ★重试前后 DB 行数不变(不会堆一堆错误行)", n_before == n_after,
|
||||
f"{n_before} -> {n_after}")
|
||||
|
||||
# 正常成功轮 → 不写 error 列
|
||||
sid3 = window.db.create_session("T3 正常轮")["id"]
|
||||
uid3 = window.db.add_message(session_id=sid3, role="user", content="你好",
|
||||
parent_id=None)["id"]
|
||||
st3 = {"msg_id": "msg_ok_1", "parent_id": uid3, "previous_leaf_id": uid3,
|
||||
"content": "你好,我在。", "reasoning": "", "timeline": [], "usage": {},
|
||||
"tl_kind": "text", "worker": None}
|
||||
window.current_session_id = sid3
|
||||
window._active_streams[sid3] = st3
|
||||
window.on_reply_finished(sid3)
|
||||
settle(150)
|
||||
ok_row = window.db.get_message_chain(sid3)[-1]
|
||||
check("T3.4 正常轮 stop_reason 为空(与错误行可区分)",
|
||||
not ok_row.get("stop_reason"), f"{ok_row.get('stop_reason')!r}")
|
||||
|
||||
# ======================================================================
|
||||
# T4 孤儿工具 → 合成结果(对照 pi insertSyntheticToolResults)
|
||||
# ======================================================================
|
||||
sid4 = window.db.create_session("T4 孤儿工具")["id"]
|
||||
uid4 = window.db.add_message(session_id=sid4, role="user", content="跑个命令",
|
||||
parent_id=None)["id"]
|
||||
mk_failed_turn(sid4, body="", tools=[tool_entry("orphan1", "bash", "{}", None, "")])
|
||||
ctx4 = window.build_api_context(sid4)
|
||||
tool4 = [m for m in ctx4 if m.get("role") == "tool"]
|
||||
check("T4.1 孤儿工具也有配对结果", len(tool4) == 1, str(len(tool4)))
|
||||
check("T4.2 孤儿结果是合成说明(不是空串,避免服务商拒绝)",
|
||||
tool4 and "未收到" in tool4[0]["content"], tool4[0]["content"] if tool4 else "")
|
||||
check("T4.3 孤儿工具的 tool_call 同时存在",
|
||||
any(tc["id"] == "orphan1" for m in ctx4 if m.get("role") == "assistant"
|
||||
for tc in (m.get("tool_calls") or [])))
|
||||
|
||||
# ======================================================================
|
||||
# T5 叶子前进(不再时光倒流)
|
||||
# ======================================================================
|
||||
check("T5.1 会话叶子 = 错误行(叶子前进,不再回退到提问)",
|
||||
window.db.get_session_leaf(sid2) == last2["id"],
|
||||
f"leaf={window.db.get_session_leaf(sid2)} last={last2['id']}")
|
||||
check("T5.2 错误行 parent_id 指向本轮提问",
|
||||
last2["parent_id"] == uid2, f"{last2['parent_id']} vs {uid2}")
|
||||
check("T5.3 链上顺序 = [user, assistant(error)]",
|
||||
[m["role"] for m in chain2] == ["user", "assistant"],
|
||||
str([m["role"] for m in chain2]))
|
||||
check("T5.4 下次提问可接着链(叶子非空 → 可继续)",
|
||||
window.db.get_session_leaf(sid2) is not None)
|
||||
|
||||
# ======================================================================
|
||||
# T6 压缩切点交互
|
||||
# ======================================================================
|
||||
sid6 = window.db.create_session("T6 压缩交互")["id"]
|
||||
u6 = window.db.add_message(session_id=sid6, role="user", content="老问题",
|
||||
parent_id=None)["id"]
|
||||
mk_failed_turn(sid6, body="中途断了", tools=[])
|
||||
err6 = window.db.get_message_chain(sid6)[-1]
|
||||
u6b = window.db.add_message(session_id=sid6, role="user", content="新问题",
|
||||
parent_id=err6["id"])["id"]
|
||||
a6 = window.db.add_message(session_id=sid6, role="assistant", content="新回答",
|
||||
parent_id=u6b)["id"]
|
||||
window.db.insert_compaction_mark(sid6, "【摘要】老问题与中断", err6["id"], u6b,
|
||||
json.dumps({"path": "test", "before": 1, "after": 1}))
|
||||
ctx6 = window.build_api_context(sid6)
|
||||
flat6 = json.dumps(ctx6, ensure_ascii=False)
|
||||
check("T6.1 切点之前的错误行 → 不进上下文(已出上下文)",
|
||||
"中途断了" not in flat6 and "⚠️" not in flat6)
|
||||
check("T6.2 摘要进上下文 + 切点之后照常",
|
||||
"【摘要】老问题与中断" in flat6 and "新回答" in flat6)
|
||||
|
||||
# ======================================================================
|
||||
# T7 旧库自动迁移(无新列 → 补齐;旧行 stop_reason=NULL)
|
||||
# ======================================================================
|
||||
_OLD = os.path.join(tempfile.gettempdir(), f"haocode_old_schema_{os.getpid()}.db")
|
||||
if os.path.exists(_OLD):
|
||||
os.remove(_OLD)
|
||||
_c = sqlite3.connect(_OLD)
|
||||
_c.executescript("""
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER);
|
||||
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
|
||||
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
|
||||
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
|
||||
""")
|
||||
_c.execute("INSERT INTO sessions VALUES ('s1','旧会话',1,1)")
|
||||
_c.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
|
||||
"VALUES ('m1','s1','user','你好',1,NULL)")
|
||||
_c.commit()
|
||||
_c.close()
|
||||
d_old = DBManager(db_path=_OLD)
|
||||
cols = [r[1] for r in d_old.get_connection().execute("PRAGMA table_info(messages)")]
|
||||
check("T7.1 旧库自动补上 stop_reason / error_message 列",
|
||||
"stop_reason" in cols and "error_message" in cols, str(cols))
|
||||
row_old = d_old.get_message_chain("s1")
|
||||
check("T7.2 旧行读出来 stop_reason 为空(行为与升级前一致)",
|
||||
row_old and not row_old[-1].get("stop_reason"),
|
||||
str(row_old[-1].get("stop_reason") if row_old else "no row"))
|
||||
check("T7.3 旧库可正常写入错误行(新列可用)",
|
||||
d_old.add_message(session_id="s1", role="assistant", content="⚠️ 中断",
|
||||
parent_id="m1", stop_reason="error",
|
||||
error_message="boom")["id"] is not None)
|
||||
check("T7.4 旧库错误行可读回",
|
||||
d_old.get_message_chain("s1")[-1].get("error_message") == "boom")
|
||||
|
||||
# ======================================================================
|
||||
# T8 copy_session 携带新列
|
||||
# ======================================================================
|
||||
copy8 = window.db.copy_session(sid2)
|
||||
chain8 = window.db.get_message_chain(copy8["id"])
|
||||
last8 = chain8[-1]
|
||||
check("T8.1 复制后错误行保留 stop_reason/error_message",
|
||||
last8.get("stop_reason") == "error" and "Connection error" in (last8.get("error_message") or ""),
|
||||
f"{last8.get('stop_reason')} / {last8.get('error_message')}")
|
||||
check("T8.2 复制后 timeline 工具条目一致",
|
||||
len(json.loads(last8["timeline"] or "[]")) == len(json.loads(last2["timeline"] or "[]")))
|
||||
check("T8.3 复制后 is_ignored 一致",
|
||||
int(last8.get("is_ignored") or 0) == int(last2.get("is_ignored") or 0))
|
||||
check("T8.4 复制后链条角色一致",
|
||||
[m["role"] for m in chain8] == [m["role"] for m in chain2])
|
||||
|
||||
# ======================================================================
|
||||
# T9 ★数据安全:追加新列绝不得触发「旧数据链表化重构」
|
||||
# _upgrade_schema 里 upgraded=True 会把每个会话的消息按时间拍平成线性链,
|
||||
# 直接毁掉树状分支(用户的 12 个分支点!)。新列迁移必须走旁路。
|
||||
# ======================================================================
|
||||
_BR = os.path.join(tempfile.gettempdir(), f"haocode_branch_{os.getpid()}.db")
|
||||
if os.path.exists(_BR):
|
||||
os.remove(_BR)
|
||||
_bc = sqlite3.connect(_BR)
|
||||
_bc.executescript("""
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER,
|
||||
has_messages BOOLEAN DEFAULT 0, sort_order INTEGER DEFAULT 0,
|
||||
is_starred BOOLEAN DEFAULT 0, current_leaf_msg_id TEXT, mode TEXT);
|
||||
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
|
||||
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
|
||||
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
|
||||
""")
|
||||
_bc.execute("INSERT INTO sessions VALUES ('s9','分支会话',1,1,1,0,0,'m5',NULL)")
|
||||
# 链 u1(1) → a1(2) → u2(3) → {a2(4), a2b(5)} ← u2 下两个孩子 = 分支点
|
||||
for _mid, _role, _ct, _par in [("m1", "user", 1, None), ("m2", "assistant", 2, "m1"),
|
||||
("m3", "user", 3, "m2"), ("m4", "assistant", 4, "m3"),
|
||||
("m5", "assistant", 5, "m3")]:
|
||||
_bc.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
|
||||
"VALUES (?,?,?,?,?,?)", (_mid, "s9", _role, _mid, _ct, _par))
|
||||
_bc.commit()
|
||||
_bc.close()
|
||||
d_b = DBManager(db_path=_BR)
|
||||
_cols_b = [r[1] for r in d_b.get_connection().execute("PRAGMA table_info(messages)")]
|
||||
check("T9.1 旧库自动追加 stop_reason / error_message 列",
|
||||
"stop_reason" in _cols_b and "error_message" in _cols_b, str(_cols_b))
|
||||
_rows_b = {r["id"]: r for r in d_b.get_connection().execute(
|
||||
"SELECT id, parent_id FROM messages WHERE session_id='s9'")}
|
||||
check("T9.2 ★分支结构未被拍平(a2b.parent 仍指向分支点 u2)",
|
||||
_rows_b["m5"]["parent_id"] == "m3",
|
||||
f"m5.parent={_rows_b['m5']['parent_id']}(拍平后会变成 m4)")
|
||||
check("T9.3 叶子节点未被改写", d_b.get_session_leaf("s9") == "m5",
|
||||
str(d_b.get_session_leaf("s9")))
|
||||
check("T9.4 原有行内容未被改动",
|
||||
all(_rows_b[k]["parent_id"] == v for k, v in
|
||||
[("m2", "m1"), ("m3", "m2"), ("m4", "m3")]),
|
||||
str({k: _rows_b[k]["parent_id"] for k in _rows_b}))
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
for _p in (_DB_TMP, _CFG_TMP, _OLD, _BR):
|
||||
try:
|
||||
if os.path.exists(_p):
|
||||
os.remove(_p)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
file_reader 单元测试(标准库 unittest,零额外依赖)
|
||||
运行:在项目根目录执行 python -m unittest discover tests -v
|
||||
或直接 python tests/test_file_attach.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# 保证直接运行(python tests/xxx.py)时也能 import 到项目根下的包
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
|
||||
from ui.views.system_tools.file_reader import BINARY_EXTS, read_text_file # noqa: E402
|
||||
|
||||
|
||||
class ReadTextFileTest(unittest.TestCase):
|
||||
"""read_text_file:编码探测 / 二进制探测 / 大小守卫"""
|
||||
|
||||
def _write(self, data: bytes, suffix: str = ".txt") -> str:
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
self.addCleanup(os.remove, path)
|
||||
return path
|
||||
|
||||
def test_utf8_file(self):
|
||||
path = self._write("你好,世界\nhello".encode("utf-8"))
|
||||
content, enc, size_kb, lines = read_text_file(path)
|
||||
self.assertIn("你好,世界", content)
|
||||
self.assertIn("hello", content)
|
||||
self.assertEqual(lines, 2)
|
||||
self.assertIn(enc, ("utf-8-sig", "utf-8"))
|
||||
self.assertGreater(size_kb, 0)
|
||||
|
||||
def test_utf8_bom_file(self):
|
||||
path = self._write(b"\xef\xbb\xbf" + "带BOM".encode("utf-8"))
|
||||
content, enc, _, _ = read_text_file(path)
|
||||
self.assertEqual(content, "带BOM") # utf-8-sig 会吃掉 BOM
|
||||
self.assertEqual(enc, "utf-8-sig")
|
||||
|
||||
def test_gbk_file_falls_back_to_gb18030(self):
|
||||
path = self._write("中文GBK内容".encode("gbk"))
|
||||
content, enc, _, _ = read_text_file(path)
|
||||
self.assertEqual(content, "中文GBK内容")
|
||||
self.assertEqual(enc, "gb18030")
|
||||
|
||||
def test_binary_file_rejected(self):
|
||||
path = self._write(b"\x00\x01\x02\x03binary-payload")
|
||||
with self.assertRaises(ValueError):
|
||||
read_text_file(path)
|
||||
|
||||
def test_oversize_file_rejected(self):
|
||||
path = self._write(b"a" * 100)
|
||||
with self.assertRaises(ValueError):
|
||||
read_text_file(path, max_bytes=10)
|
||||
|
||||
def test_latin1_fallback_never_fails(self):
|
||||
# 0xFF 既非合法 UTF-8 也非合法 GB18030 引导字节,应由 latin-1 兜底
|
||||
path = self._write(b"\xff\xfe\xfd plain text")
|
||||
content, enc, _, _ = read_text_file(path)
|
||||
self.assertEqual(enc, "latin-1")
|
||||
self.assertIn("plain text", content)
|
||||
|
||||
|
||||
class BinaryExtBlacklistTest(unittest.TestCase):
|
||||
"""黑名单分类:Word/Excel 等被拒,常见代码/文本文件放行"""
|
||||
|
||||
def test_office_and_binary_blocked(self):
|
||||
for ext in (".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".zip", ".exe", ".dll", ".mp4", ".ttf", ".db", ".psd"):
|
||||
self.assertIn(ext, BINARY_EXTS, f"{ext} 应在黑名单中")
|
||||
|
||||
def test_pdf_not_blacklisted(self):
|
||||
# PDF 改由专用分支(pdf_reader)处理,不再走二进制黑名单
|
||||
self.assertNotIn(".pdf", BINARY_EXTS, ".pdf 应由 PDF 专用分支处理,不应在黑名单中")
|
||||
|
||||
def test_text_and_code_allowed(self):
|
||||
for ext in (".py", ".js", ".ts", ".java", ".c", ".cpp", ".go", ".rs",
|
||||
".md", ".txt", ".json", ".yaml", ".html", ".css", ".sql",
|
||||
".sh", ".csv", ".log", ".ipynb", ""):
|
||||
self.assertNotIn(ext, BINARY_EXTS, f"{ext} 不应在黑名单中")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
P1-04 全局热键平台矩阵单测(适配器替身,不依赖真实 X11 显示 / 不新增依赖)
|
||||
|
||||
覆盖:
|
||||
H1 session_kind 矩阵(win32 / x11 / wayland / offscreen-unknown,mock 环境变量 + sys.platform)
|
||||
H2 hotkey_plan 路由(win32→RegisterHotKey 线程;x11→XGrabKey 线程;wayland/offscreen→None+明确日志)
|
||||
H3 X11HotkeyThread 成功路径(fake libX11:XGrabKey 参数正确、命中 keycode 发射 triggered、
|
||||
stop 后 XUngrabKey/XCloseDisplay 释放)
|
||||
H4 X11 注册失败(XGrabKey=0 键被占用 / 无显示 / 不支持的组合 → _registered=False,线程安静退出)
|
||||
H5 Windows GlobalHotkeyThread 行为保持(非 Windows run() 静默就绪退出;Windows 常量完整)
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_global_hotkey_platforms.py
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from ui.views.system_tools import desktop_session as ds # noqa: E402
|
||||
from ui.views.system_tools import x11_hotkey as xh # noqa: E402
|
||||
from ui.views.system_tools import global_hotkey as gh # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
APP = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
def with_env(patch: dict, fn):
|
||||
saved = {k: os.environ.get(k) for k in patch}
|
||||
for k, v in patch.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# H1. session_kind 矩阵
|
||||
# ======================================================================
|
||||
_orig_platform = ds.sys.platform
|
||||
|
||||
def _set_platform(p):
|
||||
ds.sys = type("FakeSys", (), {"platform": p})()
|
||||
|
||||
|
||||
try:
|
||||
_set_platform("win32")
|
||||
check("H1.1 win32", ds.session_kind() == "win32")
|
||||
_set_platform("linux")
|
||||
check("H1.2 X11(DISPLAY 有、无 WAYLAND_DISPLAY)",
|
||||
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
|
||||
"QT_QPA_PLATFORM": "xcb"}, lambda: ds.session_kind()) == "x11")
|
||||
check("H1.3 Wayland(WAYLAND_DISPLAY 有)",
|
||||
with_env({"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0", "XDG_SESSION_TYPE": None,
|
||||
"QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland")
|
||||
check("H1.4 QT_QPA_PLATFORM=wayland(无 WAYLAND_DISPLAY)",
|
||||
with_env({"WAYLAND_DISPLAY": None, "DISPLAY": None, "XDG_SESSION_TYPE": None,
|
||||
"QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland")
|
||||
check("H1.5 offscreen → unknown(即使有 DISPLAY)",
|
||||
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
|
||||
"QT_QPA_PLATFORM": "offscreen"}, lambda: ds.session_kind()) == "unknown")
|
||||
check("H1.6 无显示无 WAYLAND → unknown",
|
||||
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
|
||||
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "unknown")
|
||||
check("H1.7 XDG_SESSION_TYPE=wayland(无 WAYLAND_DISPLAY/QT 平台)",
|
||||
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "wayland",
|
||||
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland")
|
||||
check("H1.8 XDG_SESSION_TYPE=x11(无 DISPLAY)",
|
||||
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "x11",
|
||||
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "x11")
|
||||
check("H1.9 矛盾时 WAYLAND_DISPLAY 优先于 XDG_SESSION_TYPE=x11",
|
||||
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0", "XDG_SESSION_TYPE": "x11",
|
||||
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland")
|
||||
finally:
|
||||
ds.sys = sys # 还原
|
||||
|
||||
# ======================================================================
|
||||
# H2. hotkey_plan 路由
|
||||
# ======================================================================
|
||||
try:
|
||||
_set_platform("win32")
|
||||
factory, msg = ds.hotkey_plan("win32")
|
||||
check("H2.1 win32 → GlobalHotkeyThread 工厂 + 说明",
|
||||
factory is gh.GlobalHotkeyThread and "Windows" in msg, repr((factory, msg)))
|
||||
finally:
|
||||
ds.sys = sys
|
||||
|
||||
factory, msg = ds.hotkey_plan("x11")
|
||||
check("H2.2 x11 → X11HotkeyThread 工厂 + 说明",
|
||||
factory is xh.X11HotkeyThread and "X11" in msg, repr((factory, msg)))
|
||||
|
||||
factory, msg = ds.hotkey_plan("wayland")
|
||||
check("H2.3 wayland → None + 明确不可用说明(不绕过 compositor)",
|
||||
factory is None and "Wayland" in msg and "Alt+S" in msg, repr((factory, msg)))
|
||||
|
||||
factory, msg = ds.hotkey_plan("unknown")
|
||||
check("H2.4 unknown → None + 明确不可用说明",
|
||||
factory is None and "不可用" in msg, repr((factory, msg)))
|
||||
|
||||
# ======================================================================
|
||||
# H3–H4. X11HotkeyThread(fake libX11)
|
||||
# ======================================================================
|
||||
|
||||
class FakeX11:
|
||||
def __init__(self, grab_rc=1, open_rc=0xAB, keycode=39, unsupported_vk=False):
|
||||
self.grab_rc = grab_rc
|
||||
self.open_rc = open_rc
|
||||
self.keycode = keycode
|
||||
self.unsupported_vk = unsupported_vk
|
||||
self.grab_calls = []
|
||||
self.ungrab_calls = []
|
||||
self.close_calls = 0
|
||||
self.select_calls = 0
|
||||
self.opened = False
|
||||
self.pending_left = 1
|
||||
# socketpair:跨平台可被 select() 监听(Windows 上 os.pipe 的 fd 不行)
|
||||
self._a, self._b = socket.socketpair()
|
||||
self._b.send(b"\x01") # 让 a 有可读数据 → select 首次就绪
|
||||
self._event = xh.XEvent()
|
||||
self._event.type = xh.KeyPress
|
||||
self._event.keycode = self.keycode
|
||||
|
||||
# -- libX11 API(鸭子类型替身) --
|
||||
def XOpenDisplay(self, name):
|
||||
self.opened = True
|
||||
return self.open_rc
|
||||
def XCloseDisplay(self, d):
|
||||
self.close_calls += 1
|
||||
def XConnectionNumber(self, d):
|
||||
return self._a.fileno()
|
||||
def XDefaultRootWindow(self, d):
|
||||
return 123
|
||||
def XKeysymToKeycode(self, d, keysym):
|
||||
if self.unsupported_vk:
|
||||
return 0
|
||||
return self.keycode
|
||||
def XSelectInput(self, d, w, mask):
|
||||
self.select_calls += 1
|
||||
def XGrabKey(self, d, kc, mod, w, owner):
|
||||
self.grab_calls.append((kc, mod, w, owner))
|
||||
return self.grab_rc
|
||||
def XUngrabKey(self, d, kc, mod, w):
|
||||
self.ungrab_calls.append((kc, mod, w))
|
||||
def XPending(self, d):
|
||||
if self.pending_left > 0:
|
||||
self.pending_left -= 1
|
||||
return 1
|
||||
return 0
|
||||
def XNextEvent(self, d, evp):
|
||||
# 生产代码传入 ctypes.byref(ev)(CArgObject);真实 CDLL 自行解引用,
|
||||
# 替身可调用对象则通过 ._obj 拿回原始 struct
|
||||
import ctypes as _ct
|
||||
target = getattr(evp, "_obj", evp)
|
||||
_ct.memmove(_ct.byref(target), _ct.byref(self._event), _ct.sizeof(xh.XEvent))
|
||||
self._event.type = 0 # 之后无事件
|
||||
|
||||
def close(self):
|
||||
for s in (self._a, self._b):
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_hotkey_case(fake, **kw):
|
||||
"""启动 X11HotkeyThread,等待 triggered 或退出,回收。返回 (got_signal, thread)"""
|
||||
orig_open = xh._open_x11
|
||||
xh._open_x11 = lambda: (fake, fake.open_rc)
|
||||
try:
|
||||
t = xh.X11HotkeyThread(**kw)
|
||||
got = threading.Event()
|
||||
t.triggered.connect(lambda: got.set())
|
||||
t.start()
|
||||
t.wait_ready(3.0)
|
||||
deadline = time.time() + 6.0
|
||||
while not got.is_set() and time.time() < deadline:
|
||||
APP.processEvents()
|
||||
time.sleep(0.02)
|
||||
t.stop()
|
||||
t.wait(3000)
|
||||
return got.is_set(), t
|
||||
finally:
|
||||
xh._open_x11 = orig_open
|
||||
fake.close()
|
||||
|
||||
|
||||
# H3.1 成功路径:grab 参数正确 + 命中发射 + stop 释放
|
||||
fake = FakeX11()
|
||||
got, t = run_hotkey_case(fake)
|
||||
check("H3.1 命中 Alt+S → triggered 发射", got)
|
||||
check("H3.2 XGrabKey 参数(keycode=39, Mod1Mask=1, root=123, owner_events=1)",
|
||||
fake.grab_calls == [(39, xh.Mod1Mask, 123, 1)], repr(fake.grab_calls))
|
||||
check("H3.3 stop 后 XUngrabKey + XCloseDisplay 释放",
|
||||
len(fake.ungrab_calls) == 1 and fake.close_calls == 1,
|
||||
repr((fake.ungrab_calls, fake.close_calls)))
|
||||
check("H3.4 注册成功标志 _registered", t._registered is False) # cleanup 后复位为 False
|
||||
|
||||
# H4.1 键被占用(XGrabKey → 0)
|
||||
fake = FakeX11(grab_rc=0)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
got, t = run_hotkey_case(fake)
|
||||
check("H4.1 注册失败 → 无信号、_registered=False、明确日志",
|
||||
not got and t._registered is False and ("占用" in buf.getvalue() or "失败" in buf.getvalue()),
|
||||
buf.getvalue()[-200:])
|
||||
|
||||
# H4.2 无显示(XOpenDisplay → None)
|
||||
fake = FakeX11(open_rc=None)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
got, t = run_hotkey_case(fake)
|
||||
check("H4.2 无 X11 显示 → 安静退出 + 明确日志",
|
||||
not got and t._registered is False and "XOpenDisplay" in buf.getvalue(),
|
||||
buf.getvalue()[-200:])
|
||||
|
||||
# H4.3 不支持的组合(vk 不在窄映射表)
|
||||
fake = FakeX11()
|
||||
orig_open = xh._open_x11
|
||||
t_probe = xh.X11HotkeyThread(vk=0x41)
|
||||
xh._open_x11 = lambda: (fake, 0xAB)
|
||||
try:
|
||||
t_probe.start()
|
||||
t_probe.wait_ready(3.0)
|
||||
deadline = time.time() + 5.0
|
||||
while t_probe.isRunning() and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
t_probe.stop()
|
||||
t_probe.wait(2000)
|
||||
finally:
|
||||
xh._open_x11 = orig_open
|
||||
fake.close()
|
||||
check("H4.3 不支持的组合 → 不打开显示即退出",
|
||||
t_probe._registered is False and not fake.opened, repr(t_probe._registered))
|
||||
|
||||
# ======================================================================
|
||||
# H5. Windows 路径保持
|
||||
# ======================================================================
|
||||
check("H5.1 GlobalHotkeyThread 常量完整(MOD_ALT/VK_S/WM_HOTKEY)",
|
||||
gh.MOD_ALT == 0x0001 and gh.VK_S == 0x53 and gh.WM_HOTKEY == 0x0312)
|
||||
|
||||
t_win = gh.GlobalHotkeyThread()
|
||||
t_win.start()
|
||||
t_win._ready.wait(3.0)
|
||||
if gh._is_windows:
|
||||
# 本机 Windows:真实 RegisterHotKey + GetMessage 循环 = 行为保持的存活检查(随后立即释放 Alt+S)
|
||||
check("H5.2 Windows:RegisterHotKey 线程运行中(行为保持)", t_win.isRunning())
|
||||
t_win.stop()
|
||||
t_win.wait(3000)
|
||||
check("H5.3 Windows:stop() 干净退出(释放热键)", not t_win.isRunning())
|
||||
else:
|
||||
t_win.wait(3000)
|
||||
check("H5.2 非 Windows:run() 静默就绪退出(不注册、不崩)",
|
||||
not t_win.isRunning() and t_win._ready.is_set(),
|
||||
f"running={t_win.isRunning()}")
|
||||
|
||||
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
|
||||
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tests/test_main_window_event_filter.py —— P0-02 事件策略回归
|
||||
|
||||
运行: python tests/test_main_window_event_filter.py
|
||||
(仓库惯例:无 pytest 依赖,独立可跑;GUI 走 offscreen)
|
||||
|
||||
完成证据(REPAIR_BACKLOG.md P0-02):
|
||||
1. AST 静态断言:MainWindow 只有一个 eventFilter;
|
||||
2. 四种键盘状态:Enter 可发送 / 按钮禁用时 Enter 不发送 / 流式生成时 Enter 被拦截 /
|
||||
Shift+Enter 放行换行;
|
||||
3. 一次按键对应最多一次 send_message 调用(计数 wrapper 验证);
|
||||
4. 其他键与事件继续交给父类(按 a 正常插入字符、不触发发送)。
|
||||
遵守 P0-01:临时配置 + 临时数据库,在 import MainWindow 之前完成。
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_TESTS_DIR)
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
|
||||
_TMP = isolate("winfilter") # 必须在 import MainWindow 之前
|
||||
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name +
|
||||
("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 1) AST 静态断言:MainWindow 只有一个 eventFilter
|
||||
# ======================================================================
|
||||
with open(os.path.join(_ROOT, "ui", "views", "main_window.py"),
|
||||
encoding="utf-8") as f:
|
||||
_tree = ast.parse(f.read())
|
||||
_mw = [n for n in _tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "MainWindow"][0]
|
||||
_ef_lines = [n.lineno for n in _mw.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "eventFilter"]
|
||||
check("AST:MainWindow 只有一个 eventFilter", len(_ef_lines) == 1,
|
||||
f"行号: {_ef_lines}")
|
||||
|
||||
# ======================================================================
|
||||
# 2) 四种键盘状态(真实事件派发:QApplication.sendEvent → 已安装过滤器 → 控件本身)
|
||||
# ======================================================================
|
||||
from PyQt6 import QtGui, QtCore # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
import core.llm_engine as le # noqa: E402
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
win = MainWindow()
|
||||
|
||||
if not win.current_session_id:
|
||||
win.on_new_chat_clicked()
|
||||
sid = win.current_session_id
|
||||
check("前置:存在当前会话", bool(sid))
|
||||
|
||||
# 计数 wrapper:包住真实 send_message(记录调用并透传执行)
|
||||
calls = []
|
||||
_orig_send = win.send_message
|
||||
|
||||
|
||||
def _spy_send(*a, **k):
|
||||
calls.append(k)
|
||||
return _orig_send(*a, **k)
|
||||
|
||||
|
||||
win.send_message = _spy_send
|
||||
|
||||
|
||||
def _press(key, shift=False, text=""):
|
||||
"""通过 Qt 事件系统向输入框派发一次真实的 KeyPress。"""
|
||||
mods = (QtCore.Qt.KeyboardModifier.ShiftModifier if shift
|
||||
else QtCore.Qt.KeyboardModifier.NoModifier)
|
||||
ev = QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, key, mods, text)
|
||||
QApplication.sendEvent(win.text_input, ev)
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _clear_stream():
|
||||
"""确保当前会话没有残留流(等待 worker 错误自清理,兜底手工清理)。"""
|
||||
for _ in range(100):
|
||||
if sid not in win._active_streams:
|
||||
return
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
st = win._active_streams.pop(sid, None)
|
||||
if st and st.get("worker") is not None:
|
||||
try:
|
||||
st["worker"].abort()
|
||||
st["worker"].wait(2000)
|
||||
except Exception:
|
||||
pass
|
||||
win.set_send_button_state(False)
|
||||
|
||||
|
||||
# ---- A) Enter 可发送(空闲 + 按钮可用 + 有文本) ----
|
||||
# 网络层桩:立即抛 ConnectionError(等价于不可达端口),走真实错误清理路径
|
||||
_REAL_OPENAI_STREAM = le.openai_stream
|
||||
|
||||
|
||||
def _fake_openai_stream(*a, **k):
|
||||
raise ConnectionError("test stub: no network")
|
||||
yield # 保持生成器函数形态
|
||||
|
||||
|
||||
le.openai_stream = _fake_openai_stream
|
||||
win.btn_send.setEnabled(True)
|
||||
win.text_input.setPlainText("p002 enter send")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("A1 一次 Enter 至多一次 send_message 调用", len(calls) == 1,
|
||||
f"调用次数: {len(calls)}")
|
||||
check("A2 Enter 走 from_enter 规则", calls and calls[0].get("from_enter") is True,
|
||||
f"kwargs: {calls}")
|
||||
check("A3 发送已同步执行(流已注册)", sid in win._active_streams)
|
||||
check("A4 输入框被清空", win.text_input.toPlainText() == "")
|
||||
_clear_stream()
|
||||
check("A5 流清理完毕(错误路径自恢复)", sid not in win._active_streams)
|
||||
le.openai_stream = _REAL_OPENAI_STREAM
|
||||
|
||||
# ---- B) 发送按钮禁用时 Enter 不发送 ----
|
||||
_clear_stream()
|
||||
win.btn_send.setEnabled(False)
|
||||
win.text_input.setPlainText("disabled should not send")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("B1 禁用时仍至多一次调用(规则在 send_message 内单一实现)",
|
||||
len(calls) <= 1, f"调用次数: {len(calls)}")
|
||||
check("B2 禁用时未发送(无流)", sid not in win._active_streams)
|
||||
check("B3 禁用时输入内容保留", win.text_input.toPlainText() == "disabled should not send")
|
||||
win.btn_send.setEnabled(True)
|
||||
|
||||
# ---- C) 流式生成时 Enter 被拦截(不发送、不触发中断) ----
|
||||
win.set_send_button_state(True) # 与真实流式状态一致(停止图标,按钮仍可用)
|
||||
_fake_stream = {"msg_id": "fake-ai", "content": "", "worker": None, "timeline": [],
|
||||
"parent_id": None, "branch_info": {"current": 1, "total": 1}}
|
||||
win._active_streams[sid] = _fake_stream
|
||||
win.text_input.setPlainText("typing while generating")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("C1 流式时未产生真实发送(流仍是注入的假流)",
|
||||
win._active_streams.get(sid) is _fake_stream)
|
||||
check("C2 流式时未触发中断(假流字段未被改动)",
|
||||
_fake_stream["msg_id"] == "fake-ai" and _fake_stream["worker"] is None)
|
||||
check("C3 流式时输入内容保留", win.text_input.toPlainText() == "typing while generating")
|
||||
del win._active_streams[sid]
|
||||
win.set_send_button_state(False)
|
||||
|
||||
# ---- D) Shift+Enter 放行换行 ----
|
||||
win.text_input.setPlainText("abc")
|
||||
_cur = win.text_input.textCursor()
|
||||
_cur.movePosition(QtGui.QTextCursor.MoveOperation.End)
|
||||
win.text_input.setTextCursor(_cur)
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return, shift=True)
|
||||
check("D1 Shift+Enter 未调用 send_message", len(calls) == 0,
|
||||
f"调用次数: {len(calls)}")
|
||||
check("D2 Shift+Enter 插入换行(事件放行到输入框)",
|
||||
win.text_input.toPlainText() == "abc\n",
|
||||
repr(win.text_input.toPlainText()))
|
||||
check("D3 Shift+Enter 未发送(无流)", sid not in win._active_streams)
|
||||
|
||||
# ---- E) 其他键/事件继续交给父类 ----
|
||||
win.text_input.setPlainText("")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_A, text="a")
|
||||
check("E1 普通字符键正常插入", win.text_input.toPlainText() == "a",
|
||||
repr(win.text_input.toPlainText()))
|
||||
check("E2 普通字符键不触发发送", len(calls) == 0)
|
||||
|
||||
# ---- 收尾 ----
|
||||
try:
|
||||
win.close()
|
||||
except Exception:
|
||||
pass
|
||||
app.quit()
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
os._exit(0 if ok else 1) # 避免 QtWebEngine offscreen 静态析构段错误(不影响结果)
|
||||
@@ -0,0 +1,123 @@
|
||||
// -*- coding: utf-8 -*-
|
||||
// 公式管线单测:从 app.js 中切出【真实】公式段 + computeSafeLen,在沙箱里执行
|
||||
// 运行: node tests/test_math_extract.js
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', 'web', 'app.js'), 'utf8');
|
||||
|
||||
// ---- 切出公式段(公式渲染 section 起点 → 全局事件委托 section 之前)----
|
||||
const secStart = src.indexOf('// ==================== 🌟 公式渲染');
|
||||
const secEnd = src.indexOf('// ==================== 全局事件委托');
|
||||
if (secStart < 0 || secEnd < 0 || secEnd <= secStart) {
|
||||
console.error('FAIL 无法切出公式段 (start=%d end=%d)', secStart, secEnd);
|
||||
process.exit(1);
|
||||
}
|
||||
let mathCode = src.slice(secStart, secEnd);
|
||||
|
||||
// ---- 切出 computeSafeLen 函数(行首锚定,避开文档注释里的同名行)----
|
||||
var _m = src.match(/^function computeSafeLen\(s, stableLen\) \{$/m);
|
||||
if (!_m) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
|
||||
var csStart = _m.index; // 注意:indexOf 会命中文档注释里的同名行,必须用正则的 index
|
||||
const csEnd = src.indexOf('// 初始化/获取某个容器 div 的增量渲染状态');
|
||||
if (csStart < 0 || csEnd < 0) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
|
||||
mathCode += '\n' + src.slice(csStart, csEnd);
|
||||
|
||||
// ---- 沙箱 ----
|
||||
const sandbox = {
|
||||
console,
|
||||
escapeHtml: (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
katex: { renderToString: (tex, opts) => '<KATEX d=' + (opts.displayMode ? 1 : 0) + '>' + tex + '</KATEX>' },
|
||||
marked: { parse: (t) => '<MD>' + String(t), setOptions() {}, bind: null },
|
||||
};
|
||||
sandbox.marked.parse.bind = function () { return sandbox.marked.parse; };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(mathCode, sandbox, { filename: 'app.js-math-section' });
|
||||
|
||||
const extractMath = sandbox.extractMath;
|
||||
const restoreMath = sandbox.restoreMath;
|
||||
const findUnclosedMathFrom = sandbox.findUnclosedMathFrom;
|
||||
const computeSafeLen = sandbox.computeSafeLen;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function check(name, cond, extra) {
|
||||
if (cond) { pass++; console.log('PASS ' + name); }
|
||||
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
|
||||
}
|
||||
function itemsOf(text) { return extractMath(text).items; }
|
||||
|
||||
// ============ 1) 用户原始样本(部分供应商单括号格式)============
|
||||
const userSample = '上面是推导结果:\n[\nP_4=\\operatorname{BRF}(M_4,M_5)\n]\n\n[\nP_3=\\operatorname{BRF}(S_3,P_4)\n]\n\n其中:\n[\n\\operatorname{BRF}(L,H)\nL+\n\\Gamma(L,\\operatorname{Up}(H))\n\\odot\n\\Phi(\\operatorname{Up}(H))\n]\n';
|
||||
{
|
||||
const ex = extractMath(userSample);
|
||||
check('用户样本: 抽出 3 个块公式', ex.items.length === 3, 'got=' + ex.items.length);
|
||||
check('用户样本: 全是 display', ex.items.every(i => i.display));
|
||||
check('用户样本: 占位符已就位', /@@K[BI]MA/.test(ex.md));
|
||||
check('用户样本: 无残留括号([ ] 已吞)', !/[\[\]]/.test(ex.md), JSON.stringify(ex.md));
|
||||
const html = restoreMath(ex.md, ex.items);
|
||||
check('用户样本: 恢复出 3 个 KATEX 块', (html.match(/<KATEX d=1>/g) || []).length === 3);
|
||||
check('用户样本: tex 内容正确', ex.items[0].tex.includes('P_4=\\operatorname{BRF}(M_4,M_5)'));
|
||||
}
|
||||
|
||||
// ============ 2) 标准定界符 ============
|
||||
check('$$ 块', itemsOf('a $$x^2+y^2$$ b').length === 1);
|
||||
check('$$ 块 display', itemsOf('$$x$$')[0].display === true);
|
||||
check('\\[ \\] 块', itemsOf('\\[E=mc^2\\]').length === 1);
|
||||
check('行内 $x^2$', itemsOf('能量 $E=mc^2$ 著名').length === 1);
|
||||
check('行内非 display', itemsOf('$x^2$')[0].display === false);
|
||||
check('\\( \\) 行内', itemsOf('\\(a+b\\)').length === 1);
|
||||
check('多公式同段', itemsOf('$a^2$ 和 $$b^2$$ 及 $c$').length === 3);
|
||||
|
||||
// ============ 3) 防误伤 ============
|
||||
check('编号 [1] 不抽', itemsOf('见[1]参考').length === 0);
|
||||
check('链接 [text](url) 不抽', itemsOf('[text](http://a.b) 和 [\nfoo\n](x)').length === 0);
|
||||
check('列表 [a, b] 不抽', itemsOf('[a, b] 数组').length === 0);
|
||||
check('货币 $1,000 and $2,000 不抽', itemsOf('价格 $1,000 and $2,000').length === 0);
|
||||
check('单词 $a$ 按变量公式(有意取舍)', itemsOf('花 $a$ 朵').length === 1);
|
||||
check('多词 $a b$ 不抽', itemsOf('这 $a b$ 个').length === 0);
|
||||
check('未闭合 $x 不抽', itemsOf('价格 $x 未闭合').length === 0);
|
||||
check('代码块内 $y$ [0] 不抽', itemsOf('```\nx = $y$\narr[0]\n```').length === 0);
|
||||
check('行内代码 `$z$` 不抽', itemsOf('使用 `$z$` 变量').length === 0);
|
||||
check('未闭合围栏内不抽', itemsOf('```\n$w$ 流式中').length === 0);
|
||||
|
||||
// ============ 4) 混合定位 ============
|
||||
{
|
||||
const ex = extractMath('前文\n\n$$\na=b\n$$\n\n后文 $c$ 尾');
|
||||
check('混合: 2 公式', ex.items.length === 2);
|
||||
check('混合: 占位符顺序', ex.md.indexOf('@@KBMA0@@') < ex.md.indexOf('@@KMIA1@@'));
|
||||
}
|
||||
|
||||
// ============ 5) findUnclosedMathFrom ============
|
||||
check('无公式 → 0', findUnclosedMathFrom('hello\nworld\n') === 0);
|
||||
check('未闭合 $$ → 位置', findUnclosedMathFrom('a\n$$x+y\n') === 2);
|
||||
check('闭合 $$ → 0', findUnclosedMathFrom('a\n$$x+y\n$$\n') === 0);
|
||||
check('未闭合 [ 行 → 位置', findUnclosedMathFrom('a\n[\nx=1\n') === 2);
|
||||
check('闭合 [..] → 0', findUnclosedMathFrom('a\n[\nx=1\n]\n') === 0);
|
||||
check('链接 [t](u) 不算开 → 0', findUnclosedMathFrom('a\n[t](u)\n') === 0);
|
||||
check('行内未闭合 $ → 位置', findUnclosedMathFrom('cost $5 plus') === 5);
|
||||
check('代码围栏内忽略', findUnclosedMathFrom('```\n$$x\n```\n') === 0);
|
||||
|
||||
// ============ 6) computeSafeLen 公式感知回退 ============
|
||||
{
|
||||
// “para1\n\n” = 7 字符(5 + 两个换行)。未闭合 [ 块在边界之后 → 稳定区止于 7
|
||||
const s1 = 'para1\n\n[\nx=1\n';
|
||||
check('流式: 未闭合 [ 前缀止于空行', computeSafeLen(s1, 0) === 7, 'got=' + computeSafeLen(s1, 0));
|
||||
check('流式: 增量无进展返回 0', computeSafeLen(s1, 7) === 0);
|
||||
// 闭合后 → 整个块可固化
|
||||
const s2 = 'para1\n\n[\nx=1\n]\n\n';
|
||||
check('流式: 闭合后可固化全部', computeSafeLen(s2, 0) === s2.length, 'got=' + computeSafeLen(s2, 0));
|
||||
// 关键回退:块边界(空行)在 未闭合 $$ 之后 → 必须回退到 $$ 起点
|
||||
const s3 = 'a b\n\n$$x +\ny z\n\n';
|
||||
check('流式: 未闭合 $$ 触发回退到起点', computeSafeLen(s3, 0) === 5, 'got=' + computeSafeLen(s3, 0));
|
||||
// 闭合 $$ 后不再回退
|
||||
const s3b = 'a b\n\n$$x +\ny z$$\n\n';
|
||||
check('流式: 闭合 $$ 可固化全部', computeSafeLen(s3b, 0) === s3b.length, 'got=' + computeSafeLen(s3b, 0));
|
||||
// 链接不触发回退(candidate 到链接后的空行 15;若误判未闭合会回退到 7)
|
||||
const s4 = 'para1\n\n[t](u)\n\ndone\n';
|
||||
check('流式: 链接不触发回退', computeSafeLen(s4, 0) === 15, 'got=' + computeSafeLen(s4, 0));
|
||||
}
|
||||
|
||||
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tools/builtin_tools/pdf_reader.py 的单元测试。
|
||||
|
||||
用 PyMuPDF 现场生成含文本与内嵌图片的测试 PDF,验证文本结构化提取、
|
||||
图片提取落地,以及异常分支(文件不存在 / 超过大小上限)。
|
||||
|
||||
需在装有 PyMuPDF 的 haocode 环境运行::
|
||||
|
||||
python -m unittest discover tests
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# 让 tests 目录能 import 到项目根目录下的 tools 包
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
try:
|
||||
import pymupdf
|
||||
except ImportError: # 兼容旧版导入名
|
||||
import fitz as pymupdf # type: ignore
|
||||
|
||||
from tools.builtin_tools.pdf_reader import (
|
||||
extract_pdf_text,
|
||||
extract_pdf_images,
|
||||
)
|
||||
|
||||
|
||||
def _make_png_bytes() -> bytes:
|
||||
"""生成一张 8x8 红色小图的 PNG 字节流。"""
|
||||
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 8, 8), 0)
|
||||
pix.set_rect(pix.irect, (255, 0, 0))
|
||||
data = pix.tobytes("png")
|
||||
pix = None
|
||||
return data
|
||||
|
||||
|
||||
def _build_sample_pdf(path: str) -> None:
|
||||
"""造一个 2 页 PDF:第 1 页含文本 + 图片,第 2 页仅文本。"""
|
||||
doc = pymupdf.open()
|
||||
p1 = doc.new_page()
|
||||
p1.insert_text((72, 72), "Hello PDF page one")
|
||||
p1.insert_image(pymupdf.Rect(72, 100, 172, 200), stream=_make_png_bytes())
|
||||
p2 = doc.new_page()
|
||||
p2.insert_text((72, 72), "Second page text here")
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
|
||||
|
||||
class PdfReaderTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.dir = self.tmp.name
|
||||
self.pdf = os.path.join(self.dir, "sample.pdf")
|
||||
_build_sample_pdf(self.pdf)
|
||||
|
||||
def test_extract_text_structure(self):
|
||||
text, pages = extract_pdf_text(self.pdf)
|
||||
self.assertEqual(pages, 2)
|
||||
self.assertIn("[第 1 页]", text)
|
||||
self.assertIn("[第 2 页]", text)
|
||||
self.assertIn("Hello PDF page one", text)
|
||||
self.assertIn("Second page text here", text)
|
||||
|
||||
def test_extract_images(self):
|
||||
out_dir = os.path.join(self.dir, "imgs")
|
||||
imgs = extract_pdf_images(self.pdf, out_dir)
|
||||
self.assertGreaterEqual(len(imgs), 1)
|
||||
im = imgs[0]
|
||||
for key in ("page", "index", "abs_path", "mime", "size_kb", "width", "height"):
|
||||
self.assertIn(key, im)
|
||||
self.assertEqual(im["page"], 1)
|
||||
self.assertEqual(im["mime"], "image/png")
|
||||
self.assertTrue(os.path.isfile(im["abs_path"]))
|
||||
self.assertGreater(os.path.getsize(im["abs_path"]), 0)
|
||||
|
||||
def test_missing_file(self):
|
||||
with self.assertRaises(ValueError):
|
||||
extract_pdf_text(os.path.join(self.dir, "nope.pdf"))
|
||||
|
||||
def test_oversize(self):
|
||||
# 用一个极小的上限触发超大分支
|
||||
with self.assertRaises(ValueError):
|
||||
extract_pdf_text(self.pdf, max_bytes=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,347 @@
|
||||
// -*- coding: utf-8 -*-
|
||||
// P1-01 渲染窗口状态机单测(DOM 无关,直接加载 ui/web/render_window.js,无 JSDOM/npm)
|
||||
// 运行: node tests/test_render_window.js
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const RW = require(path.join(__dirname, '..', 'ui', 'web', 'render_window.js'));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function check(name, cond, extra) {
|
||||
if (cond) { pass++; console.log('PASS ' + name); }
|
||||
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
|
||||
}
|
||||
|
||||
// ---------- 夹具 ----------
|
||||
function makeChain(n) {
|
||||
var out = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
out.push({ id: 'm' + i, role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'msg ' + i, created_at: 1000 + i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// 模拟 Python 页载荷:按 (方向 + 边界消息 ID) 返回 [{id, chainIndex}] + 链长
|
||||
function pageFor(chain, st, req) {
|
||||
var bi = chain.findIndex(function (d) { return d.id === req.boundaryId; });
|
||||
if (bi < 0) return null;
|
||||
var start, items;
|
||||
if (req.direction === 'older') {
|
||||
start = Math.max(0, bi - st.size);
|
||||
items = chain.slice(start, bi);
|
||||
} else {
|
||||
start = bi + 1;
|
||||
items = chain.slice(bi + 1, bi + 1 + st.size);
|
||||
}
|
||||
return {
|
||||
sessionId: st.sessionId, generation: st.generation,
|
||||
boundaryId: req.boundaryId, direction: req.direction,
|
||||
chainLen: chain.length,
|
||||
items: items.map(function (d, k) { return { id: d.id, chainIndex: start + k }; })
|
||||
};
|
||||
}
|
||||
function initChain(st, chain) {
|
||||
var n = Math.min(st.size, chain.length);
|
||||
var items = chain.slice(chain.length - n);
|
||||
return RW.initFullChain(st, {
|
||||
sessionId: 'sess-1', generation: 1,
|
||||
chainLen: chain.length,
|
||||
items: items.map(function (d, k) { return { id: d.id, chainIndex: chain.length - n + k }; })
|
||||
});
|
||||
}
|
||||
function idsOf(st) { return st.order.slice(); }
|
||||
function chainIds(chain) { return chain.map(function (d) { return d.id; }); }
|
||||
|
||||
// ============ 1) 配置规范化 ============
|
||||
{
|
||||
var c;
|
||||
c = RW.normalizeConfig({});
|
||||
check('size 缺失 → 40 / mode 缺失 → auto', c.size === 40 && c.mode === 'auto');
|
||||
c = RW.normalizeConfig(undefined);
|
||||
check('配置 undefined → {auto,40}', c.size === 40 && c.mode === 'auto');
|
||||
c = RW.normalizeConfig({ render_window_size: true });
|
||||
check('size=true(布尔)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: false });
|
||||
check('size=false(布尔)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: '40' });
|
||||
check('size="40"(字符串)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: 0 });
|
||||
check('size=0 → 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: -5 });
|
||||
check('size=-5 → 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: 9 });
|
||||
check('size=9(下越界)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: 201 });
|
||||
check('size=201(上越界)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: 40.5 });
|
||||
check('size=40.5(小数)→ 40', c.size === 40);
|
||||
c = RW.normalizeConfig({ render_window_size: 10 });
|
||||
check('size=10 保留', c.size === 10);
|
||||
c = RW.normalizeConfig({ render_window_size: 200 });
|
||||
check('size=200 保留', c.size === 200);
|
||||
c = RW.normalizeConfig({ render_window_mode: 'manual' });
|
||||
check('mode=manual 保留', c.mode === 'manual');
|
||||
c = RW.normalizeConfig({ render_window_mode: 'AUTO' });
|
||||
check('mode=AUTO(大小写不符)→ auto', c.mode === 'auto');
|
||||
c = RW.normalizeConfig({ render_window_mode: 1 });
|
||||
check('mode=1 → auto', c.mode === 'auto');
|
||||
c = RW.normalizeConfig({ render_window_mode: null, render_window_size: 10 });
|
||||
check('mode=null + size=10 → {auto,10}', c.mode === 'auto' && c.size === 10);
|
||||
}
|
||||
|
||||
// ============ 2) auto/manual × 10/40/200 参数化初始窗口 ============
|
||||
[10, 40, 200].forEach(function (size) {
|
||||
['auto', 'manual'].forEach(function (mode) {
|
||||
var st = RW.create({ render_window_mode: mode, render_window_size: size });
|
||||
var chain = makeChain(400);
|
||||
initChain(st, chain);
|
||||
var tail = chainIds(chain).slice(400 - size);
|
||||
check('初始窗口 [' + mode + '/' + size + '] 长度=size', st.order.length === size);
|
||||
check('初始窗口 [' + mode + '/' + size + '] = 最新 size 条',
|
||||
JSON.stringify(idsOf(st)) === JSON.stringify(tail));
|
||||
check('初始窗口 [' + mode + '/' + size + '] hasMoreOlder', st.hasMoreOlder === true);
|
||||
check('初始窗口 [' + mode + '/' + size + '] hiddenOlder=' + (400 - size),
|
||||
st.hiddenOlder === 400 - size);
|
||||
check('初始窗口 [' + mode + '/' + size + '] hiddenNewer=0', st.hiddenNewer === 0);
|
||||
RW.clear(st);
|
||||
check('clear 后模式/大小保持 [' + mode + '/' + size + ']',
|
||||
st.mode === mode && st.size === size);
|
||||
check('clear 后窗口/缓存/计数清空', st.order.length === 0 &&
|
||||
Object.keys(st.indexById).length === 0 &&
|
||||
st.hiddenOlder === 0 && st.hiddenNewer === 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 3) 双向连续换页(size=10,400 条链)============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 10 });
|
||||
var chain = makeChain(400);
|
||||
initChain(st, chain);
|
||||
|
||||
// ---- 连续向上,直到头部边界 ----
|
||||
var pages = 0;
|
||||
while (RW.canRequest(st, 'older')) {
|
||||
var req = RW.beginRequest(st, 'older');
|
||||
check('向上换页边界=窗口最旧条 p' + pages,
|
||||
req && req.boundaryId === st.order[0]);
|
||||
var res = RW.applyPage(st, req, pageFor(chain, st, req));
|
||||
check('向上 p' + pages + ' 非 stale', res.stale !== true);
|
||||
check('向上 p' + pages + ' 窗口≤size', st.order.length <= 10, 'len=' + st.order.length);
|
||||
check('向上 p' + pages + ' 加一端裁另一端', res.removedIds.length === 10,
|
||||
'removed=' + res.removedIds.length);
|
||||
check('向上 p' + pages + ' 加入数=10', res.addedIds.length === 10);
|
||||
// 顺序性:窗口内 id 在链中连续且递增
|
||||
var idx = chainIds(chain).indexOf(st.order[0]);
|
||||
var okSeq = true;
|
||||
for (var i = 1; i < st.order.length; i++) {
|
||||
if (chainIds(chain).indexOf(st.order[i]) !== idx + i) { okSeq = false; break; }
|
||||
}
|
||||
check('向上 p' + pages + ' 窗口连续无重复', okSeq);
|
||||
pages++;
|
||||
if (pages > 50) { check('向上换页终止', false, '死循环'); break; }
|
||||
}
|
||||
check('向上换页到达头部边界', !st.hasMoreOlder && !RW.canRequest(st, 'older'));
|
||||
check('头部窗口=链首 10 条', JSON.stringify(idsOf(st)) === JSON.stringify(
|
||||
chainIds(chain).slice(0, 10)));
|
||||
check('头部 hiddenOlder=0', st.hiddenOlder === 0);
|
||||
check('头部 hiddenNewer=390', st.hiddenNewer === 390);
|
||||
check('向上总换页数=39(窗口 10,400 条)', pages === 39, 'pages=' + pages);
|
||||
|
||||
// ---- 连续向下,直到尾部边界 ----
|
||||
var down = 0;
|
||||
while (RW.canRequest(st, 'newer')) {
|
||||
var req2 = RW.beginRequest(st, 'newer');
|
||||
var res2 = RW.applyPage(st, req2, pageFor(chain, st, req2));
|
||||
check('向下 p' + down + ' 非 stale', res2.stale !== true);
|
||||
check('向下 p' + down + ' 窗口≤size', st.order.length <= 10);
|
||||
down++;
|
||||
if (down > 50) { check('向下换页终止', false, '死循环'); break; }
|
||||
}
|
||||
check('向下换页到达尾部边界', !st.hasMoreNewer && !RW.canRequest(st, 'newer'));
|
||||
check('尾部窗口=链尾 10 条', JSON.stringify(idsOf(st)) === JSON.stringify(
|
||||
chainIds(chain).slice(390)));
|
||||
check('向下总换页数=39', down === 39, 'down=' + down);
|
||||
check('往返后窗口无重复', new Set(idsOf(st)).size === idsOf(st).length);
|
||||
}
|
||||
|
||||
// ============ 4) 短链边界(链 ≤ size)============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 40 });
|
||||
var chain = makeChain(7);
|
||||
initChain(st, chain);
|
||||
check('短链: 全链入窗', st.order.length === 7);
|
||||
check('短链: 无向上/向下', !st.hasMoreOlder && !st.hasMoreNewer);
|
||||
check('短链: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer'));
|
||||
}
|
||||
|
||||
// ============ 5) 过期响应 ============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 10 });
|
||||
var chain = makeChain(60);
|
||||
initChain(st, chain);
|
||||
|
||||
// 5a) 切会话(sessionId 变化)
|
||||
var req = RW.beginRequest(st, 'older');
|
||||
var payload = pageFor(chain, st, req);
|
||||
st.sessionId = 'sess-2'; // 模拟切会话(未走 clear 的代次变化也需被拒)
|
||||
var r = RW.applyPage(st, req, payload);
|
||||
check('切会话后旧响应 → stale', r.stale === true);
|
||||
check('stale 后 pending 清空', st.pending === null);
|
||||
|
||||
// 5b) clear 递增代次 → 旧载荷(旧代次)失效
|
||||
initChain(st, chain);
|
||||
req = RW.beginRequest(st, 'older');
|
||||
payload = pageFor(chain, st, req);
|
||||
RW.clear(st);
|
||||
r = RW.applyPage(st, req, payload);
|
||||
check('clear 后旧代次响应 → stale', r.stale === true);
|
||||
|
||||
// 5c) 边界不匹配(竞态:窗口已变化才到达的旧页)
|
||||
initChain(st, chain);
|
||||
req = RW.beginRequest(st, 'older');
|
||||
var reqB = RW.beginRequest(st, 'older');
|
||||
check('pending 时不可重复请求', reqB === null);
|
||||
var bogus = { sessionId: st.sessionId, generation: st.generation,
|
||||
boundaryId: 'm0', direction: 'older', chainLen: chain.length, items: [] };
|
||||
r = RW.applyPage(st, { direction: 'older', boundaryId: 'm0' }, bogus);
|
||||
check('边界不匹配 → stale', r.stale === true);
|
||||
|
||||
// 5d) 同一页重复投递(幂等)
|
||||
req = RW.beginRequest(st, 'older');
|
||||
var p1 = pageFor(chain, st, req);
|
||||
r = RW.applyPage(st, req, p1);
|
||||
check('正常页投递成功', r.stale !== true);
|
||||
r = RW.applyPage(st, req, p1);
|
||||
check('同页重复投递 → stale(pending 已消费)', r.stale === true);
|
||||
}
|
||||
|
||||
// ============ 6) 活动流:计入上限、永不裁剪 ============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 40 });
|
||||
var chain = makeChain(600);
|
||||
initChain(st, chain);
|
||||
var streamId = st.order[st.order.length - 1]; // 最新条即活动流
|
||||
RW.noteStream(st, streamId);
|
||||
|
||||
// 默认 size=40 + 1 条流 → 最多另外 39 条
|
||||
var req = RW.beginRequest(st, 'older');
|
||||
var res = RW.applyPage(st, req, pageFor(chain, st, req));
|
||||
check('流在窗时向上换页非 stale', res.stale !== true);
|
||||
check('流在窗时窗口恒≤size', st.order.length <= 40, 'len=' + st.order.length);
|
||||
check('流在窗时窗口含流', st.order.indexOf(streamId) >= 0);
|
||||
var others = st.order.filter(function (id) { return id !== streamId; }).length;
|
||||
check('流在窗时另外 ≤39 条', others <= 39, 'others=' + others);
|
||||
check('流未被裁剪(removed 不含流)', res.removedIds.indexOf(streamId) < 0);
|
||||
|
||||
// 继续向上:流始终存活
|
||||
for (var i = 0; i < 4 && RW.canRequest(st, 'older'); i++) {
|
||||
var rq = RW.beginRequest(st, 'older');
|
||||
var rs = RW.applyPage(st, rq, pageFor(chain, st, rq));
|
||||
check('流持续存活 p' + i, st.order.indexOf(streamId) >= 0 &&
|
||||
rs.removedIds.indexOf(streamId) < 0);
|
||||
}
|
||||
|
||||
// 流结束后不再受保护
|
||||
RW.streamFinished(st, streamId);
|
||||
var rq2 = RW.beginRequest(st, 'older');
|
||||
var rs2 = RW.applyPage(st, rq2, pageFor(chain, st, rq2));
|
||||
check('流结束后可被正常裁剪', rs2.removedIds.indexOf(streamId) >= 0);
|
||||
|
||||
// 流在窗口最旧端 + 向下换页:trimHead 保护(手工构造中部窗口)
|
||||
var st2 = RW.create({ render_window_size: 10 });
|
||||
initChain(st2, chain);
|
||||
st2.order = ['m140', 'm141', 'm142', 'm143', 'm144', 'm145', 'm146', 'm147', 'm148', 'm149'];
|
||||
st2.indexById = {};
|
||||
for (var k = 0; k < st2.order.length; k++) st2.indexById[st2.order[k]] = 140 + k;
|
||||
st2.hasMoreNewer = true; // 模拟窗口不在链尾
|
||||
st2.hiddenNewer = 100;
|
||||
RW.noteStream(st2, st2.order[0]);
|
||||
var rq3 = RW.beginRequest(st2, 'newer');
|
||||
var rs3 = RW.applyPage(st2, rq3, pageFor(chain, st2, rq3));
|
||||
check('流在最旧端时向下换页仍受保护', st2.order.indexOf(st2.order[0]) >= 0 &&
|
||||
rs3.removedIds.indexOf('m140') < 0);
|
||||
check('流在最旧端时窗口恒≤size', st2.order.length <= 10);
|
||||
check('流在最旧端时裁掉 10 条并补新页 10 条', rs3.removedIds.length === 10 &&
|
||||
rs3.addedIds.length === 10);
|
||||
}
|
||||
|
||||
// ============ 7) clear 语义(保留配置模式/大小)============
|
||||
{
|
||||
var st = RW.create({ render_window_mode: 'manual', render_window_size: 10 });
|
||||
var chain = makeChain(60);
|
||||
initChain(st, chain);
|
||||
var req = RW.beginRequest(st, 'older');
|
||||
var g0 = st.generation;
|
||||
RW.clear(st);
|
||||
check('clear: 代次+1', st.generation === g0 + 1);
|
||||
check('clear: pending 清空', st.pending === null);
|
||||
check('clear: 模式/大小保留', st.mode === 'manual' && st.size === 10);
|
||||
check('clear: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer'));
|
||||
}
|
||||
|
||||
// ============ 8) 锚点几何(纯数学)============
|
||||
{
|
||||
var entries = [
|
||||
{ id: 'a', top: 0, height: 100 },
|
||||
{ id: 'b', top: 100, height: 100 },
|
||||
{ id: 'c', top: 200, height: 100 }
|
||||
];
|
||||
var an = RW.computeAnchor(entries, 50, 150);
|
||||
check('锚点: 首个可见=a, offset=50', an && an.msgId === 'a' && an.offset === 50,
|
||||
JSON.stringify(an));
|
||||
an = RW.computeAnchor(entries, 101, 200);
|
||||
check('锚点: 首个可见=b, offset=1', an && an.msgId === 'b' && an.offset === 1,
|
||||
JSON.stringify(an));
|
||||
an = RW.computeAnchor(entries, 100, 200);
|
||||
check('锚点: 视口顶恰在 b 起点, offset=0', an && an.msgId === 'b' && an.offset === 0);
|
||||
an = RW.computeAnchor(entries, 500, 600);
|
||||
check('锚点: 无可见 → null', an === null);
|
||||
check('滚动增量: 350-100=250', RW.scrollDeltaFromRects(100, 350) === 250);
|
||||
check('滚动增量: 无位移=0', RW.scrollDeltaFromRects(100, 100) === 0);
|
||||
}
|
||||
|
||||
// ============ 9) 一次换页的原子性(加一端 + 裁另一端 + 计数同步)============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 10 });
|
||||
var chain = makeChain(100);
|
||||
initChain(st, chain);
|
||||
var before = st.hiddenOlder; // 90
|
||||
var req = RW.beginRequest(st, 'older');
|
||||
var res = RW.applyPage(st, req, pageFor(chain, st, req));
|
||||
check('加 10 条', res.addedIds.length === 10);
|
||||
check('裁 10 条', res.removedIds.length === 10);
|
||||
check('hiddenOlder 递减=加入数', st.hiddenOlder === before - 10,
|
||||
'before=' + before + ' now=' + st.hiddenOlder);
|
||||
check('返回计数与状态一致', res.hiddenOlder === st.hiddenOlder &&
|
||||
res.hiddenNewer === st.hiddenNewer &&
|
||||
res.hasMoreOlder === st.hasMoreOlder && res.hasMoreNewer === st.hasMoreNewer);
|
||||
}
|
||||
|
||||
// ============ 10) 活动消息追加(noteLive):计入上限、裁旧端 ============
|
||||
{
|
||||
var st = RW.create({ render_window_size: 10 });
|
||||
var chain = makeChain(60);
|
||||
initChain(st, chain); // 窗口 m50..m59
|
||||
var r1 = RW.noteLive(st, 'live-user', -1); // 用户消息(未持久化)
|
||||
check('noteLive: 追加到较新一端', r1.added === true &&
|
||||
st.order[st.order.length - 1] === 'live-user');
|
||||
check('noteLive: 窗口≤size(裁掉最旧 1 条)', st.order.length <= 10 &&
|
||||
st.order.indexOf('m50') < 0);
|
||||
check('noteLive: 裁掉的 id 同步清除下标', st.indexById['m50'] === undefined);
|
||||
|
||||
RW.noteStream(st, 'live-ai');
|
||||
var r2 = RW.noteLive(st, 'live-ai', -1); // 助手占位(活动流)
|
||||
check('noteLive: 流占位入窗', r2.added === true && st.activeStreamId === 'live-ai');
|
||||
check('noteLive: 流计入上限后仍≤size', st.order.length <= 10);
|
||||
|
||||
var r3 = RW.noteLive(st, 'live-ai', 61); // 持久化后补下标(幂等)
|
||||
check('noteLive: 重复 id 幂等补下标', r3.added === false &&
|
||||
st.indexById['live-ai'] === 61);
|
||||
|
||||
// hidden 计数:trimHead 两次后窗口最旧=m52;链尾未知条计入 hiddenNewer(截断≥0)
|
||||
check('noteLive 后 hiddenOlder=52', st.hiddenOlder === 52, 'got=' + st.hiddenOlder);
|
||||
check('noteLive 后 hiddenNewer=0(链尾无已持久化更新消息)', st.hiddenNewer === 0 &&
|
||||
st.hasMoreNewer === false, 'got=' + st.hiddenNewer);
|
||||
}
|
||||
|
||||
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,242 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
P1-03 渲染器矩阵单测(平台模拟 + 真实进程级,无 UI 依赖,不启动真实 LLM)
|
||||
|
||||
覆盖:
|
||||
R1 resolve_backend 平台矩阵:Windows/Linux × auto/webview2/qtwebengine/非法值
|
||||
(非法值 → 可见警告 + 平台默认;Linux 请求 webview2 → 警告 + qtwebengine)
|
||||
R3 Linux 模拟导入链(子进程 sys.platform='linux' 导入 ui.views.main_window):
|
||||
_wv2mod 为 None,且 core.webview2 / clr / clr_loader 未进入 sys.modules
|
||||
R4 两个 QtWebEngine 实例并行(2 个 offscreen 子进程同时跑):
|
||||
各自独立 profile 载入本地页面并关闭,无 profile 锁争用、无互相清理
|
||||
R5 Chromium sandbox 标志契约:普通桌面剥离 --no-sandbox + 告警;
|
||||
root/容器 + 显式 --no-sandbox → 保留 + 高可见警告;root/容器未设 → 提示
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PY = sys.executable
|
||||
IS_WIN = os.name == "nt"
|
||||
|
||||
from core import renderer_backend as rb # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# R1. resolve_backend 平台矩阵(mock 平台,纯逻辑)
|
||||
# ======================================================================
|
||||
_orig_is_win = rb.is_windows
|
||||
|
||||
|
||||
def _mock(is_win: bool):
|
||||
rb.is_windows = lambda: is_win
|
||||
|
||||
|
||||
try:
|
||||
_mock(True)
|
||||
b, w = rb.resolve_backend("auto")
|
||||
check("R1.1 Win auto → webview2 无警告", b == "webview2" and w is None, f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("webview2")
|
||||
check("R1.2 Win webview2 → webview2", b == "webview2" and w is None, f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("qtwebengine")
|
||||
check("R1.3 Win qtwebengine → qtwebengine(强制回落)", b == "qtwebengine" and w is None,
|
||||
f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("gecko")
|
||||
check("R1.4 Win 非法值 → webview2 + 可见警告",
|
||||
b == "webview2" and w and "非法" in w, f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend(123)
|
||||
check("R1.5 Win 非字符串 → webview2 + 警告", b == "webview2" and w is not None,
|
||||
f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend(" QTWebEngine ")
|
||||
check("R1.6 大小写/空白容差 → qtwebengine", b == "qtwebengine" and w is None,
|
||||
f"{b} {w!r}")
|
||||
|
||||
_mock(False)
|
||||
b, w = rb.resolve_backend("auto")
|
||||
check("R1.7 Linux auto → qtwebengine 无警告", b == "qtwebengine" and w is None,
|
||||
f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("webview2")
|
||||
check("R1.8 Linux webview2 → qtwebengine + 警告",
|
||||
b == "qtwebengine" and w and "webview2" in w, f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("qtwebengine")
|
||||
check("R1.9 Linux qtwebengine → qtwebengine", b == "qtwebengine" and w is None,
|
||||
f"{b} {w!r}")
|
||||
b, w = rb.resolve_backend("gecko")
|
||||
check("R1.10 Linux 非法值 → qtwebengine + 警告",
|
||||
b == "qtwebengine" and w and "非法" in w, f"{b} {w!r}")
|
||||
finally:
|
||||
rb.is_windows = _orig_is_win
|
||||
|
||||
# webview2_module 门控(同进程 mock)
|
||||
try:
|
||||
_mock(False)
|
||||
check("R1.11 非 Windows:webview2_module() 返回 None 且不导入",
|
||||
rb.webview2_module() is None)
|
||||
finally:
|
||||
rb.is_windows = _orig_is_win
|
||||
|
||||
# ======================================================================
|
||||
# R5. Chromium sandbox 标志契约(mock root/容器探测)
|
||||
# ======================================================================
|
||||
_orig_rootc = rb.is_root_or_container
|
||||
|
||||
|
||||
def _run_sanitize(flags, rootc):
|
||||
rb.is_root_or_container = lambda: rootc
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
out = rb.sanitize_chromium_flags(flags, verbose=True)
|
||||
return out, buf.getvalue()
|
||||
|
||||
|
||||
try:
|
||||
out, log = _run_sanitize("--disable-gpu", False)
|
||||
check("R5.1 普通桌面:无 --no-sandbox 原样返回、无告警",
|
||||
out == "--disable-gpu" and "no-sandbox" not in log, f"{out!r} {log!r}")
|
||||
|
||||
out, log = _run_sanitize("--disable-gpu --no-sandbox", False)
|
||||
check("R5.2 普通桌面:剥离 --no-sandbox + 告警",
|
||||
out == "--disable-gpu" and "--no-sandbox" in log, f"{out!r} {log!r}")
|
||||
|
||||
out, log = _run_sanitize("--disable-gpu --no-sandbox", True)
|
||||
check("R5.3 root/容器 + 显式:保留 --no-sandbox + 高可见警告",
|
||||
out == "--disable-gpu --no-sandbox" and "高可见警告" in log, f"{out!r} {log!r}")
|
||||
|
||||
out, log = _run_sanitize("--disable-gpu", True)
|
||||
check("R5.4 root/容器未设:原样返回 + 提示显式设置",
|
||||
out == "--disable-gpu" and "--no-sandbox" in log and "高可见警告" not in log,
|
||||
f"{out!r} {log!r}")
|
||||
finally:
|
||||
rb.is_root_or_container = _orig_rootc
|
||||
|
||||
# ======================================================================
|
||||
# R3. Linux 模拟导入链(子进程:sys.platform='linux' 后 import main_window)
|
||||
# ======================================================================
|
||||
r3_code = (
|
||||
"import os, sys;"
|
||||
f"sys.path.insert(0, {ROOT!r});"
|
||||
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen');"
|
||||
"os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-gpu';"
|
||||
"sys.platform = 'linux';" # 模拟非 Windows(导入门控按 sys.platform 判断)
|
||||
"import ui.views.main_window as m;"
|
||||
"bad = sorted(k for k in ('core.webview2', 'clr', 'clr_loader') if k in sys.modules);"
|
||||
"ok = m._wv2mod is None and not bad;"
|
||||
"print('R3_OK' if ok else f'R3_FAIL {bad!r}')"
|
||||
)
|
||||
r3 = subprocess.run([PY, "-c", r3_code], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=180)
|
||||
check("R3.1 Linux 模拟:main_window 导入成功且 _wv2mod 为 None、未导入 webview2/clr",
|
||||
"R3_OK" in (r3.stdout or ""),
|
||||
f"rc={r3.returncode} out={(r3.stdout or '')[-200:]!r} err={(r3.stderr or '')[-300:]!r}")
|
||||
|
||||
# ======================================================================
|
||||
# R4. 两个 QtWebEngine 实例并行(独立 profile,offscreen)
|
||||
# ======================================================================
|
||||
_r4_base = tempfile.mkdtemp(prefix="haocode_p103_profiles_")
|
||||
_worker = os.path.join(_r4_base, "qtwe_worker.py")
|
||||
_worker_code = f'''# -*- coding: utf-8 -*-
|
||||
import os, sys
|
||||
sys.path.insert(0, {ROOT!r})
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import QTimer, QUrl
|
||||
from PyQt6.QtWebEngineCore import QWebEngineProfile
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from ui.views.custom_web_page import CustomWebPage
|
||||
from core import renderer_backend as rbr
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
pid = os.getpid()
|
||||
name = f"parallel_{{pid}}"
|
||||
pdir = rbr.webengine_profile_dir(name)
|
||||
prof = QWebEngineProfile(name)
|
||||
prof.setPersistentStoragePath(os.path.join(pdir, "storage"))
|
||||
prof.setCachePath(os.path.join(pdir, "cache"))
|
||||
|
||||
html = os.path.join(os.path.dirname(pdir), f"page_{{pid}}.html")
|
||||
with open(html, "w", encoding="utf-8") as f:
|
||||
f.write(f"<html><body>ok {{pid}}</body></html>")
|
||||
|
||||
view = QWebEngineView()
|
||||
view.setPage(CustomWebPage(prof, view))
|
||||
view.resize(800, 600)
|
||||
view.show()
|
||||
|
||||
st = {{"ok": False}}
|
||||
fired = {{"v": False}}
|
||||
|
||||
def finish():
|
||||
if fired["v"]: return
|
||||
fired["v"] = True
|
||||
print(f"WORKER_OK pid={{pid}} loaded={{st['ok']}} profile={{pdir}}", flush=True)
|
||||
app.quit()
|
||||
|
||||
view.page().loadFinished.connect(lambda o: (st.__setitem__("ok", bool(o)), finish()))
|
||||
view.load(QUrl.fromLocalFile(html))
|
||||
|
||||
QTimer.singleShot(30000, finish) # 兜底显式时间预算(30s)
|
||||
app.exec()
|
||||
sys.exit(0 if st["ok"] else 1)
|
||||
'''
|
||||
with open(_worker, "w", encoding="utf-8") as f:
|
||||
f.write(_worker_code)
|
||||
|
||||
_r4_env = {**os.environ,
|
||||
"QT_QPA_PLATFORM": "offscreen",
|
||||
"QTWEBENGINE_CHROMIUM_FLAGS": "--disable-gpu",
|
||||
"HAOCODE_WEBENGINE_PROFILE_DIR": _r4_base,
|
||||
"PYTHONIOENCODING": "utf-8"}
|
||||
|
||||
procs = []
|
||||
try:
|
||||
for _ in range(2):
|
||||
procs.append(subprocess.Popen([PY, _worker], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True,
|
||||
encoding="utf-8", errors="replace", env=_r4_env))
|
||||
outs = []
|
||||
for p in procs:
|
||||
try:
|
||||
out, err = p.communicate(timeout=90)
|
||||
outs.append((p.returncode, out, err))
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
outs.append((None, "", "TIMEOUT"))
|
||||
oks = [rc == 0 and "WORKER_OK" in out for rc, out, err in outs]
|
||||
check("R4.1 两个 QtWebEngine 实例并行:都成功载入本地页面",
|
||||
all(oks) and len({out for _, out, _ in outs if out}) == 2,
|
||||
repr([(rc, (out or '')[-120:], (err or '')[-200:]) for rc, out, err in outs]))
|
||||
# 两个不同 profile 目录、关闭后目录仍在(无互相清理)
|
||||
prof_dirs = []
|
||||
for rc, out, err in outs:
|
||||
for line in (out or "").splitlines():
|
||||
if line.startswith("WORKER_OK"):
|
||||
prof_dirs.append(line.rsplit("profile=", 1)[1].strip())
|
||||
check("R4.2 两个实例 profile 目录不同(独立,不争用)",
|
||||
len(prof_dirs) == 2 and prof_dirs[0] != prof_dirs[1], repr(prof_dirs))
|
||||
check("R4.3 关闭后 profile 目录仍存在(无互相清理)",
|
||||
all(os.path.isdir(d) for d in prof_dirs), repr(prof_dirs))
|
||||
finally:
|
||||
for p in procs:
|
||||
try:
|
||||
if p.poll() is None:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ======================================================================
|
||||
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
|
||||
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)
|
||||
@@ -0,0 +1,293 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
P1-04 平台截图能力单测(portal 用替身 subprocess,不依赖真实 portal / compositor)
|
||||
|
||||
覆盖:
|
||||
C1 capture_plan 路由(win32/x11 → overlay;wayland → portal;unknown → unavailable+明确日志)
|
||||
C2 detect_portal(非 Linux / 无 D-Bus 会话 / 无 gdbus / 齐备 → 四分支)
|
||||
C3 portal_screenshot_sync 成功路径(fake subprocess:Screenshot 返回解析 + FilePicked
|
||||
file:// URI 解码(含空格文件名)→ 现有附件流程可用的真实文件路径;校验 gdbus 命令行)
|
||||
C4 用户取消/授权被拒(只有 Finished 无 FilePicked → denied)
|
||||
C5 portal 缺失(call rc≠0 NotSupported → "portal 不可用" + 原因)
|
||||
C6 等待超时(显式预算内无信号 → timeout,不无限挂起)
|
||||
C7 PortalScreenshotWorker 信号回主线程(done(ok, path))
|
||||
C8 ScreenCaptureOverlay offscreen 构造 + start() 空画面守卫不崩
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_screen_capture_platforms.py
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import urllib.parse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from ui.views.system_tools import desktop_session as ds # noqa: E402
|
||||
from ui.views.system_tools import portal_capture as pc # noqa: E402
|
||||
from ui.views.system_tools import screen_capture as sc # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
APP = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# C1. capture_plan 路由
|
||||
# ======================================================================
|
||||
mode, msg = ds.capture_plan("win32")
|
||||
check("C1.1 win32 → overlay", mode == "overlay" and msg is None, repr((mode, msg)))
|
||||
mode, msg = ds.capture_plan("x11")
|
||||
check("C1.2 x11 → overlay(原生 grabWindow 路径)", mode == "overlay" and msg is None,
|
||||
repr((mode, msg)))
|
||||
mode, msg = ds.capture_plan("wayland")
|
||||
check("C1.3 wayland → portal(不绕过 compositor)", mode == "portal" and msg is None,
|
||||
repr((mode, msg)))
|
||||
mode, msg = ds.capture_plan("unknown")
|
||||
check("C1.4 unknown → unavailable + 明确日志(主程序其余功能不受影响)",
|
||||
mode == "unavailable" and msg and "不可用" in msg, repr((mode, msg)))
|
||||
|
||||
# ======================================================================
|
||||
# C2. detect_portal 四分支
|
||||
# ======================================================================
|
||||
_orig_sys = pc.sys
|
||||
_orig_which = pc.shutil.which
|
||||
|
||||
try:
|
||||
pc.sys = types.SimpleNamespace(platform="win32")
|
||||
ok, reason = pc.detect_portal()
|
||||
check("C2.1 非 Linux → 不可用", ok is False and reason, repr((ok, reason)))
|
||||
|
||||
pc.sys = types.SimpleNamespace(platform="linux")
|
||||
saved = {k: os.environ.get(k) for k in ("XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS")}
|
||||
for k in saved:
|
||||
os.environ.pop(k, None)
|
||||
ok, reason = pc.detect_portal()
|
||||
check("C2.2 无 D-Bus 会话 → 不可用(明确原因)",
|
||||
ok is False and "D-Bus" in reason, repr((ok, reason)))
|
||||
os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000"
|
||||
pc.shutil.which = lambda name: None
|
||||
ok, reason = pc.detect_portal()
|
||||
check("C2.3 无 gdbus CLI → 不可用(明确原因)",
|
||||
ok is False and "gdbus" in reason, repr((ok, reason)))
|
||||
pc.shutil.which = lambda name: "/usr/bin/gdbus"
|
||||
ok, reason = pc.detect_portal()
|
||||
check("C2.4 齐备 → 可用", ok is True and reason is None, repr((ok, reason)))
|
||||
finally:
|
||||
pc.sys = _orig_sys
|
||||
pc.shutil.which = _orig_which
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# C3–C7. portal_screenshot_sync(fake subprocess,模拟 Linux 环境)
|
||||
# ======================================================================
|
||||
_linux_sys = types.SimpleNamespace(platform="linux")
|
||||
|
||||
|
||||
class FakeRun:
|
||||
"""替身 subprocess.run:记录 argv,返回预先设定的 call 输出"""
|
||||
def __init__(self, rc=0, stdout="", stderr=""):
|
||||
self.rc = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.cmds = []
|
||||
|
||||
def __call__(self, cmd, **kw):
|
||||
self.cmds.append(list(cmd))
|
||||
p = types.SimpleNamespace()
|
||||
p.returncode = self.rc
|
||||
p.stdout = self.stdout
|
||||
p.stderr = self.stderr
|
||||
return p
|
||||
|
||||
|
||||
class FakePipe:
|
||||
"""替身 monitor 的 stdout:按序输出 JSON 行;readline 可模拟阻塞"""
|
||||
def __init__(self, lines, delay_s=0.0):
|
||||
self._lines = list(lines)
|
||||
self._delay = delay_s
|
||||
self.closed = False
|
||||
|
||||
def readline(self):
|
||||
if self._delay:
|
||||
time.sleep(self._delay)
|
||||
self._delay = 0
|
||||
if self._lines:
|
||||
return self._lines.pop(0) + "\n"
|
||||
# 模拟管道关闭(EOF)
|
||||
return ""
|
||||
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, lines, delay_s=0.0):
|
||||
self._lines = lines
|
||||
self._delay = delay_s
|
||||
self.cmd = None
|
||||
self.killed = False
|
||||
|
||||
def __call__(self, cmd, **kw):
|
||||
self.cmd = list(cmd)
|
||||
p = types.SimpleNamespace()
|
||||
p.stdout = FakePipe(self._lines, self._delay)
|
||||
p.stderr = io.StringIO()
|
||||
self._rc = None
|
||||
p.poll = lambda: self._rc
|
||||
p.kill = lambda: setattr(p, "killed", True) or setattr(self, "killed", True)
|
||||
p.wait = lambda timeout=None: 0
|
||||
return p
|
||||
|
||||
|
||||
def _uri(path: str) -> str:
|
||||
return "file://" + urllib.parse.quote(path)
|
||||
|
||||
|
||||
def _json(member, iface, body):
|
||||
return json.dumps({"interface": iface, "member": member, "body": body},
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
# C3.1 成功路径(含空格文件名 → URI 解码)
|
||||
tmpdir = tempfile.mkdtemp(prefix="haocode_p104_portal_")
|
||||
shot = os.path.join(tmpdir, "shot test.png")
|
||||
with open(shot, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\nfake")
|
||||
|
||||
pc.sys = _linux_sys # C3–C7 模拟 Linux 宿主
|
||||
_orig_which2 = pc.shutil.which
|
||||
pc.shutil.which = lambda name: "/usr/bin/gdbus" # 本机无 gdbus,替身探测
|
||||
_saved_xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||
os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000"
|
||||
run_fake = FakeRun(rc=0, stdout="('/org/freedesktop/portal/desktop/request/1000/haocode/7', <>)")
|
||||
popen_fake = FakePopen([
|
||||
_json("FilePicked", "org.freedesktop.portal.FileChooser", [_uri(shot), {}]),
|
||||
_json("Finished", "org.freedesktop.portal.Request", [0]),
|
||||
])
|
||||
_orig_run, _orig_popen = pc.subprocess.run, pc.subprocess.Popen
|
||||
pc.subprocess.run = run_fake
|
||||
pc.subprocess.Popen = popen_fake
|
||||
try:
|
||||
t0 = time.time()
|
||||
ok, result = pc.portal_screenshot_sync("haocode-shot", request_timeout_s=5, wait_budget_s=10)
|
||||
dt = time.time() - t0
|
||||
finally:
|
||||
pc.subprocess.run = _orig_run
|
||||
pc.subprocess.Popen = _orig_popen
|
||||
|
||||
check("C3.1 成功:返回真实文件路径(URI 空格解码正确)",
|
||||
ok is True and result == shot and os.path.exists(result), repr((ok, result)))
|
||||
check("C3.2 Screenshot gdbus 命令行正确(dest/path/method/parent=/)",
|
||||
run_fake.cmds and run_fake.cmds[0][:5] == ["gdbus", "call", "--session", "--dest",
|
||||
pc.PORTAL_DEST]
|
||||
and "--method=org.freedesktop.portal.Screenshot.Screenshot" in run_fake.cmds[0]
|
||||
and "/" in run_fake.cmds[0], repr(run_fake.cmds))
|
||||
check("C3.3 monitor 监听 request 对象路径",
|
||||
popen_fake.cmd and popen_fake.cmd[-2:] == ["--object-path",
|
||||
"/org/freedesktop/portal/desktop/request/1000/haocode/7"],
|
||||
repr(popen_fake.cmd))
|
||||
check("C3.4 全程在显式预算内完成(<10s)", dt < 10, f"{dt:.1f}s")
|
||||
|
||||
# C4. 用户取消/授权被拒(只有 Finished)
|
||||
popen_fake = FakePopen([_json("Finished", "org.freedesktop.portal.Request", [0])])
|
||||
pc.subprocess.run = run_fake
|
||||
pc.subprocess.Popen = popen_fake
|
||||
try:
|
||||
ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 10)
|
||||
finally:
|
||||
pc.subprocess.run = _orig_run
|
||||
pc.subprocess.Popen = _orig_popen
|
||||
check("C4.1 授权被拒 → 不伪造成功,明确原因",
|
||||
ok is False and "取消" in result, repr((ok, result)))
|
||||
|
||||
# C5. portal 缺失/方法不支持(call rc≠0)
|
||||
run_fake2 = FakeRun(rc=1, stderr="Error: org.freedesktop.DBus.Error.NotSupported: "
|
||||
"Method not supported by portal")
|
||||
pc.subprocess.run = run_fake2
|
||||
pc.subprocess.Popen = FakePopen([])
|
||||
try:
|
||||
ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 10)
|
||||
finally:
|
||||
pc.subprocess.run = _orig_run
|
||||
pc.subprocess.Popen = _orig_popen
|
||||
check("C5.1 portal 不支持 → 不可用 + 原因透出",
|
||||
ok is False and "portal 不可用" in result and "NotSupported" in result, repr((ok, result)))
|
||||
|
||||
# C6. 等待超时(monitor 首行前阻塞 > 预算)
|
||||
popen_fake = FakePopen([_json("Finished", "org.freedesktop.portal.Request", [0])],
|
||||
delay_s=3.0)
|
||||
pc.subprocess.run = run_fake
|
||||
pc.subprocess.Popen = popen_fake
|
||||
try:
|
||||
t0 = time.time()
|
||||
ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 1.0)
|
||||
dt = time.time() - t0
|
||||
finally:
|
||||
pc.subprocess.run = _orig_run
|
||||
pc.subprocess.Popen = _orig_popen
|
||||
check("C6.1 超预算 → timeout(不无限挂起)",
|
||||
ok is False and "超时" in result and dt < 5, repr((ok, result, f"{dt:.1f}s")))
|
||||
|
||||
# ======================================================================
|
||||
# C7. PortalScreenshotWorker 信号
|
||||
# ======================================================================
|
||||
popen_fake = FakePopen([
|
||||
_json("FilePicked", "org.freedesktop.portal.FileChooser", [_uri(shot), {}]),
|
||||
_json("Finished", "org.freedesktop.portal.Request", [0]),
|
||||
])
|
||||
pc.subprocess.run = run_fake
|
||||
pc.subprocess.Popen = popen_fake
|
||||
try:
|
||||
w = pc.PortalScreenshotWorker(wait_budget_s=10)
|
||||
done = {}
|
||||
w.done.connect(lambda ok, r: done.update(ok=ok, r=r))
|
||||
w.start()
|
||||
deadline = time.time() + 15
|
||||
while "ok" not in done and time.time() < deadline:
|
||||
APP.processEvents()
|
||||
time.sleep(0.02)
|
||||
w.wait(3000)
|
||||
finally:
|
||||
pc.subprocess.run = _orig_run
|
||||
pc.subprocess.Popen = _orig_popen
|
||||
pc.shutil.which = _orig_which2
|
||||
if _saved_xdg is None:
|
||||
os.environ.pop("XDG_RUNTIME_DIR", None)
|
||||
else:
|
||||
os.environ["XDG_RUNTIME_DIR"] = _saved_xdg
|
||||
pc.sys = _orig_sys
|
||||
check("C7.1 worker done 信号回主线程(ok + 路径)",
|
||||
done.get("ok") is True and done.get("r") == shot, repr(done))
|
||||
|
||||
# ======================================================================
|
||||
# C8. 覆盖层 offscreen 构造 + start() 空画面守卫
|
||||
# ======================================================================
|
||||
ov = sc.ScreenCaptureOverlay()
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
ov.start() # offscreen:无屏幕或空画面都不得崩
|
||||
shown = ov.isVisible()
|
||||
check("C8.1 offscreen start() 不崩;空画面时不显示覆盖层且日志明确",
|
||||
(not shown and "空画面" in buf.getvalue()) or (shown and ov._full_pixmap is not None),
|
||||
f"shown={shown} log={buf.getvalue()[-160:]!r}")
|
||||
if shown:
|
||||
ov.close()
|
||||
|
||||
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
|
||||
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
离屏验证:思考/压缩气泡内代码朴素渲染(去"紫+边框")+ 正文代码块不受影响
|
||||
背景:压缩摘要充满代码,finishTimelineMessage 对全部 pre code 做 hljs 高亮后,
|
||||
.code-block-wrapper 的浅色语法色(#a626a4 紫)+ 1px 边框 + 语言栏让定格后的
|
||||
压缩气泡观感"紫色带边框"。修复:.think-content 作用域内代码统一朴素灰、
|
||||
无边框、无头部、无 min-width;正文气泡代码块保持原样。
|
||||
"""
|
||||
import os, sys, json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402 铁律:WebEngine 先于 QApplication 创建导入
|
||||
from PyQt6.QtCore import QTimer, QUrl # noqa: E402
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
GRAY = "rgb(119, 119, 119)" # #777
|
||||
PURPLE = "rgb(166, 38, 164)" # #a626a4(正文代码块关键字色,保持)
|
||||
|
||||
SUMMARY = ("## 摘要\n说明文字。\n```python\nimport os\ndef run():\n return \"x\"\n```\n"
|
||||
"另外 `flowkit` 与 `read_file` 是工具名。")
|
||||
|
||||
TEST_JS = r"""
|
||||
(function(){
|
||||
try {
|
||||
var out = {};
|
||||
var SUM = __SUMMARY__;
|
||||
|
||||
// ---------- 压缩气泡:定格 + finish(触发全量 hljs 兜底)后探测 ----------
|
||||
createMessage('m1','assistant','','Assistant');
|
||||
compactionStarted('m1','pre_prompt');
|
||||
compactionFinished('m1',{summary:SUM,before:100,after:50,duration_ms:10,path:'pre_prompt'});
|
||||
finishMessage('m1');
|
||||
(function(){
|
||||
var block = document.querySelector('#m1 .compaction-think');
|
||||
if (!block) { out.m1 = 'NO-BLOCK'; return; }
|
||||
var wrap = block.querySelector('.code-block-wrapper');
|
||||
if (!wrap) { out.m1 = 'NO-WRAP'; return; }
|
||||
var cs = getComputedStyle(wrap);
|
||||
out.m1_border = cs.borderTopWidth;
|
||||
out.m1_minw = cs.minWidth;
|
||||
var hdr = wrap.querySelector('.code-header');
|
||||
out.m1_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
|
||||
var kw = wrap.querySelector('.hljs-keyword');
|
||||
out.m1_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
|
||||
var pre = wrap.querySelector('pre code');
|
||||
out.m1_pre_color = pre ? getComputedStyle(pre).color : 'NO-PRE';
|
||||
var ic = block.querySelector('p code');
|
||||
out.m1_inline_color = ic ? getComputedStyle(ic).color : 'NO-INLINE';
|
||||
})();
|
||||
|
||||
// ---------- 对照组:正文消息的代码块保持原样(边框+语法色) ----------
|
||||
createMessage('m2','assistant','','Assistant');
|
||||
appendToken('m2','前文。\n```python\nimport os\ndef run():\n return "x"\n```\n后文。');
|
||||
finishMessage('m2');
|
||||
(function(){
|
||||
var w = document.querySelector('#m2 .code-block-wrapper');
|
||||
if (!w) { out.m2 = 'NO-WRAP'; return; }
|
||||
var cs = getComputedStyle(w);
|
||||
out.m2_border = cs.borderTopWidth;
|
||||
var hdr = w.querySelector('.code-header');
|
||||
out.m2_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
|
||||
var kw = w.querySelector('.hljs-keyword');
|
||||
out.m2_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
|
||||
})();
|
||||
|
||||
return JSON.stringify(out);
|
||||
} catch(e) { return 'JSERR:' + e.message + ' @' + ((e.stack||'').split('\n')[1]||''); }
|
||||
})()
|
||||
""".replace("__SUMMARY__", json.dumps(SUMMARY, ensure_ascii=False))
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
_js = {"v": None}
|
||||
view = QWebEngineView(); view.resize(1000, 700)
|
||||
_index = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "ui", "web", "index.html"))
|
||||
def _run():
|
||||
view.page().runJavaScript(TEST_JS, lambda v: _js.update(v=v))
|
||||
view.loadFinished.connect(lambda ok: QTimer.singleShot(1500, _run))
|
||||
QTimer.singleShot(9000, app.quit)
|
||||
view.load(QUrl.fromLocalFile(_index))
|
||||
app.exec()
|
||||
|
||||
try:
|
||||
r = json.loads(_js["v"])
|
||||
except Exception:
|
||||
r = None
|
||||
print(f"DOM 结果解析失败: {_js['v']!r}", flush=True)
|
||||
|
||||
check("T.DOM 返回", isinstance(r, dict), repr(_js["v"])[:300])
|
||||
if isinstance(r, dict):
|
||||
# 压缩气泡内:朴素化
|
||||
check("T.压缩气泡代码块边框=0", r.get("m1_border") == "0px", r)
|
||||
check("T.压缩气泡代码块min-width=0", r.get("m1_minw") == "0px", r)
|
||||
check("T.压缩气泡代码头部=隐藏", r.get("m1_header_display") == "none", r)
|
||||
check("T.压缩气泡关键字色=#777(去紫)", r.get("m1_kw_color") == GRAY, r.get("m1_kw_color"))
|
||||
check("T.压缩气泡pre代码色=#777", r.get("m1_pre_color") == GRAY, r.get("m1_pre_color"))
|
||||
check("T.压缩气泡内联代码色=#777(去粉)", r.get("m1_inline_color") == GRAY, r.get("m1_inline_color"))
|
||||
# 正文对照:保持原样
|
||||
check("T.正文代码块边框=1px(不变)", r.get("m2_border") == "1px", r)
|
||||
check("T.正文代码头部=可见(不变)", r.get("m2_header_display") not in ("none", "NO-HDR"), r)
|
||||
check("T.正文关键字色=#a626a4(不变)", r.get("m2_kw_color") == PURPLE, r.get("m2_kw_color"))
|
||||
|
||||
failed = [n for n, ok in RESULTS if not ok]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,255 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
四大工具参数层单测(纯函数级,无 DB/无 UI 依赖)
|
||||
|
||||
覆盖本轮修复:
|
||||
T1-T9 参数归一化 + JSON Schema 校验(全错误上报 / null 归一化 / 轻量强制转换 / bool 漏洞)
|
||||
T10-T13 edit 参数预处理(legacy 单条 / edits 为 JSON 字符串)
|
||||
T14-T19 edit 区间规划(唯一性 / 重叠检测 / 空 oldText)
|
||||
T20-T25 read 参数钳制(limit 负数漏洞 / 越界 / 空文件)
|
||||
T26-T29 write + edit 原子写(无临时文件残留)
|
||||
T30-T31 before 钩子改参后重新校验
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_tool_params.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import glob
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.agent.tools import (validate_json_schema, normalize_and_coerce, # noqa: E402
|
||||
_prepare_edit_args, _plan_edits,
|
||||
tool_read, tool_write, tool_edit,
|
||||
execute_tool_call, PreparedToolCall)
|
||||
from core.agent.types import (AgentTool, AgentToolResult, AbortSignal, # noqa: E402
|
||||
ToolCall, AgentMessage)
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试用 schema(与 default_tools() 一致)
|
||||
# ======================================================================
|
||||
S_READ = {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"offset": {"type": "integer"},
|
||||
"limit": {"type": "integer"}},
|
||||
"required": ["path"]}
|
||||
S_BASH = {"type": "object",
|
||||
"properties": {"command": {"type": "string"},
|
||||
"timeout": {"type": "number"}},
|
||||
"required": ["command"]}
|
||||
S_EDIT = {"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"edits": {"type": "array",
|
||||
"items": {"type": "object",
|
||||
"properties": {"oldText": {"type": "string"},
|
||||
"newText": {"type": "string"}},
|
||||
"required": ["oldText"]}}},
|
||||
"required": ["path", "edits"]}
|
||||
|
||||
SG = AbortSignal()
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# T1-T9 校验层
|
||||
# ======================================================================
|
||||
def err_of(args, schema):
|
||||
return validate_json_schema(normalize_and_coerce(args, schema), schema)
|
||||
|
||||
|
||||
check("T1.缺必填参数", err_of({"offset": 1}, S_READ) == "缺少必填参数: path",
|
||||
err_of({"offset": 1}, S_READ))
|
||||
check("T2.string 类型错误", "应为 string" in (err_of({"path": 123}, S_READ) or ""),
|
||||
err_of({"path": 123}, S_READ))
|
||||
check("T3.bool 不得冒充 integer(旧版漏洞)",
|
||||
"应为 integer" in (err_of({"path": "a", "offset": True}, S_READ) or ""),
|
||||
err_of({"path": "a", "offset": True}, S_READ))
|
||||
check("T4.bool 不得冒充 number(旧版漏洞)",
|
||||
"应为 number" in (err_of({"command": "x", "timeout": True}, S_BASH) or ""),
|
||||
err_of({"command": "x", "timeout": True}, S_BASH))
|
||||
check("T5.整数 1.5 拒绝", "期望 integer" in (err_of({"path": "a", "offset": 1.5}, S_READ) or ""),
|
||||
err_of({"path": "a", "offset": 1.5}, S_READ))
|
||||
check("T6.多余参数宽容(与 pi 一致)", err_of({"path": "a", "lines": 10}, S_READ) is None,
|
||||
err_of({"path": "a", "lines": 10}, S_READ))
|
||||
_err2 = err_of({"path": 123, "offset": "bad"}, S_READ) or ""
|
||||
check("T7.多错全量上报(不再只报第 1 个)",
|
||||
_err2.count(";") >= 1 and "path" in _err2 and "offset" in _err2, _err2)
|
||||
check("T8.嵌套必填字段",
|
||||
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT)
|
||||
== "参数 edits[0] 缺少必填字段 oldText",
|
||||
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT))
|
||||
check("T9.数组元素类型错误定位到下标",
|
||||
"edits[1]" in (err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT) or ""),
|
||||
err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT))
|
||||
|
||||
# ---- 归一化 / 强制转换 ----
|
||||
check("T10.可选字段 null 被删除(不再误报类型错)",
|
||||
normalize_and_coerce({"path": "a", "offset": None}, S_READ) == {"path": "a"})
|
||||
check("T11.数字字符串被转换 \"30\"→30",
|
||||
normalize_and_coerce({"command": "x", "timeout": "30"}, S_BASH)["timeout"] == 30)
|
||||
check("T12.integer 字段收 \"1.5\" 保持原值(交由校验报错)",
|
||||
normalize_and_coerce({"path": "a", "offset": "1.5"}, S_READ)["offset"] == "1.5")
|
||||
_orig = {"path": "a", "offset": None}
|
||||
normalize_and_coerce(_orig, S_READ)
|
||||
check("T13.归一化不修改入参", _orig == {"path": "a", "offset": None}, _orig)
|
||||
|
||||
# ======================================================================
|
||||
# T14-T15 edit 参数预处理
|
||||
# ======================================================================
|
||||
check("T14.legacy 单条形式 {oldText,newText} → edits[]",
|
||||
_prepare_edit_args({"path": "a", "oldText": "x", "newText": "y"})
|
||||
== {"path": "a", "edits": [{"oldText": "x", "newText": "y"}]})
|
||||
check("T15.edits 为 JSON 字符串 → 解析为数组",
|
||||
_prepare_edit_args({"path": "a", "edits": '[{"oldText":"x"}]'})
|
||||
== {"path": "a", "edits": [{"oldText": "x"}]})
|
||||
|
||||
# ======================================================================
|
||||
# T16-T19 edit 区间规划
|
||||
# ======================================================================
|
||||
_e, _sp = _plan_edits("hello world", [{"oldText": "world", "newText": "there"}])
|
||||
check("T16.唯一命中 → 规划成功", _e is None and _sp[0][0] == 6, (_e, _sp))
|
||||
check("T17.未命中 → 报错", "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "zz"}])[0] or ""),
|
||||
_plan_edits("abc", [{"oldText": "zz"}])[0])
|
||||
check("T18.重复命中 → 报错", "匹配到 2 处" in (_plan_edits("foo foo", [{"oldText": "foo"}])[0] or ""),
|
||||
_plan_edits("foo foo", [{"oldText": "foo"}])[0])
|
||||
# 旧版会:两条对原始内容各自唯一 → 校验通过 → 应用时第 2 条已找不到(静默 no-op,仍报“已应用 2 处”)
|
||||
# 现在必须判定为重叠并整批拒绝
|
||||
_e2 = _plan_edits("foo bar", [{"oldText": "foo bar", "newText": "foo BAR"},
|
||||
{"oldText": "bar", "newText": "baz"}])[0]
|
||||
check("T19.区间重叠被检出(旧版静默 no-op 第 2 条)", "重叠" in (_e2 or ""), _e2)
|
||||
check("T19b.嵌套重叠被检出",
|
||||
"重叠" in (_plan_edits("abc", [{"oldText": "abc", "newText": "Z"},
|
||||
{"oldText": "ab", "newText": "Q"}])[0] or ""))
|
||||
check("T19d.原文件不存在的 oldText 被拒(对齐 pi:对原始快照匹配)",
|
||||
"未找到匹配文本" in (_plan_edits("abc", [{"oldText": "ab", "newText": "aX"},
|
||||
{"oldText": "aXb", "newText": "ZZ"}])[0] or ""))
|
||||
check("T19c.空 oldText 被拒(旧版空文件会静默插入)",
|
||||
"不能为空" in (_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0] or ""),
|
||||
_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0])
|
||||
|
||||
# ======================================================================
|
||||
# T20-T25 read 参数钳制(临时文件,不碰项目数据)
|
||||
# ======================================================================
|
||||
_TMP = tempfile.mkdtemp(prefix="haocode_tp_")
|
||||
CTX = {"cwd": _TMP}
|
||||
|
||||
|
||||
def _mk(name, text):
|
||||
p = os.path.join(_TMP, name)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return p
|
||||
|
||||
|
||||
try:
|
||||
_mk("lines.txt", "".join(f"L{i}\n" for i in range(1, 11))) # 10 行
|
||||
_mk("empty.txt", "")
|
||||
|
||||
r = tool_read("t", {"path": "lines.txt", "limit": -5}, SG, None, CTX)
|
||||
_t20 = r.as_text()
|
||||
check("T20.limit 负数不再读全文件(旧版返回 10 行减 5)",
|
||||
"L1" in _t20 and "L2" not in _t20, _t20.replace("\n", " | ")[:120])
|
||||
|
||||
r = tool_read("t", {"path": "lines.txt", "limit": 0}, SG, None, CTX)
|
||||
check("T21.limit=0 钳到 1(旧版返回空块 + 无意义脚注)",
|
||||
"L1" in r.as_text() and "L2" not in r.as_text(), r.as_text().replace("\n", " | ")[:120])
|
||||
|
||||
r = tool_read("t", {"path": "lines.txt", "offset": 999}, SG, None, CTX)
|
||||
check("T22.offset 越界 → 明确提示(旧版'已显示 999–998 行')",
|
||||
"超出文件范围" in r.as_text() and "共 10 行" in r.as_text(), r.as_text())
|
||||
|
||||
r = tool_read("t", {"path": "empty.txt"}, SG, None, CTX)
|
||||
check("T23.空文件 → 明确提示", "文件为空" in r.as_text(), r.as_text())
|
||||
|
||||
r = tool_read("t", {"path": ""}, SG, None, CTX)
|
||||
check("T24.path 为空 → 'path 不能为空'(旧版报'文件不存在: <cwd>')",
|
||||
r.is_error and "不能为空" in r.as_text(), r.as_text())
|
||||
|
||||
r = tool_read("t", {"path": "lines.txt", "offset": 3, "limit": 2}, SG, None, CTX)
|
||||
check("T25.正常行窗口不变(offset=3,limit=2 → L3,L4)",
|
||||
"L3" in r.as_text() and "L4" in r.as_text() and "L5" not in r.as_text()
|
||||
and "offset=5" in r.as_text(), r.as_text().replace("\n", " | ")[:140])
|
||||
|
||||
# ==================================================================
|
||||
# T26-T29 原子写 / edit 端到端
|
||||
# ==================================================================
|
||||
r = tool_write("t", {"path": "w.txt", "content": "a\nb\n"}, SG, None, CTX)
|
||||
left = glob.glob(os.path.join(_TMP, ".hocode_w_*"))
|
||||
check("T26.write 成功且无临时文件残留", not r.is_error and not left, (r.as_text(), left))
|
||||
tool_write("t", {"path": "w2.txt", "content": "foo bar"}, SG, None, CTX)
|
||||
|
||||
r = tool_edit("t", {"path": "w2.txt",
|
||||
"edits": [{"oldText": "foo bar", "newText": "foo BAR"},
|
||||
{"oldText": "bar", "newText": "baz"}]}, SG, None, CTX)
|
||||
check("T27.重叠 edit 整批拒绝且文件未被改动",
|
||||
r.is_error and "重叠" in r.as_text(), r.as_text())
|
||||
|
||||
r = tool_edit("t", {"path": "w.txt",
|
||||
"edits": [{"oldText": "a", "newText": "A"},
|
||||
{"oldText": "b", "newText": "B"}]}, SG, None, CTX)
|
||||
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
|
||||
after = f.read()
|
||||
check("T28.多条不重叠 edit 一次性应用(对原始快照)",
|
||||
not r.is_error and after == "A\nB\n", (r.as_text(), repr(after)))
|
||||
|
||||
r = tool_edit("t", {"path": "w.txt", "edits": [{"oldText": "", "newText": "X"}]}, SG, None, CTX)
|
||||
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
|
||||
after = f.read()
|
||||
check("T29.空 oldText 被拒且文件未变", r.is_error and after == "A\nB\n",
|
||||
(r.as_text(), repr(after)))
|
||||
|
||||
# ==================================================================
|
||||
# T30-T31 before 钩子改参后重新校验
|
||||
# ==================================================================
|
||||
def _echo(args):
|
||||
return AgentToolResult.text(f"echo:{json.dumps(args, sort_keys=True)}")
|
||||
|
||||
def _echo5(tool_call_id, args, signal, on_update, ctx):
|
||||
return _echo(args)
|
||||
|
||||
class _Cfg:
|
||||
tool_context = {"cwd": _TMP}
|
||||
before_tool_call = None
|
||||
after_tool_call = None
|
||||
|
||||
etool = AgentTool(name="echo", description="d", parameters=S_READ, execute=_echo5)
|
||||
tc = ToolCall(id="c1", name="echo", arguments={"path": "a"})
|
||||
|
||||
cfg_bad = _Cfg()
|
||||
cfg_bad.before_tool_call = lambda payload, sig: {"args": {"path": 123}}
|
||||
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
|
||||
AgentMessage(role="assistant", content=""), cfg_bad, SG, None)
|
||||
check("T30.before 钩子返回非法参数 → 拒绝执行(pi 同款重校验)",
|
||||
res.is_error and "校验失败" in res.as_text(), res.as_text())
|
||||
|
||||
cfg_ok = _Cfg()
|
||||
cfg_ok.before_tool_call = lambda payload, sig: {"args": {"path": "b", "offset": "7"}}
|
||||
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
|
||||
AgentMessage(role="assistant", content=""), cfg_ok, SG, None)
|
||||
check("T31.before 钩子改参后归一化生效(\"7\"→7)",
|
||||
not res.is_error and '"offset": 7' in res.as_text(), res.as_text())
|
||||
|
||||
cfg_blk = _Cfg()
|
||||
cfg_blk.before_tool_call = lambda payload, sig: {"block": True, "reason": "nope"}
|
||||
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
|
||||
AgentMessage(role="assistant", content=""), cfg_blk, SG, None)
|
||||
check("T32.before 钩子 block 语义不变", res.is_error and "nope" in res.as_text(), res.as_text())
|
||||
|
||||
finally:
|
||||
shutil.rmtree(_TMP, ignore_errors=True)
|
||||
|
||||
failed = [n for n, ok in RESULTS if not ok]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebView2 守卫单测 —— 锁死 T0 级事故不再复发
|
||||
|
||||
事故链(已发生):
|
||||
每个 MainWindow()(含 offscreen 测试)→ get_environment()
|
||||
→ 无条件 `taskkill /F /IM msedgewebview2.exe`
|
||||
→ 把【正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
|
||||
→ 它的 controller 变 disposed(set_bounds 报 0x8007139F)
|
||||
→ DOM 照渲染但视觉层永久空白(“选中会话不渲染核心内容”)
|
||||
|
||||
覆盖:
|
||||
T1 无头环境(QT_QPA_PLATFORM=offscreen)→ 不启用 WebView2
|
||||
T2 HAOCODE_FORCE_QTWEBENGINE=1 → 不启用
|
||||
T3 桌面平台(QT_QPA_PLATFORM=windows)→ 允许
|
||||
T4 单实例锁:持有者独占;【另一个进程】拿不到(跨进程互斥,是真守卫)
|
||||
T5 offscreen 下 get_environment() 直接返回 None(根本不碰共享 profile)
|
||||
T6 同一进程重复 acquire 幂等返回 True(不会把自己锁死)
|
||||
|
||||
运行: PYTHONIOENCODING=utf-8 python tests/test_wv2_guard.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 铁律:测试用独立锁文件,不得与正在运行的 app 争 data/app_instance.lock
|
||||
# (否则断言会依赖“app 是否在跑”)
|
||||
_LOCK_TMP = os.path.join(tempfile.gettempdir(), "haocode_wv2guard_%d.lock" % os.getpid())
|
||||
os.environ["HAOCODE_INSTANCE_LOCK_FILE"] = _LOCK_TMP
|
||||
if os.path.exists(_LOCK_TMP):
|
||||
try:
|
||||
os.remove(_LOCK_TMP)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
|
||||
import core.webview2 as W # noqa: E402
|
||||
|
||||
_ORIG = dict(os.environ)
|
||||
|
||||
try:
|
||||
# ---------------- T1/T2/T3: 环境守卫 ----------------
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
|
||||
check("T1.1 offscreen → 不允许 WebView2", W._wv2_allowed_here() is False)
|
||||
|
||||
os.environ["QT_QPA_PLATFORM"] = "windows"
|
||||
check("T1.2 windows → 允许 WebView2", W._wv2_allowed_here() is True)
|
||||
|
||||
for p in ("minimal", "minimalegl", "vnc", "wayland", "embedded", "offscreen"):
|
||||
os.environ["QT_QPA_PLATFORM"] = p
|
||||
if W._wv2_allowed_here() is not False:
|
||||
check(f"T1.3 平台 {p} 应被拒绝", False, p)
|
||||
break
|
||||
else:
|
||||
check("T1.3 非 windows 平台全部拒绝", True)
|
||||
|
||||
os.environ["QT_QPA_PLATFORM"] = "windows"
|
||||
os.environ["HAOCODE_FORCE_QTWEBENGINE"] = "1"
|
||||
check("T2.1 强制回退开关生效", W._wv2_allowed_here() is False)
|
||||
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
|
||||
|
||||
# ---------------- T4: 跨进程单实例互斥(核心) ----------------
|
||||
os.environ["QT_QPA_PLATFORM"] = "windows"
|
||||
W._INSTANCE_LOCK["fh"] = None # 从头开始,避免受本进程历史影响
|
||||
first = W.acquire_instance_lock()
|
||||
check("T4.1 首个 acquires 成功", first is True, str(first))
|
||||
check("T4.2 同进程重复 acquire 幂等为 True",
|
||||
W.acquire_instance_lock() is True)
|
||||
|
||||
code = ("import sys; sys.path.insert(0, r'%s');"
|
||||
"import core.webview2 as W; print(W.acquire_instance_lock())"
|
||||
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
r = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace", timeout=60)
|
||||
got = (r.stdout or "").strip().splitlines()
|
||||
got = got[-1] if got else ""
|
||||
check("T4.3 另一进程拿不到锁(互斥生效)", got == "False", f"stdout={got!r} err={(r.stderr or '')[:120]}")
|
||||
|
||||
# ---------------- T5: offscreen 下 get_environment 直接 None ----------------
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
W._env = None
|
||||
env = W.get_environment(None)
|
||||
check("T5.1 offscreen 下 get_environment → None(不碰共享 profile)", env is None)
|
||||
check("T5.2 被守卫拦下后 _env 仍为空", W._env is None)
|
||||
|
||||
# ---------------- T6: 已有实例在跑时 → 不启用 WebView2 ----------------
|
||||
# 此时本进程仍持有锁;用子进程模拟“后来的实例”
|
||||
code2 = ("import sys; sys.path.insert(0, r'%s');"
|
||||
"import core.webview2 as W; print(W.get_environment(None) is None)"
|
||||
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
r2 = subprocess.run([sys.executable, "-c", code2], capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
env={**os.environ, "QT_QPA_PLATFORM": "windows"},
|
||||
timeout=60)
|
||||
out2 = (r2.stdout or "").strip().splitlines()
|
||||
out2 = out2[-1] if out2 else ""
|
||||
check("T6.1 已有实例持锁 → 后来的实例拿到 None(回落 QtWebEngine,不 taskkill)",
|
||||
out2 == "True", f"stdout={out2!r} err={(r2.stderr or '')[:160]}")
|
||||
|
||||
finally:
|
||||
try:
|
||||
fh = W._INSTANCE_LOCK.get("fh")
|
||||
if fh is not None:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.exists(_LOCK_TMP):
|
||||
os.remove(_LOCK_TMP)
|
||||
except Exception:
|
||||
pass
|
||||
os.environ.clear()
|
||||
os.environ.update(_ORIG)
|
||||
|
||||
failed = [n for n, ok in RESULTS if not ok]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""会话模式弹窗 · 可视化调参工具
|
||||
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_mode_popup.py
|
||||
- 左侧:调参窗(拖动/修改参数 → 右侧弹窗实时重渲染)
|
||||
- 右侧:真实的 SessionModePopup 本体(独立窗口,点调参窗不会消失)
|
||||
- 满意后点「确定」→ 参数 JSON 写入 data/mode_popup_tune.json → 自动退出
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
|
||||
QPushButton, QCheckBox, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt # noqa: E402
|
||||
from PyQt6.QtGui import QPixmap # noqa: E402
|
||||
from ui.views.main_window import SessionModePopup, _ModeRow, _popup_svg_path # noqa: E402
|
||||
|
||||
DEFAULTS = {
|
||||
"icon_size": 14, # 图标尺寸
|
||||
"name_font": 12, # 名称字号
|
||||
"desc_font": 12, # 描述字号
|
||||
"row_margin_v": 8, # 行上下内边距
|
||||
"row_margin_h": 10, # 行左右内边距
|
||||
"icon_text_gap": 12, # 图标-文字间距
|
||||
"name_desc_gap": 4, # 名称/描述行间距
|
||||
"row_radius": 8, # 行圆角
|
||||
"popup_width": 320, # 弹窗宽度
|
||||
"container_margin": 4, # 容器边距
|
||||
}
|
||||
RANGES = {
|
||||
"icon_size": (8, 48), "name_font": (10, 20), "desc_font": (9, 18),
|
||||
"row_margin_v": (2, 20), "row_margin_h": (4, 24), "icon_text_gap": (4, 24),
|
||||
"name_desc_gap": (0, 8), "row_radius": (0, 16), "popup_width": (240, 420),
|
||||
"container_margin": (0, 16),
|
||||
}
|
||||
CN = {
|
||||
"icon_size": "图标尺寸 (px)",
|
||||
"name_font": "名称字号 (px)",
|
||||
"desc_font": "描述字号 (px)",
|
||||
"row_margin_v": "行上下内边距 (px)",
|
||||
"row_margin_h": "行左右内边距 (px)",
|
||||
"icon_text_gap": "图标-文字间距 (px)",
|
||||
"name_desc_gap": "名称/描述行距 (px)",
|
||||
"row_radius": "行圆角 (px)",
|
||||
"popup_width": "弹窗宽度 (px)",
|
||||
"container_margin": "容器边距 (px)",
|
||||
}
|
||||
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif'
|
||||
|
||||
|
||||
class Tuner:
|
||||
def __init__(self):
|
||||
self.app = QApplication(sys.argv)
|
||||
self.params = dict(DEFAULTS)
|
||||
|
||||
# ---- 弹窗本体(真实类):去掉 Popup 标志 → 独立普通无边框窗,不随失焦消失 ----
|
||||
self.popup = SessionModePopup(None)
|
||||
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
|
||||
self.rows = self.popup.container.findChildren(_ModeRow)
|
||||
icon_by_mode = {m: _popup_svg_path(ic) for m, _, _, ic in SessionModePopup.MODES}
|
||||
for r in self.rows:
|
||||
r._tune_pix = QPixmap(icon_by_mode[r.mode]) # 原始分辨率,重缩不糊
|
||||
|
||||
screen = self.app.primaryScreen().availableGeometry()
|
||||
self.px = screen.center().x() - 300
|
||||
self.py = screen.center().y() - 200
|
||||
self.popup.move(self.px, self.py)
|
||||
self.popup.show()
|
||||
|
||||
# ---- 调参窗 ----
|
||||
self.win = QWidget()
|
||||
self.win.setWindowTitle("会话模式弹窗调参 —— 调好后点「确定」")
|
||||
form = QFormLayout(self.win)
|
||||
form.setSpacing(8)
|
||||
self.spins = {}
|
||||
for key, (lo, hi) in RANGES.items():
|
||||
sp = QSpinBox()
|
||||
sp.setRange(lo, hi)
|
||||
sp.setValue(self.params[key])
|
||||
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
|
||||
self.spins[key] = sp
|
||||
form.addRow(CN[key], sp)
|
||||
|
||||
self.chk_selected = QCheckBox("显示选中态(预览)")
|
||||
self.chk_selected.toggled.connect(lambda _: self.apply_all())
|
||||
form.addRow(self.chk_selected)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
b_reset = QPushButton("重置默认")
|
||||
b_reset.clicked.connect(self.reset)
|
||||
b_ok = QPushButton("✅ 确定(写入日志并退出)")
|
||||
b_ok.setStyleSheet("font-weight: bold;")
|
||||
b_ok.clicked.connect(self.finish)
|
||||
btns.addWidget(b_reset)
|
||||
btns.addWidget(b_ok)
|
||||
form.addRow(btns)
|
||||
|
||||
self.win.resize(360, self.win.sizeHint().height())
|
||||
self.win.move(self.px - 380, self.py)
|
||||
self.win.show()
|
||||
|
||||
self.apply_all()
|
||||
|
||||
# ---------- 实时渲染 ----------
|
||||
def on_change(self, k, v):
|
||||
self.params[k] = v
|
||||
self.apply_all()
|
||||
|
||||
def apply_all(self):
|
||||
p = self.params
|
||||
popup = self.popup
|
||||
popup.setFixedWidth(p["popup_width"])
|
||||
cl = popup.container.layout()
|
||||
cl.setContentsMargins(p["container_margin"], p["container_margin"],
|
||||
p["container_margin"], p["container_margin"])
|
||||
sel = self.chk_selected.isChecked()
|
||||
for r in self.rows:
|
||||
r.lbl_icon.setFixedSize(p["icon_size"], p["icon_size"])
|
||||
r.lbl_icon.setPixmap(r._tune_pix.scaled(
|
||||
p["icon_size"], p["icon_size"],
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation))
|
||||
lay = r.layout()
|
||||
lay.setContentsMargins(p["row_margin_h"], p["row_margin_v"],
|
||||
p["row_margin_h"] + 2, p["row_margin_v"])
|
||||
lay.setSpacing(p["icon_text_gap"])
|
||||
txt = lay.itemAt(1).layout()
|
||||
if txt is not None:
|
||||
txt.setSpacing(p["name_desc_gap"])
|
||||
name_color = "#1a73e8" if sel else "#333333"
|
||||
r.lbl_name.setStyleSheet(
|
||||
f"font-family: {FONT_STACK}; font-size: {p['name_font']}px;"
|
||||
f"font-weight: bold; color: {name_color}; background: transparent;")
|
||||
r.lbl_desc.setStyleSheet(
|
||||
f"font-family: {FONT_STACK}; font-size: {p['desc_font']}px;"
|
||||
f"color: #888888; background: transparent;")
|
||||
bg = "#e8f0fe" if sel else "transparent"
|
||||
r.setStyleSheet(f"background-color: {bg}; border-radius: {p['row_radius']}px;")
|
||||
self.app.processEvents()
|
||||
popup.adjust_popup_height()
|
||||
popup.adjustSize()
|
||||
|
||||
def reset(self):
|
||||
self.params = dict(DEFAULTS)
|
||||
for k, sp in self.spins.items():
|
||||
sp.blockSignals(True)
|
||||
sp.setValue(self.params[k])
|
||||
sp.blockSignals(False)
|
||||
self.apply_all()
|
||||
|
||||
# ---------- 确定 → 写日志 ----------
|
||||
def finish(self):
|
||||
log = os.path.join(os.path.dirname(__file__), "..", "data", "mode_popup_tune.json")
|
||||
os.makedirs(os.path.dirname(log), exist_ok=True)
|
||||
with open(log, "w", encoding="utf-8") as f:
|
||||
json.dump(self.params, f, ensure_ascii=False, indent=2)
|
||||
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
|
||||
print(json.dumps(self.params, ensure_ascii=False), flush=True)
|
||||
self.app.quit()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
Tuner()
|
||||
QApplication.instance().exec()
|
||||
except Exception:
|
||||
err = traceback.format_exc()
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(__file__), "..", "data",
|
||||
"tune_error.log"), "w", encoding="utf-8") as f:
|
||||
f.write(err)
|
||||
except OSError:
|
||||
pass
|
||||
QMessageBox.critical(None, "调参工具启动失败", err)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,399 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模型选择弹窗 · 可视化调参工具
|
||||
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_model_popup.py
|
||||
- 左侧:调参窗(改参数 → 右侧弹窗实时重渲染)
|
||||
- 右侧:真实的 ModelSelectPopup 本体(独立窗口,点调参窗不会消失)
|
||||
- 满意后点「确定」→ 参数 JSON 写入 data/model_popup_tune.json → 自动退出
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
|
||||
QPushButton, QCheckBox, QLabel, QMessageBox, QComboBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize # noqa: E402
|
||||
from PyQt6.QtGui import QPixmap, QIcon, QPainter, QFont, QFontDatabase # noqa: E402
|
||||
from ui.views.main_window import ModelSelectPopup, MainWindow, _popup_svg_path # noqa: E402 (先于 QApplication)
|
||||
|
||||
DEFAULTS = {
|
||||
# —— 字体 ——
|
||||
"font_family": "Microsoft YaHei", # 字体族(下拉选择;调参确认值)
|
||||
# —— 模型行 ——
|
||||
"item_font": 13, # 模型名字号(经 list_widget.setFont 生效)
|
||||
"model_item_h": 27, # 模型行高
|
||||
"model_icon": 16, # 模型图标尺寸
|
||||
"model_icon_pad": 34, # 模型行左边距(图标透明左垫,仅作用模型行)
|
||||
# —— 供应商行 ——
|
||||
"header_height": 38, # 供应商行高(= widget 高;默认 38 = 当前代码实际行高)
|
||||
"header_font": 13, # 供应商名字号
|
||||
"count_font": 10, # 数量字号
|
||||
"provider_svg": "provider.svg", # 供应商图标(3 个 SVG 可切换)
|
||||
"header_icon": 16, # 供应商图标尺寸
|
||||
"header_icon_pad": 0, # 供应商图标水平位置(左侧留白,仅推图标与后续内容)
|
||||
"header_icon_pad_v": 1, # 供应商图标垂直位置(正=下移,负=上移)
|
||||
"arrow_size": 13, # 展开/收起箭头尺寸
|
||||
"header_hpad_l": 3, # 供应商行左边距(头部按钮左 margin)
|
||||
# —— 整体 ——
|
||||
"popup_width": 340, # 弹窗宽度
|
||||
"container_vmargin": 4, # 容器上下边距
|
||||
}
|
||||
RANGES = {
|
||||
"item_font": (10, 20),
|
||||
"model_item_h": (18, 40), "model_icon": (8, 28),
|
||||
"model_icon_pad": (0, 48), "header_height": (20, 60), "header_font": (9, 18),
|
||||
"count_font": (8, 16), "header_icon": (10, 28), "header_icon_pad": (0, 30),
|
||||
"header_icon_pad_v": (-10, 10),
|
||||
"arrow_size": (8, 20),
|
||||
"header_hpad_l": (0, 20),
|
||||
"popup_width": (260, 420), "container_vmargin": (0, 12),
|
||||
}
|
||||
CN = {
|
||||
"font_family": "字体(下拉选择)",
|
||||
"item_font": "模型名字号 (px)",
|
||||
"model_item_h": "模型行高 (px)",
|
||||
"model_icon": "模型图标尺寸 (px)", "model_icon_pad": "模型行左边距 (px)",
|
||||
"header_height": "供应商行高 (px)", "header_font": "供应商名字号 (px)",
|
||||
"count_font": "数量字号 (px)", "header_icon": "供应商图标尺寸 (px)",
|
||||
"provider_svg": "供应商图标(SVG 切换)",
|
||||
"header_icon_pad": "供应商图标水平位置 (px)",
|
||||
"header_icon_pad_v": "供应商图标垂直位置 (px,正=下移)",
|
||||
"arrow_size": "箭头尺寸 (px)", "header_hpad_l": "供应商行左边距 (px)",
|
||||
"popup_width": "弹窗宽度 (px)",
|
||||
"container_vmargin": "容器上下边距 (px)",
|
||||
}
|
||||
SECTIONS = [
|
||||
("字体", ["font_family"]),
|
||||
("模型行", ["item_font", "model_item_h", "model_icon", "model_icon_pad"]),
|
||||
("供应商行", ["header_height", "header_font", "count_font",
|
||||
"provider_svg", "header_icon", "header_icon_pad",
|
||||
"header_icon_pad_v", "arrow_size", "header_hpad_l"]),
|
||||
("整体", ["popup_width", "container_vmargin"]),
|
||||
]
|
||||
# 供应商图标候选(文件名, 显示名)
|
||||
PROVIDER_SVGS = [
|
||||
("provider.svg", "图标 1 · 服务器"),
|
||||
("provider2.svg", "图标 2 · 二级服务器"),
|
||||
("provider3.svg", "图标 3 · 服务器(细线)"),
|
||||
]
|
||||
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", sans-serif'
|
||||
|
||||
QSS_TMPL = """
|
||||
* {
|
||||
font-family: FONT_STACK;
|
||||
}
|
||||
#popup_container {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 12px;
|
||||
}
|
||||
#group_toggle_btn { background: transparent; border: none; }
|
||||
#group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; }
|
||||
#model_list {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
#model_list::item {
|
||||
font-family: FONT_STACK;
|
||||
color: #333333;
|
||||
font-weight: normal;
|
||||
/* 实测本 Qt/PyQt6 构建下 ::item 仅 background-color/color 生效;
|
||||
font-size/font 缩写/font-family/margin/padding/border-radius 全部无效——
|
||||
字号走 setFont,行高走 sizeHint,边距走图标透明垫/按钮 margin */
|
||||
}
|
||||
#model_list::item:hover {
|
||||
background-color: #f0f4f9;
|
||||
color: #111111;
|
||||
}
|
||||
#model_list::item:selected {
|
||||
background-color: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
font-weight: bold;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 5px;
|
||||
margin: 12px 2px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #d0d0d0;
|
||||
min-height: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #a0a0a0;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tuner:
|
||||
def __init__(self):
|
||||
self.app = QApplication(sys.argv)
|
||||
self.params = dict(DEFAULTS)
|
||||
|
||||
# ---- 弹窗本体(真实类 + 真实 config + 真实祖先上下文) ----
|
||||
# ⚠️ 保真关键:实际 App 中弹窗是 MainWindow 的子部件,会继承其全局样式表
|
||||
# (QListWidget::item 的 padding/margin/选中蓝条等);若 parent=None 渲染,
|
||||
# 就会出“调试与实际不符”的矛盾 → 借 MainWindow.setup_stylesheet 把真实
|
||||
# 样式表套到宿主上,弹窗挂在宿主下渲染(与生产同构)
|
||||
self._host = QWidget()
|
||||
MainWindow.setup_stylesheet(self._host)
|
||||
self._host.resize(1, 1)
|
||||
self._host.move(-200, -200)
|
||||
self._host.show()
|
||||
cfg_path = os.path.join(os.path.dirname(__file__), "..", "data", "config.json")
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
self.popup = ModelSelectPopup(self._host, cfg)
|
||||
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
|
||||
self.list = self.popup.list_widget
|
||||
self._tag_headers()
|
||||
|
||||
screen = self.app.primaryScreen().availableGeometry()
|
||||
self.px = screen.center().x() - 200
|
||||
self.py = screen.center().y() - 300
|
||||
self.popup.move(self.px, self.py)
|
||||
self.popup.show()
|
||||
|
||||
# ---- 调参窗 ----
|
||||
self.win = QWidget()
|
||||
self.win.setWindowTitle("模型选择弹窗调参 —— 调好后点「确定」")
|
||||
form = QFormLayout(self.win)
|
||||
form.setSpacing(7)
|
||||
self.spins = {}
|
||||
self.combos = {}
|
||||
self.broken = set() # 用户标记的“不生效”参数
|
||||
for title, keys in SECTIONS:
|
||||
form.addRow(QLabel(f"—— {title} ——"))
|
||||
for key in keys:
|
||||
mark = QPushButton("不生效")
|
||||
mark.setCheckable(True)
|
||||
mark.setFixedWidth(58)
|
||||
mark.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
mark.setStyleSheet("""
|
||||
QPushButton { font-size: 11px; color: #888888; border: 1px solid #cccccc;
|
||||
border-radius: 4px; background: #fafafa; }
|
||||
QPushButton:checked { color: #ffffff; background: #d93025; border-color: #d93025; }
|
||||
""")
|
||||
mark.toggled.connect(lambda on, k=key: self.on_mark(k, on))
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(4)
|
||||
if key == "font_family":
|
||||
combo = QComboBox()
|
||||
fams = ["HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC",
|
||||
"Segoe UI", "Arial", "Consolas", "SimHei", "PingFang SC"]
|
||||
seen = set()
|
||||
for fam in fams + sorted(QFontDatabase.families()):
|
||||
if fam and fam not in seen:
|
||||
seen.add(fam)
|
||||
combo.addItem(fam)
|
||||
combo.setCurrentText(self.params[key])
|
||||
combo.currentTextChanged.connect(lambda v, k=key: self.on_change(k, v))
|
||||
self.combos[key] = combo
|
||||
row.addWidget(combo, 1)
|
||||
elif key == "provider_svg":
|
||||
combo = QComboBox()
|
||||
for fname, cname in PROVIDER_SVGS:
|
||||
combo.addItem(cname, fname)
|
||||
combo.setCurrentIndex(max(combo.findData(self.params[key]), 0))
|
||||
combo.currentIndexChanged.connect(
|
||||
lambda i, k=key, c=combo:
|
||||
self.on_change(k, c.itemData(i) or "provider.svg"))
|
||||
self.combos[key] = combo
|
||||
row.addWidget(combo, 1)
|
||||
else:
|
||||
lo, hi = RANGES[key]
|
||||
sp = QSpinBox()
|
||||
sp.setRange(lo, hi)
|
||||
sp.setValue(self.params[key])
|
||||
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
|
||||
self.spins[key] = sp
|
||||
row.addWidget(sp, 1)
|
||||
row.addWidget(mark)
|
||||
form.addRow(CN[key], row)
|
||||
|
||||
self.chk_selected = QCheckBox("显示选中行(预览)")
|
||||
self.chk_selected.toggled.connect(self.on_toggle_selected)
|
||||
form.addRow(self.chk_selected)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
b_reset = QPushButton("重置默认")
|
||||
b_reset.clicked.connect(self.reset)
|
||||
b_ok = QPushButton("✅ 确定(写入日志并退出)")
|
||||
b_ok.setStyleSheet("font-weight: bold;")
|
||||
b_ok.clicked.connect(self.finish)
|
||||
btns.addWidget(b_reset)
|
||||
btns.addWidget(b_ok)
|
||||
form.addRow(btns)
|
||||
|
||||
self.win.resize(380, min(self.win.sizeHint().height(), screen.height() - 80))
|
||||
self.win.move(max(20, self.px - 400), self.py)
|
||||
self.win.show()
|
||||
|
||||
self.apply_all()
|
||||
|
||||
# ---------- 启动时给头部标签打角色标签(此时尺寸还是默认值,判定可靠) ----------
|
||||
def _tag_headers(self):
|
||||
for g in self.popup._groups:
|
||||
hwd = self.list.itemWidget(g["header_item"])
|
||||
for lbl in hwd.findChildren(QLabel):
|
||||
txt = lbl.text()
|
||||
if txt == "":
|
||||
# 默认: 供应商图标 16×16, 箭头 12×12 → 启动时按尺寸区分(此后不再变判定基准)
|
||||
lbl.setProperty("_role", "icon" if lbl.size().width() >= 14 else "chevron")
|
||||
elif txt.isdigit():
|
||||
lbl.setProperty("_role", "count")
|
||||
else:
|
||||
lbl.setProperty("_role", "name")
|
||||
|
||||
# ---------- 实时渲染 ----------
|
||||
def on_mark(self, k, on):
|
||||
if on:
|
||||
self.broken.add(k)
|
||||
else:
|
||||
self.broken.discard(k)
|
||||
|
||||
def on_change(self, k, v):
|
||||
self.params[k] = v
|
||||
self.apply_all()
|
||||
|
||||
def _rebuild_icons(self):
|
||||
p = self.params
|
||||
pop = self.popup
|
||||
pop._provider_pixmap = QPixmap(_popup_svg_path(p.get("provider_svg", "provider.svg"))).scaled(
|
||||
p["header_icon"], p["header_icon"],
|
||||
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
pop._arrow_expanded = QIcon(_popup_svg_path("chevron_down.svg")).pixmap(p["arrow_size"], p["arrow_size"])
|
||||
pop._arrow_collapsed = QIcon(_popup_svg_path("chevron_right.svg")).pixmap(p["arrow_size"], p["arrow_size"])
|
||||
m = QPixmap(_popup_svg_path("model.svg")).scaled(
|
||||
p["model_icon"], p["model_icon"],
|
||||
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
pad_w = p["model_icon"] + p["model_icon_pad"]
|
||||
_pad = QPixmap(pad_w, p["model_icon"])
|
||||
_pad.fill(Qt.GlobalColor.transparent)
|
||||
_pp = QPainter(_pad)
|
||||
_pp.drawPixmap(p["model_icon_pad"], 0, m)
|
||||
_pp.end()
|
||||
pop._model_icon = QIcon(_pad)
|
||||
# 关键:item 持有 QIcon 的拷贝,类属性替换后必须逐个 setIcon 才会更新(实测)
|
||||
for g in pop._groups:
|
||||
for it in g["model_items"]:
|
||||
it.setIcon(pop._model_icon)
|
||||
self.list.setIconSize(QSize(pad_w, p["model_icon"]))
|
||||
|
||||
def apply_all(self):
|
||||
p = self.params
|
||||
pop = self.popup
|
||||
family = p["font_family"]
|
||||
stack = f'"{family}", "Microsoft YaHei", "Noto Sans SC", sans-serif'
|
||||
pop.setFixedWidth(p["popup_width"])
|
||||
pop.container_layout.setContentsMargins(0, p["container_vmargin"], 0, p["container_vmargin"])
|
||||
self._rebuild_icons()
|
||||
|
||||
for g in pop._groups:
|
||||
hwd = self.list.itemWidget(g["header_item"])
|
||||
hwd.setFixedHeight(p["header_height"])
|
||||
g["header_item"].setSizeHint(QSize(0, p["header_height"])) # 行高 = widget 高
|
||||
btns = hwd.findChildren(QPushButton)
|
||||
if btns:
|
||||
bl = btns[0].layout()
|
||||
if bl is not None:
|
||||
bl.setContentsMargins(p["header_hpad_l"], 0, 8, 0)
|
||||
for lbl in hwd.findChildren(QLabel):
|
||||
role = lbl.property("_role")
|
||||
if role == "icon":
|
||||
pad = p["header_icon_pad"]
|
||||
pv = p["header_icon_pad_v"]
|
||||
# 2 倍 margin:抵消 label 行内居中吸收的一半增高 → 1:1 位移
|
||||
top, bot = 2 * max(pv, 0), 2 * max(-pv, 0)
|
||||
lbl.setFixedSize(p["header_icon"] + pad, p["header_icon"] + top + bot)
|
||||
lbl.setContentsMargins(pad, top, 0, bot)
|
||||
lbl.setPixmap(pop._provider_pixmap)
|
||||
elif role == "chevron":
|
||||
lbl.setFixedSize(p["arrow_size"], p["arrow_size"])
|
||||
lbl.setPixmap(pop._arrow_expanded if g["expanded"] else pop._arrow_collapsed)
|
||||
elif role == "name":
|
||||
lbl.setStyleSheet(
|
||||
f"font-family: {stack}; color: #555555; font-weight: normal;"
|
||||
f"font-size: {p['header_font']}px; letter-spacing: 1px; background: transparent;")
|
||||
elif role == "count":
|
||||
lbl.setStyleSheet(
|
||||
f"font-family: {stack}; color: #888888;"
|
||||
f"font-size: {p['count_font']}px; font-weight: normal; background: transparent;")
|
||||
for it in g["model_items"]:
|
||||
it.setSizeHint(QSize(0, p["model_item_h"]))
|
||||
|
||||
pop.setStyleSheet(QSS_TMPL
|
||||
.replace("FONT_STACK", stack))
|
||||
# 模型行字体:widget 级 setFont(QSS ::item 的 font-size/缩写都不可靠,实测)
|
||||
_f = self.list.font()
|
||||
_f.setPixelSize(p["item_font"])
|
||||
_f.setFamily(family)
|
||||
self.list.setFont(_f)
|
||||
self.app.processEvents()
|
||||
pop.adjust_popup_height()
|
||||
pop.adjustSize()
|
||||
|
||||
def on_toggle_selected(self, on):
|
||||
if on:
|
||||
for g in self.popup._groups:
|
||||
if g["model_items"] and not g["model_items"][0].isHidden():
|
||||
self.list.setCurrentItem(g["model_items"][0])
|
||||
break
|
||||
else:
|
||||
self.list.clearSelection()
|
||||
|
||||
def reset(self):
|
||||
self.params = dict(DEFAULTS)
|
||||
for k, sp in self.spins.items():
|
||||
sp.blockSignals(True)
|
||||
sp.setValue(self.params[k])
|
||||
sp.blockSignals(False)
|
||||
for k, cb in self.combos.items():
|
||||
cb.blockSignals(True)
|
||||
cb.setCurrentText(self.params[k])
|
||||
cb.blockSignals(False)
|
||||
self.apply_all()
|
||||
|
||||
# ---------- 确定 → 写日志 ----------
|
||||
def finish(self):
|
||||
log = os.path.join(os.path.dirname(__file__), "..", "data", "model_popup_tune.json")
|
||||
os.makedirs(os.path.dirname(log), exist_ok=True)
|
||||
payload = dict(self.params)
|
||||
payload["not_working"] = sorted(self.broken) # 用户标记的不生效参数
|
||||
with open(log, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
|
||||
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
||||
self.app.quit()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
Tuner()
|
||||
QApplication.instance().exec()
|
||||
except Exception:
|
||||
err = traceback.format_exc()
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(__file__), "..", "data",
|
||||
"tune_error.log"), "w", encoding="utf-8") as f:
|
||||
f.write(err)
|
||||
except OSError:
|
||||
pass
|
||||
QMessageBox.critical(None, "调参工具启动失败", err)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,151 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""on-screen 布局验证:正文 .md-segment 在真实窗口中的 offsetHeight。
|
||||
offscreen 没有布局(一切 h=0),必须在真实窗口验证。
|
||||
PASS 条件:流式中 + finish 后 正文段 h > 0。
|
||||
"""
|
||||
import os, sys
|
||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import PyQt6.QtWebEngineWidgets # noqa
|
||||
from PyQt6.QtCore import QTimer
|
||||
from ui.views.main_window import MainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = MainWindow()
|
||||
w.resize(1000, 700)
|
||||
w.show()
|
||||
|
||||
AUDIT = {}
|
||||
|
||||
def js(expr, cb=None):
|
||||
if cb:
|
||||
w.browser.page().runJavaScript(expr, cb)
|
||||
else:
|
||||
w.browser.page().runJavaScript(expr)
|
||||
|
||||
def on_ready(_res):
|
||||
js(f"createMessage('vtest', 'assistant');")
|
||||
QTimer.singleShot(300, phase_stream)
|
||||
|
||||
def phase_stream():
|
||||
js("appendReasoning('vtest', '这是思考第一段内容。');")
|
||||
body = "测试!测试!测试!\n\n这是第二段正文,用于验证布局高度。"
|
||||
import json as _json
|
||||
for ch in body:
|
||||
js("appendToken('vtest', " + _json.dumps(ch) + ");")
|
||||
QTimer.singleShot(1500, phase_mid_check)
|
||||
|
||||
def phase_mid_check(_r=None):
|
||||
js("""(function(){
|
||||
var seg = document.querySelector('#vtest .md-segment');
|
||||
var tc = document.querySelector('#vtest .think-content');
|
||||
return JSON.stringify({
|
||||
mid: true,
|
||||
segH: seg ? seg.offsetHeight : -1,
|
||||
segConnected: seg ? seg.isConnected : null,
|
||||
segRect: seg ? Math.round(seg.getBoundingClientRect().height) : -1,
|
||||
tcH: tc ? tc.offsetHeight : -1
|
||||
});
|
||||
})()""", on_mid)
|
||||
|
||||
def on_mid(res):
|
||||
AUDIT["mid"] = res
|
||||
js("finishMessage('vtest');")
|
||||
QTimer.singleShot(1200, phase_finish_check)
|
||||
|
||||
def phase_finish_check(_r=None):
|
||||
js("""(function(){
|
||||
var seg = document.querySelector('#vtest .md-segment');
|
||||
return JSON.stringify({
|
||||
fin: true,
|
||||
segH: seg ? seg.offsetHeight : -1,
|
||||
segText: seg ? seg.textContent.length : -1,
|
||||
segDisplay: seg ? getComputedStyle(seg).display : null
|
||||
});
|
||||
})()""", on_finish)
|
||||
|
||||
def on_finish(res):
|
||||
AUDIT["fin"] = res
|
||||
print("MID =", AUDIT.get("mid"))
|
||||
print("FIN =", AUDIT.get("fin"))
|
||||
try:
|
||||
import json
|
||||
m, f = json.loads(AUDIT["mid"]), json.loads(AUDIT["fin"])
|
||||
ok = m["segH"] > 0 and m["tcH"] > 0 and f["segH"] > 0 and f["segText"] > 20
|
||||
print("===== " + ("PASS: 正文段真实布局高度正常" if ok else "FAIL: 正文段高度异常") + f" mid.segH={m['segH']} fin.segH={f['segH']} =====")
|
||||
except Exception as e:
|
||||
print("===== FAIL: 解析异常", e, "=====")
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(2500, lambda: js("document.readyState", on_ready))
|
||||
QTimer.singleShot(30000, app.quit)
|
||||
app.exec()
|
||||
+112
-489
@@ -1,5 +1,4 @@
|
||||
import sys
|
||||
import copy
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
# 引入我们刚才写的线程工作类
|
||||
@@ -9,23 +8,12 @@ import json
|
||||
from ui.views.custom_web_page import CustomWebPage
|
||||
from PyQt6.QtCore import QUrl # 🌟 新增:用于加载本地 HTML
|
||||
|
||||
# ========== 流式诊断日志(仅显式开启时写 stream_diag.log) ==========
|
||||
# ========== 流式诊断日志(排查显示问题的根本手段,写 stream_diag.log) ==========
|
||||
import time as _diag_time
|
||||
|
||||
|
||||
def _stream_diagnostics_enabled() -> bool:
|
||||
"""仅在显式要求时开启高开销流式诊断。"""
|
||||
return (os.environ.get("HAOCODE_STREAM_DIAG") == "1"
|
||||
or os.environ.get("HAOCODE_SHOT") == "1")
|
||||
|
||||
|
||||
DIAG_LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"stream_diag.log")
|
||||
|
||||
|
||||
def diag_log(line: str):
|
||||
if not _stream_diagnostics_enabled():
|
||||
return
|
||||
try:
|
||||
t = _diag_time.time()
|
||||
with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
@@ -434,27 +422,20 @@ class AttachmentPreviewOverlay(QtWidgets.QWidget):
|
||||
|
||||
|
||||
class SettingsWindow(QtWidgets.QWidget):
|
||||
"""设置窗口:可覆盖原生 WebView2 的顶层遮罩。"""
|
||||
"""设置窗口:虚化遮罩居中自适应卡片"""
|
||||
close_requested = QtCore.pyqtSignal()
|
||||
config_changed = QtCore.pyqtSignal(dict)
|
||||
model_selected = QtCore.pyqtSignal(str, str)
|
||||
|
||||
def __init__(self, parent=None, config_data=None):
|
||||
super().__init__(parent)
|
||||
self._main = parent.window() if parent is not None else None
|
||||
self.config_data = copy.deepcopy(config_data) if isinstance(config_data, dict) else {}
|
||||
self._closed = False
|
||||
self._closing = False
|
||||
self.config_data = config_data or {}
|
||||
self._setup_ui()
|
||||
self._setup_animation()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _setup_ui(self):
|
||||
# 全屏透明遮罩(只负责背景展示,不负责关闭)
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint)
|
||||
|
||||
self.overlay = QtWidgets.QWidget(self)
|
||||
self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);")
|
||||
@@ -484,8 +465,7 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
body_layout.addWidget(self.sidebar)
|
||||
|
||||
self.content_stack = QtWidgets.QStackedWidget()
|
||||
self.provider_page = self._build_provider_section()
|
||||
self.content_stack.addWidget(self.provider_page)
|
||||
self.content_stack.addWidget(self._build_provider_section())
|
||||
self.content_stack.addWidget(self._build_placeholder_section())
|
||||
body_layout.addWidget(self.content_stack, 1)
|
||||
|
||||
@@ -493,24 +473,13 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
|
||||
self.resize_to_parent()
|
||||
self.content_stack.setCurrentWidget(self.content_stack.widget(0))
|
||||
if self._main is not None:
|
||||
self._main.installEventFilter(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def eventFilter(self, obj, event):
|
||||
"""阻止卡片点击穿透,并跟随主窗口几何/生命周期。"""
|
||||
"""🌟 核心:拦截 main_card 上的鼠标点击,防止冒泡到 overlay 触发关闭"""
|
||||
if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress:
|
||||
event.ignore() # 阻止冒泡
|
||||
return True
|
||||
if self._main is not None and obj is self._main:
|
||||
event_type = event.type()
|
||||
if event_type in (
|
||||
QtCore.QEvent.Type.Move,
|
||||
QtCore.QEvent.Type.Resize,
|
||||
QtCore.QEvent.Type.WindowStateChange):
|
||||
self.resize_to_parent()
|
||||
elif event_type in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide):
|
||||
self._close(immediate=True)
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -604,8 +573,6 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
"} "
|
||||
"QPushButton:hover { background-color: #1565c0; }"
|
||||
)
|
||||
add_btn.setObjectName("settings_add_provider")
|
||||
add_btn.clicked.connect(self._prompt_new_provider)
|
||||
header_layout.addWidget(add_btn)
|
||||
layout.addWidget(header)
|
||||
|
||||
@@ -623,8 +590,6 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
inner_layout.setSpacing(10)
|
||||
|
||||
providers = self.config_data.get("providers", {})
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
if not providers:
|
||||
empty = QtWidgets.QLabel("暂无配置")
|
||||
empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
|
||||
@@ -643,9 +608,7 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _build_provider_card(self, name: str, config: dict) -> QtWidgets.QWidget:
|
||||
config = config if isinstance(config, dict) else {}
|
||||
card = QtWidgets.QWidget()
|
||||
card.setProperty("providerName", name)
|
||||
card.setStyleSheet(
|
||||
"background-color: #fafafa; border-radius: 10px; "
|
||||
"border: 1px solid #e8e8e8;"
|
||||
@@ -672,9 +635,6 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
"} "
|
||||
"QPushButton:hover { background-color: #e0e0e0; color: #333; }"
|
||||
)
|
||||
edit_btn.setProperty("providerName", name)
|
||||
edit_btn.clicked.connect(
|
||||
lambda checked=False, provider=name: self._prompt_edit_provider(provider))
|
||||
row1.addWidget(edit_btn)
|
||||
|
||||
add_btn = QtWidgets.QPushButton("+模型")
|
||||
@@ -687,15 +647,11 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
"} "
|
||||
"QPushButton:hover { background-color: #bbdefb; }"
|
||||
)
|
||||
add_btn.setObjectName("settings_add_model")
|
||||
add_btn.setProperty("providerName", name)
|
||||
add_btn.clicked.connect(
|
||||
lambda checked=False, provider=name: self._prompt_add_model(provider))
|
||||
row1.addWidget(add_btn)
|
||||
layout.addLayout(row1)
|
||||
|
||||
# 🌟 第二行:模型垂直排列,加左缩进,不超出边框
|
||||
models = self._model_names(config)
|
||||
models = config.get("models", [])
|
||||
if models:
|
||||
models_wrap = QtWidgets.QWidget()
|
||||
models_layout = QtWidgets.QVBoxLayout(models_wrap)
|
||||
@@ -703,9 +659,7 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
models_layout.setSpacing(5)
|
||||
|
||||
for model_name in models:
|
||||
mbtn = QtWidgets.QPushButton(str(model_name))
|
||||
mbtn.setProperty("providerName", name)
|
||||
mbtn.setProperty("modelName", model_name)
|
||||
mbtn = QtWidgets.QPushButton(model_name)
|
||||
mbtn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开
|
||||
mbtn.setStyleSheet(
|
||||
@@ -718,8 +672,7 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
"QPushButton:hover { background-color: #d0ebff; }"
|
||||
)
|
||||
mbtn.clicked.connect(
|
||||
lambda checked=False, provider=name, model=model_name:
|
||||
self._select_model(provider, model)
|
||||
lambda checked, m=model_name: print(f"[设置] 选中模型: {m}")
|
||||
)
|
||||
models_layout.addWidget(mbtn)
|
||||
|
||||
@@ -733,192 +686,6 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _model_names(config: dict) -> list:
|
||||
"""兼容字符串、对象列表、映射及旧 model_contexts 配置。"""
|
||||
if not isinstance(config, dict):
|
||||
return []
|
||||
raw = config.get("models", [])
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
elif isinstance(raw, dict):
|
||||
raw = list(raw.keys())
|
||||
elif not isinstance(raw, (list, tuple)):
|
||||
raw = []
|
||||
|
||||
names = []
|
||||
for entry in raw:
|
||||
if isinstance(entry, str):
|
||||
name = entry.strip()
|
||||
elif isinstance(entry, dict):
|
||||
name = next((str(entry.get(key, "")).strip()
|
||||
for key in ("id", "name", "model", "value")
|
||||
if str(entry.get(key, "")).strip()), "")
|
||||
else:
|
||||
name = ""
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
|
||||
for key in ("model_contexts", "model_max_tokens"):
|
||||
mapping = config.get(key, {})
|
||||
if isinstance(mapping, dict):
|
||||
for name in mapping:
|
||||
name = str(name).strip()
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
def _refresh_provider_section(self):
|
||||
"""用当前内存配置重建供应商页,避免旧控件残留。"""
|
||||
old_page = self.provider_page
|
||||
index = self.content_stack.indexOf(old_page)
|
||||
new_page = self._build_provider_section()
|
||||
self.content_stack.insertWidget(max(0, index), new_page)
|
||||
self.provider_page = new_page
|
||||
self.content_stack.setCurrentWidget(new_page)
|
||||
self.content_stack.removeWidget(old_page)
|
||||
old_page.deleteLater()
|
||||
|
||||
def _commit_config(self, candidate: dict) -> bool:
|
||||
"""先原子落盘,成功后再更新界面和主窗口。"""
|
||||
from core.config_paths import save_config
|
||||
if not save_config(candidate):
|
||||
return False
|
||||
self.config_data = copy.deepcopy(candidate)
|
||||
self._refresh_provider_section()
|
||||
self.config_changed.emit(copy.deepcopy(self.config_data))
|
||||
return True
|
||||
|
||||
def _create_provider(self, name: str, base_url: str = "", api_key: str = "") -> bool:
|
||||
"""创建供应商;供按钮路径与自动化测试共用。"""
|
||||
provider_name = str(name).strip()
|
||||
if not provider_name:
|
||||
return False
|
||||
candidate = copy.deepcopy(self.config_data)
|
||||
providers = candidate.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
candidate["providers"] = providers
|
||||
if provider_name in providers:
|
||||
return False
|
||||
providers[provider_name] = {
|
||||
"api_key": str(api_key).strip(),
|
||||
"base_url": str(base_url).strip(),
|
||||
"models": [],
|
||||
"model_contexts": {},
|
||||
}
|
||||
return self._commit_config(candidate)
|
||||
|
||||
def _update_provider(self, name: str, base_url: str, api_key: str) -> bool:
|
||||
candidate = copy.deepcopy(self.config_data)
|
||||
providers = candidate.get("providers")
|
||||
info = providers.get(name) if isinstance(providers, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
return False
|
||||
info["base_url"] = str(base_url).strip()
|
||||
info["api_key"] = str(api_key).strip()
|
||||
return self._commit_config(candidate)
|
||||
|
||||
def _add_model(self, provider: str, model: str) -> bool:
|
||||
"""向供应商添加模型,并立即重建设置列表与主模型弹窗。"""
|
||||
model_name = str(model).strip()
|
||||
if not model_name:
|
||||
return False
|
||||
candidate = copy.deepcopy(self.config_data)
|
||||
providers = candidate.get("providers")
|
||||
info = providers.get(provider) if isinstance(providers, dict) else None
|
||||
if not isinstance(info, dict) or model_name in self._model_names(info):
|
||||
return False
|
||||
|
||||
raw = info.get("models")
|
||||
if isinstance(raw, list):
|
||||
raw.append(model_name)
|
||||
elif isinstance(raw, dict):
|
||||
raw[model_name] = {}
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
info["models"] = [raw.strip(), model_name]
|
||||
else:
|
||||
info["models"] = [model_name]
|
||||
|
||||
contexts = info.get("model_contexts")
|
||||
if not isinstance(contexts, dict):
|
||||
contexts = {}
|
||||
info["model_contexts"] = contexts
|
||||
contexts.setdefault(model_name, 128000)
|
||||
if not candidate.get("default_provider") or not candidate.get("default_model"):
|
||||
candidate["default_provider"] = provider
|
||||
candidate["default_model"] = model_name
|
||||
return self._commit_config(candidate)
|
||||
|
||||
def _select_model(self, provider: str, model: str):
|
||||
providers = self.config_data.get("providers", {})
|
||||
info = providers.get(provider) if isinstance(providers, dict) else None
|
||||
if model not in self._model_names(info):
|
||||
return
|
||||
candidate = copy.deepcopy(self.config_data)
|
||||
candidate["default_provider"] = provider
|
||||
candidate["default_model"] = model
|
||||
if self._commit_config(candidate):
|
||||
self.model_selected.emit(provider, model)
|
||||
|
||||
def _provider_dialog(self, title: str, name: str = "", config=None, editing=False):
|
||||
config = config if isinstance(config, dict) else {}
|
||||
dialog = QtWidgets.QDialog(self)
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setModal(True)
|
||||
dialog.setMinimumWidth(430)
|
||||
layout = QtWidgets.QFormLayout(dialog)
|
||||
layout.setContentsMargins(22, 18, 22, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
name_input = QtWidgets.QLineEdit(name)
|
||||
name_input.setReadOnly(editing)
|
||||
base_input = QtWidgets.QLineEdit(str(config.get("base_url", "")))
|
||||
base_input.setPlaceholderText("https://example.com/v1")
|
||||
key_input = QtWidgets.QLineEdit(str(config.get("api_key", "")))
|
||||
key_input.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
|
||||
layout.addRow("名称", name_input)
|
||||
layout.addRow("Base URL", base_input)
|
||||
layout.addRow("API Key", key_input)
|
||||
|
||||
buttons = QtWidgets.QDialogButtonBox(
|
||||
QtWidgets.QDialogButtonBox.StandardButton.Save
|
||||
| QtWidgets.QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
layout.addRow(buttons)
|
||||
name_input.setFocus()
|
||||
|
||||
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return None
|
||||
return name_input.text().strip(), base_input.text().strip(), key_input.text().strip()
|
||||
|
||||
def _prompt_new_provider(self):
|
||||
result = self._provider_dialog("新建模型提供商")
|
||||
if result is None:
|
||||
return
|
||||
if not self._create_provider(*result):
|
||||
QtWidgets.QMessageBox.warning(self, "无法创建", "提供商名称为空、已存在或配置保存失败。")
|
||||
|
||||
def _prompt_edit_provider(self, provider: str):
|
||||
providers = self.config_data.get("providers", {})
|
||||
info = providers.get(provider) if isinstance(providers, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
return
|
||||
result = self._provider_dialog(
|
||||
"编辑模型提供商", provider, info, editing=True)
|
||||
if result is None:
|
||||
return
|
||||
_, base_url, api_key = result
|
||||
if not self._update_provider(provider, base_url, api_key):
|
||||
QtWidgets.QMessageBox.warning(self, "保存失败", "未能保存提供商配置。")
|
||||
|
||||
def _prompt_add_model(self, provider: str):
|
||||
model, accepted = QtWidgets.QInputDialog.getText(
|
||||
self, "添加模型", f"{provider} 的模型名称:")
|
||||
if accepted and not self._add_model(provider, model):
|
||||
QtWidgets.QMessageBox.warning(self, "无法添加", "模型名称为空、已存在或配置保存失败。")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _build_placeholder_section(self) -> QtWidgets.QWidget:
|
||||
page = QtWidgets.QWidget()
|
||||
@@ -960,14 +727,11 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def resize_to_parent(self):
|
||||
if self._main is None:
|
||||
parent = self.parentWidget()
|
||||
if not parent:
|
||||
return
|
||||
|
||||
# SettingsWindow 是顶层 Tool 窗口,geometry 必须使用屏幕坐标;
|
||||
# 主窗口的 rect 则准确表示不含系统标题栏的客户区。
|
||||
top_left = self._main.mapToGlobal(self._main.rect().topLeft())
|
||||
self.setGeometry(QtCore.QRect(top_left, self._main.rect().size()))
|
||||
self.overlay.setGeometry(self.rect())
|
||||
self.setFixedSize(parent.size())
|
||||
self.overlay.setFixedSize(self.size())
|
||||
|
||||
W = int(self.width() * 0.8)
|
||||
H = int(self.height() * 0.88)
|
||||
@@ -979,66 +743,72 @@ class SettingsWindow(QtWidgets.QWidget):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _setup_animation(self):
|
||||
self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||||
self._anim.setDuration(160)
|
||||
self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity")
|
||||
self._anim.setDuration(500)
|
||||
self._anim.setStartValue(0.0)
|
||||
self._anim.setEndValue(1.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_with_animation(self):
|
||||
self.resize_to_parent()
|
||||
self.setWindowOpacity(0.0)
|
||||
# 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下
|
||||
self.setFixedSize(self.parentWidget().size())
|
||||
self.overlay.setFixedSize(self.size())
|
||||
self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect
|
||||
|
||||
# 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入
|
||||
self._anim.setStartValue(0.0)
|
||||
self._anim.setEndValue(1.0)
|
||||
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward)
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
self._anim.start()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _close(self, immediate=False):
|
||||
"""关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画"""
|
||||
if self._closed:
|
||||
return
|
||||
if immediate:
|
||||
self._do_close()
|
||||
else:
|
||||
if self._closing:
|
||||
return
|
||||
self._closing = True
|
||||
self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward)
|
||||
try:
|
||||
self._anim.finished.disconnect(self._on_close_finished)
|
||||
except TypeError:
|
||||
pass
|
||||
self._anim.finished.connect(self._on_close_finished)
|
||||
self._anim.start()
|
||||
|
||||
def _do_close(self):
|
||||
"""直接关闭,不播放动画"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self._anim.finished.disconnect(self._on_close_finished)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if self._main is not None:
|
||||
self._main.removeEventFilter(self)
|
||||
self.hide()
|
||||
parent = self.parent()
|
||||
if parent is not None and getattr(parent, "_settings_win", None) is self:
|
||||
parent._settings_win = None
|
||||
if parent:
|
||||
for attr_name in dir(parent):
|
||||
try:
|
||||
if getattr(parent, attr_name, None) is self:
|
||||
setattr(parent, attr_name, None)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self.deleteLater()
|
||||
self.close_requested.emit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _on_close_finished(self):
|
||||
self._do_close()
|
||||
self._anim.finished.disconnect(self._on_close_finished)
|
||||
self.hide()
|
||||
parent = self.parent()
|
||||
if parent:
|
||||
for attr_name in dir(parent):
|
||||
try:
|
||||
if getattr(parent, attr_name, None) is self:
|
||||
setattr(parent, attr_name, None)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self.deleteLater()
|
||||
self.close_requested.emit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def keyPressEvent(self, event):
|
||||
@@ -1588,7 +1358,7 @@ class SessionContextPopup(QtWidgets.QWidget):
|
||||
|
||||
self._anim_group.addAnimation(opacity_anim)
|
||||
self._anim_group.addAnimation(pos_anim)
|
||||
self._anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self._anim_group.start()
|
||||
|
||||
class RenameOverlay(QtWidgets.QWidget):
|
||||
"""无边框重命名 — 独立顶层透明窗口(P2-03 修复)
|
||||
@@ -1955,7 +1725,6 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
_p.end()
|
||||
self._model_icon = QtGui.QIcon(_pad)
|
||||
self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}]
|
||||
self._empty_item = None
|
||||
self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom}
|
||||
|
||||
self.setup_ui()
|
||||
@@ -2051,62 +1820,13 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
""")
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _model_names(info):
|
||||
"""兼容字符串、对象列表和以模型名为键的映射,返回去重后的模型名。"""
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
raw_models = info.get("models", [])
|
||||
if isinstance(raw_models, dict):
|
||||
raw_models = list(raw_models.keys())
|
||||
elif isinstance(raw_models, str):
|
||||
raw_models = [raw_models]
|
||||
elif not isinstance(raw_models, (list, tuple)):
|
||||
raw_models = []
|
||||
|
||||
names = []
|
||||
for raw in raw_models:
|
||||
name = raw.strip() if isinstance(raw, str) else ""
|
||||
if isinstance(raw, dict):
|
||||
for key in ("id", "name", "model", "value"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
name = value.strip()
|
||||
break
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
|
||||
# 某些旧配置只保存模型参数映射,没有显式 models 数组。
|
||||
for key in ("model_contexts", "model_max_tokens"):
|
||||
mapping = info.get(key)
|
||||
if not isinstance(mapping, dict):
|
||||
continue
|
||||
for name in mapping:
|
||||
if isinstance(name, str) and name.strip() and name.strip() not in names:
|
||||
names.append(name.strip())
|
||||
return names
|
||||
|
||||
def reload_config(self, config_data):
|
||||
"""按最新配置重建列表;配置缺失时仍保留可理解、可绘制的空状态。"""
|
||||
self._finish_drawer(preserve_top=True)
|
||||
self.config_data = config_data if isinstance(config_data, dict) else {}
|
||||
self.list_widget.clear()
|
||||
self._groups.clear()
|
||||
self._empty_item = None
|
||||
self.populate_data()
|
||||
self.adjust_popup_height()
|
||||
|
||||
def populate_data(self):
|
||||
providers = self.config_data.get("providers", {})
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
# 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认)
|
||||
self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE))
|
||||
|
||||
for provider_name, info in providers.items():
|
||||
if not isinstance(provider_name, str) or not provider_name.strip():
|
||||
continue
|
||||
models = self._model_names(info)
|
||||
models = info.get("models", [])
|
||||
if not models: continue
|
||||
|
||||
# --- 🆕 供应商分组头(整行可点:展开/收起抽屉) ---
|
||||
@@ -2202,38 +1922,6 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
"chevron": lbl_chevron,
|
||||
})
|
||||
|
||||
if self._groups:
|
||||
return
|
||||
|
||||
default_provider = self.config_data.get("default_provider")
|
||||
default_model = self.config_data.get("default_model")
|
||||
if isinstance(default_provider, str) and default_provider.strip() \
|
||||
and isinstance(default_model, str) and default_model.strip():
|
||||
self.config_data = dict(self.config_data)
|
||||
self.config_data["providers"] = {
|
||||
default_provider.strip(): {"models": [default_model.strip()]}
|
||||
}
|
||||
self.populate_data()
|
||||
return
|
||||
|
||||
self._empty_item = QtWidgets.QListWidgetItem("未配置可用模型")
|
||||
self._empty_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags)
|
||||
self._empty_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._empty_item.setForeground(QtGui.QColor("#8a94a6"))
|
||||
self._empty_item.setSizeHint(QtCore.QSize(0, 42))
|
||||
self.list_widget.addItem(self._empty_item)
|
||||
|
||||
def mark_selected(self, provider, model):
|
||||
"""打开弹窗时高亮当前模型,并确保滚动到可见位置。"""
|
||||
for group in self._groups:
|
||||
for item in group["model_items"]:
|
||||
if item.data(QtCore.Qt.ItemDataRole.UserRole) == (provider, model):
|
||||
self.list_widget.setCurrentItem(item)
|
||||
self.list_widget.scrollToItem(
|
||||
item, QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
return
|
||||
self.list_widget.clearSelection()
|
||||
|
||||
# ==================== 🆕 抽屉 v2:fade + 相邻供应商行滑移 ====================
|
||||
# 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体
|
||||
# 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点)
|
||||
@@ -2258,7 +1946,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural))
|
||||
|
||||
def toggle_group(self, provider_name: str):
|
||||
"""点击供应商头后立即应用稳定终态,避免列表子控件动画产生残影。"""
|
||||
"""点击供应商头 → 折叠/展开该供应商的模型组(🆕 抽屉 v2 动画)"""
|
||||
g = None
|
||||
for gg in self._groups:
|
||||
if gg["provider"] == provider_name:
|
||||
@@ -2267,18 +1955,17 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
if g is None:
|
||||
return
|
||||
# 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换)
|
||||
# 折叠/展开只改变高度时锁定当前顶部;否则按底边贴按钮会把
|
||||
# 用户正在看的内容整体向下挤。
|
||||
self._finish_drawer(preserve_top=True)
|
||||
self._finish_drawer()
|
||||
g["expanded"] = not g["expanded"]
|
||||
# SVG 箭头切换(展开=朝下 / 收起=朝右)
|
||||
g["chevron"].setPixmap(
|
||||
self._arrow_collapsed if not g["expanded"] else self._arrow_expanded)
|
||||
self._clear_fx()
|
||||
self._apply_group_state(g)
|
||||
self.list_widget.doItemsLayout()
|
||||
self._apply_height_and_position(preserve_top=True)
|
||||
self.list_widget.viewport().update()
|
||||
if not self.isVisible():
|
||||
# 防御:弹窗未显示时直接应用终态(不播动画)
|
||||
self._apply_group_state(g)
|
||||
self._apply_height_and_position()
|
||||
return
|
||||
self._start_drawer(g)
|
||||
|
||||
def _apply_group_state(self, g):
|
||||
"""应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)"""
|
||||
@@ -2417,16 +2104,12 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
if d["t"] >= 1.0:
|
||||
self._finish_drawer()
|
||||
|
||||
def _finish_drawer(self, preserve_top=False):
|
||||
def _finish_drawer(self):
|
||||
"""抽屉动画收敛到终态(动画完成 / 连点打断共用)"""
|
||||
d = self._drawer
|
||||
if d is None:
|
||||
return
|
||||
top_anchor = self.y() if preserve_top and self.isVisible() else None
|
||||
d["timer"].stop()
|
||||
# 每次动画都会创建一个 timer;停用后立即释放,避免反复折叠后
|
||||
# 大量已停止的 QObject 留在弹窗树中。
|
||||
d["timer"].deleteLater()
|
||||
self._drawer = None
|
||||
# 🆕 恢复滑移供应商头的透明背景(_start_drawer 里铺的白底)
|
||||
for it in d["after_items"]:
|
||||
@@ -2442,9 +2125,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
return
|
||||
parent = self.parentWidget()
|
||||
btn = getattr(parent, "model_selector", None) if parent else None
|
||||
if top_anchor is not None:
|
||||
self.move(self.x(), top_anchor)
|
||||
elif btn is not None:
|
||||
if btn is not None:
|
||||
# 真实 App:回到“按钮正上方右对齐”锚点
|
||||
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
|
||||
self.move(btn_pos.x() + btn.width() - self.width(),
|
||||
@@ -2453,9 +2134,8 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
# 无锚定按钮(如调参工具宿主):保持动画起点底缘不漂
|
||||
self.move(self.x(), d["bottom0"] - self.minimumHeight())
|
||||
|
||||
def _apply_height_and_position(self, preserve_top=False):
|
||||
"""重算高度;折叠时可锁定顶部,避免当前列表位置被挤走。"""
|
||||
top_anchor = self.y() if preserve_top and self.isVisible() else None
|
||||
def _apply_height_and_position(self):
|
||||
"""折叠/展开后:按可见项重算高度;若弹窗正显示中,保持“按钮正上方右对齐”锚点"""
|
||||
self.adjust_popup_height()
|
||||
if not self.isVisible():
|
||||
return
|
||||
@@ -2465,7 +2145,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
return
|
||||
btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0))
|
||||
x = btn_pos.x() + btn.width() - self.width()
|
||||
y = top_anchor if top_anchor is not None else btn_pos.y() - self.height() - 5
|
||||
y = btn_pos.y() - self.height() - 5
|
||||
self.move(x, y)
|
||||
|
||||
def adjust_popup_height(self):
|
||||
@@ -2514,7 +2194,7 @@ class ModelSelectPopup(QtWidgets.QWidget):
|
||||
|
||||
self.anim_group.addAnimation(self.opacity_anim)
|
||||
self.anim_group.addAnimation(self.pos_anim)
|
||||
self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.anim_group.start()
|
||||
|
||||
def on_item_clicked(self, item):
|
||||
data = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
@@ -2700,7 +2380,7 @@ class SessionModePopup(QtWidgets.QWidget):
|
||||
self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||||
self.anim_group.addAnimation(self.opacity_anim)
|
||||
self.anim_group.addAnimation(self.pos_anim)
|
||||
self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.anim_group.start()
|
||||
|
||||
|
||||
# ==================== 🌟 PDF 图片提取子线程工作者 ====================
|
||||
@@ -3084,7 +2764,7 @@ class PdfModePopup(QtWidgets.QWidget):
|
||||
ps.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad)
|
||||
self.anim_group.addAnimation(op)
|
||||
self.anim_group.addAnimation(ps)
|
||||
self.anim_group.start(QtCore.QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.anim_group.start()
|
||||
|
||||
# 关闭方式:依赖 Qt.Popup 原生行为——点击弹窗以外任意区域即关闭(与 ModelSelectPopup 一致),
|
||||
# 鼠标移出不再关闭。
|
||||
@@ -3312,29 +2992,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
|
||||
# 🌟 提前初始化(init_model_popup 内部会触发 update_context_display 用到)
|
||||
self._active_streams = {}
|
||||
self._stream_diagnostics = _stream_diagnostics_enabled()
|
||||
self._diag_chunk_n = 0
|
||||
self._diag_think_n = 0
|
||||
if self._stream_diagnostics:
|
||||
try:
|
||||
open(DIAG_LOG_PATH, "w").close()
|
||||
diag_log("APP_START")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
open(DIAG_LOG_PATH, "w").close()
|
||||
diag_log("APP_START")
|
||||
except Exception:
|
||||
pass
|
||||
# 🌟 渲染看门狗:若前端 rAF/定时器被浏览器节流(窗口隐藏/GPU 问题),
|
||||
# 由 Qt 侧定时器每 200ms 主动 forceRenderNow,保证流式正文一定上屏
|
||||
self._render_watchdog = QtCore.QTimer(self)
|
||||
self._render_watchdog.setInterval(200)
|
||||
self._render_watchdog.timeout.connect(self._render_watchdog_tick)
|
||||
self._render_watchdog.start()
|
||||
# 人肉 debug:JS console 桥抽取器(JS console.log → Python 控制台)。
|
||||
# 这是诊断通道,不应在普通运行中每 500ms 跨进程执行一次 JS。
|
||||
self._jslog_timer = None
|
||||
if self._stream_diagnostics:
|
||||
self._jslog_timer = QtCore.QTimer(self)
|
||||
self._jslog_timer.setInterval(500)
|
||||
self._jslog_timer.timeout.connect(self._jslog_drain_tick)
|
||||
self._jslog_timer.start()
|
||||
# 人肉 debug:JS console 桥抽取器(JS console.log → Python 控制台)
|
||||
self._jslog_timer = QtCore.QTimer(self)
|
||||
self._jslog_timer.setInterval(500)
|
||||
self._jslog_timer.timeout.connect(self._jslog_drain_tick)
|
||||
self._jslog_timer.start()
|
||||
# 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次)
|
||||
self._ctx_refresh_timer = QtCore.QTimer(self)
|
||||
self._ctx_refresh_timer.setSingleShot(True)
|
||||
@@ -5002,8 +4677,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
|
||||
if session_id == self.current_session_id:
|
||||
self.chat_bridge.finish_message(msg_id)
|
||||
if self._stream_diagnostics:
|
||||
self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped)
|
||||
self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped)
|
||||
print("\n[系统]:✅ 回复完毕。")
|
||||
else:
|
||||
print(f"\n[系统]: ✅ 会话 {session_id[:8]} 回复完毕(后台)")
|
||||
@@ -5019,27 +4693,21 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
f"usage_output={_du.get('output')} timeline={len(_tl0)}条")
|
||||
except Exception:
|
||||
pass
|
||||
# 完成时刻屏幕快照 + DOM 体检均为诊断操作;尤其是 WebView2 grab()
|
||||
# 会同步读回原生窗口,可能阻塞渲染器数百毫秒,普通运行绝不执行。
|
||||
if self._stream_diagnostics:
|
||||
if os.environ.get("HAOCODE_SHOT") == "1":
|
||||
try:
|
||||
self._shot_n = getattr(self, "_shot_n", 0) + 1
|
||||
import os as _os
|
||||
_shot_path = _os.path.join(
|
||||
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
|
||||
f"diag_shot_{self._shot_n:02d}_FINISH.png")
|
||||
self.browser.grab().save(_shot_path)
|
||||
print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True)
|
||||
except Exception as _e:
|
||||
print(f"[画面] 完成截图失败: {_e}", flush=True)
|
||||
|
||||
def _finish_dom_probe(_res):
|
||||
diag_log(f"FINISH_DOM {_res}")
|
||||
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True)
|
||||
|
||||
self.browser.page().runJavaScript(
|
||||
f"probeStream('{msg_id}')", _finish_dom_probe)
|
||||
# 完成时刻屏幕快照 + DOM 体检
|
||||
try:
|
||||
self._shot_n = getattr(self, "_shot_n", 0) + 1
|
||||
import os as _os
|
||||
_shot_path = _os.path.join(
|
||||
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
|
||||
f"diag_shot_{self._shot_n:02d}_FINISH.png")
|
||||
self.browser.grab().save(_shot_path)
|
||||
print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True)
|
||||
except Exception as _e:
|
||||
print(f"[画面] 完成截图失败: {_e}", flush=True)
|
||||
def _finish_dom_probe(_res):
|
||||
diag_log(f"FINISH_DOM {_res}")
|
||||
print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True)
|
||||
self.browser.page().runJavaScript(f"probeStream('{msg_id}')", _finish_dom_probe)
|
||||
# 🚀 入库,自动成为时间线新叶子!
|
||||
if (stream_state["content"] or stream_state["reasoning"]
|
||||
or stream_state.get("timeline")):
|
||||
@@ -5421,7 +5089,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
now = _t.time()
|
||||
if not hasattr(self, "_last_probe_at"):
|
||||
self._last_probe_at = 0
|
||||
if self._stream_diagnostics and now - self._last_probe_at >= 2.0:
|
||||
if now - self._last_probe_at >= 2.0:
|
||||
self._last_probe_at = now
|
||||
self.browser.page().runJavaScript(
|
||||
f"probeStream('{st['msg_id']}')", self._on_probe_result)
|
||||
@@ -5470,8 +5138,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
"""接收到 Token"""
|
||||
self._diag_chunk_n += 1
|
||||
_match = session_id == self.current_session_id
|
||||
if self._stream_diagnostics and (
|
||||
self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match):
|
||||
if self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match:
|
||||
_st0 = self._active_streams.get(session_id)
|
||||
diag_log(f"CHUNK n={self._diag_chunk_n} match={_match} "
|
||||
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
|
||||
@@ -5491,14 +5158,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
else:
|
||||
st["timeline"][-1]["text"] += chunk
|
||||
|
||||
# 人肉 debug:每个正文 token 控制台打印(仅显式诊断时开启)。
|
||||
if self._stream_diagnostics:
|
||||
try:
|
||||
_mid = self._active_streams[session_id]["msg_id"]
|
||||
_flat = " ".join(chunk.split())
|
||||
print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
# 人肉 debug:每个正文 token 控制台打印
|
||||
try:
|
||||
_mid = self._active_streams[session_id]["msg_id"]
|
||||
_flat = " ".join(chunk.split())
|
||||
print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 只有当前激活的会话才推送到前端显示
|
||||
if session_id == self.current_session_id:
|
||||
@@ -5780,13 +5446,6 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
from core.config_paths import load_config as _load_cfg
|
||||
self.config_data = _load_cfg() or {"providers": {}}
|
||||
|
||||
default_p = self.config_data.get("default_provider", "GPTBest")
|
||||
default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high")
|
||||
# 即使配置缺失,也让弹窗与按钮共享同一默认项,避免按钮有文字而列表为空。
|
||||
self.config_data = dict(self.config_data)
|
||||
self.config_data.setdefault("default_provider", default_p)
|
||||
self.config_data.setdefault("default_model", default_m)
|
||||
|
||||
# 2. 实例化我们写的自定义弹窗 (先不显示)
|
||||
self.model_popup = ModelSelectPopup(self, self.config_data)
|
||||
|
||||
@@ -5799,34 +5458,19 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
# 5. 设置默认选中
|
||||
default_p = self.config_data.get("default_provider", "GPTBest")
|
||||
default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high")
|
||||
self.on_model_selected(default_p, default_m)
|
||||
|
||||
def show_model_popup(self):
|
||||
"""刷新模型列表,在当前屏幕内计算位置并显示。"""
|
||||
from core.config_paths import load_config as _load_cfg
|
||||
latest_config = _load_cfg() or {}
|
||||
latest_config = dict(latest_config)
|
||||
latest_config.setdefault("default_provider", self.current_provider or "GPTBest")
|
||||
latest_config.setdefault(
|
||||
"default_model", self.current_model or "gemini-3.1-pro-preview-thinking-high")
|
||||
self.config_data = latest_config
|
||||
self.model_popup.reload_config(latest_config)
|
||||
self.model_popup.mark_selected(self.current_provider, self.current_model)
|
||||
|
||||
"""计算位置并执行动画弹出"""
|
||||
# 获取按钮在屏幕上的全局坐标
|
||||
btn_pos = self.model_selector.mapToGlobal(QtCore.QPoint(0, 0))
|
||||
|
||||
# 优先放在按钮上方并右对齐;空间不足时放到下方,最终钳制在当前屏幕内。
|
||||
|
||||
# 计算弹窗最终应该停留的目标坐标
|
||||
x = btn_pos.x() + self.model_selector.width() - self.model_popup.width()
|
||||
y = btn_pos.y() - self.model_popup.height() - 5
|
||||
screen = QtGui.QGuiApplication.screenAt(btn_pos) or self.screen()
|
||||
if screen is not None:
|
||||
area = screen.availableGeometry()
|
||||
x = max(area.left(), min(x, area.right() - self.model_popup.width() + 1))
|
||||
if y < area.top():
|
||||
y = btn_pos.y() + self.model_selector.height() + 5
|
||||
y = max(area.top(), min(y, area.bottom() - self.model_popup.height() + 1))
|
||||
|
||||
|
||||
# 🌟 调用动画显示方法,传入目标坐标
|
||||
self.model_popup.show_with_animation(QtCore.QPoint(x, y))
|
||||
|
||||
@@ -5845,22 +5489,11 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
#新增:切换模型后刷新上下文显示(因为不同模型上限不同)
|
||||
self.update_context_display()
|
||||
|
||||
def _on_settings_config_changed(self, config_data):
|
||||
"""设置页保存后同步刷新主窗口和模型弹窗。"""
|
||||
if not isinstance(config_data, dict):
|
||||
return
|
||||
self.config_data = copy.deepcopy(config_data)
|
||||
self.model_popup.reload_config(self.config_data)
|
||||
self.model_popup.mark_selected(
|
||||
self.current_provider, self.current_model)
|
||||
self.update_context_display()
|
||||
|
||||
def on_reasoning_received(self, session_id, chunk: str):
|
||||
"""接收到思考过程"""
|
||||
self._diag_think_n += 1
|
||||
_match = session_id == self.current_session_id
|
||||
if self._stream_diagnostics and (
|
||||
self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match):
|
||||
if self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match:
|
||||
_st0 = self._active_streams.get(session_id)
|
||||
diag_log(f"THINK n={self._diag_think_n} match={_match} "
|
||||
f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c")
|
||||
@@ -5880,14 +5513,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
else:
|
||||
st["timeline"][-1]["text"] += chunk
|
||||
|
||||
# 人肉 debug:每个思考 token 控制台打印(仅显式诊断时开启)。
|
||||
if self._stream_diagnostics:
|
||||
try:
|
||||
_mid = self._active_streams[session_id]["msg_id"]
|
||||
_flat = " ".join(chunk.split())
|
||||
print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
# 人肉 debug:每个思考 token 控制台打印
|
||||
try:
|
||||
_mid = self._active_streams[session_id]["msg_id"]
|
||||
_flat = " ".join(chunk.split())
|
||||
print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 只有当前激活的会话才推送到前端显示
|
||||
if session_id == self.current_session_id:
|
||||
@@ -5934,9 +5566,6 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
"""JS 就绪检测的回调"""
|
||||
if is_ready:
|
||||
print("[System]:✅ JS 引擎已就绪,正在加载历史记录...")
|
||||
if self._stream_diagnostics:
|
||||
self.chat_bridge.run_js(
|
||||
"if (window.setStreamDiagnostics) window.setStreamDiagnostics(true);")
|
||||
# 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载
|
||||
if not self._rw_config_pushed:
|
||||
self._rw_config_pushed = True
|
||||
@@ -6477,20 +6106,14 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
return widget
|
||||
|
||||
def show_settings(self):
|
||||
existing = getattr(self, '_settings_win', None)
|
||||
if existing is not None:
|
||||
existing._close(immediate=True)
|
||||
|
||||
if hasattr(self, "model_popup"):
|
||||
self.model_popup.hide()
|
||||
if hasattr(self, '_settings_win') and self._settings_win is not None:
|
||||
self._settings_win.deleteLater()
|
||||
self._settings_win = None
|
||||
|
||||
self._settings_win = SettingsWindow(
|
||||
parent=self,
|
||||
config_data=self.config_data
|
||||
)
|
||||
self._settings_win.config_changed.connect(
|
||||
self._on_settings_config_changed)
|
||||
self._settings_win.model_selected.connect(self.on_model_selected)
|
||||
self._settings_win.show_with_animation()
|
||||
def _on_branch_switch(self, msg_id, direction):
|
||||
"""🌟 核心:处理用户点击左右箭头切换分支"""
|
||||
|
||||
@@ -74,44 +74,13 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
@staticmethod
|
||||
def _scale_rect(rect, logical_size, native_size):
|
||||
"""把 Qt 逻辑坐标选区映射到抓屏位图的原生像素坐标。"""
|
||||
if (logical_size.width() <= 0 or logical_size.height() <= 0
|
||||
or native_size.width() <= 0 or native_size.height() <= 0):
|
||||
return QtCore.QRect()
|
||||
|
||||
logical_bounds = QtCore.QRect(QtCore.QPoint(), logical_size)
|
||||
clipped = rect.normalized().intersected(logical_bounds)
|
||||
if clipped.isEmpty():
|
||||
return QtCore.QRect()
|
||||
|
||||
scale_x = native_size.width() / logical_size.width()
|
||||
scale_y = native_size.height() / logical_size.height()
|
||||
left = round(clipped.x() * scale_x)
|
||||
top = round(clipped.y() * scale_y)
|
||||
right = round((clipped.x() + clipped.width()) * scale_x)
|
||||
bottom = round((clipped.y() + clipped.height()) * scale_y)
|
||||
|
||||
left = max(0, min(native_size.width(), left))
|
||||
top = max(0, min(native_size.height(), top))
|
||||
right = max(left, min(native_size.width(), right))
|
||||
bottom = max(top, min(native_size.height(), bottom))
|
||||
return QtCore.QRect(left, top, right - left, bottom - top)
|
||||
|
||||
def _native_rect(self, rect):
|
||||
"""返回选区在当前抓屏位图中的原生像素矩形。"""
|
||||
if self._full_pixmap is None or self._full_pixmap.isNull():
|
||||
return QtCore.QRect()
|
||||
return self._scale_rect(rect, self.size(), self._full_pixmap.size())
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self._full_pixmap:
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
|
||||
# 1. 绘制屏幕截图作为背景
|
||||
painter.drawPixmap(self.rect(), self._full_pixmap, self._full_pixmap.rect())
|
||||
painter.drawPixmap(0, 0, self._full_pixmap)
|
||||
# 2. 半透明遮罩
|
||||
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100))
|
||||
|
||||
@@ -123,17 +92,15 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
|
||||
rect = self._current_rect
|
||||
|
||||
if rect.width() > 0 and rect.height() > 0:
|
||||
native_rect = self._native_rect(rect)
|
||||
# 3. 选区内重绘原图(去掉遮罩,形成高亮效果)
|
||||
painter.drawPixmap(rect, self._full_pixmap, native_rect)
|
||||
painter.drawPixmap(rect, self._full_pixmap, rect)
|
||||
# 4. 蓝色边框
|
||||
pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(rect)
|
||||
# 5. 尺寸标注
|
||||
# 标签显示最终文件的真实像素,而不是受系统缩放影响的逻辑尺寸。
|
||||
size_text = f"{native_rect.width()} x {native_rect.height()} px"
|
||||
size_text = f"{rect.width()} x {rect.height()}"
|
||||
font = painter.font()
|
||||
font.setPointSize(9)
|
||||
painter.setFont(font)
|
||||
@@ -210,12 +177,15 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
|
||||
def _on_confirm(self):
|
||||
"""确认截图:裁剪并发射信号"""
|
||||
if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5:
|
||||
native_rect = self._native_rect(self._current_rect)
|
||||
if not native_rect.isEmpty():
|
||||
captured = self._full_pixmap.toImage().copy(native_rect)
|
||||
# 截图是普通位图;避免下游再次按桌面 DPR 缩小显示。
|
||||
captured.setDevicePixelRatio(1.0)
|
||||
self.screenshot_captured.emit(captured)
|
||||
dpr = self._full_pixmap.devicePixelRatio()
|
||||
phys_rect = QtCore.QRect(
|
||||
int(self._current_rect.x() * dpr),
|
||||
int(self._current_rect.y() * dpr),
|
||||
int(self._current_rect.width() * dpr),
|
||||
int(self._current_rect.height() * dpr),
|
||||
)
|
||||
captured = self._full_pixmap.toImage().copy(phys_rect)
|
||||
self.screenshot_captured.emit(captured)
|
||||
self.close()
|
||||
|
||||
def _on_cancel(self):
|
||||
|
||||
+3
-16
@@ -152,32 +152,19 @@ class WebView2View(QtWidgets.QWidget):
|
||||
# ---------- 几何同步 ----------
|
||||
def _dpr(self):
|
||||
try:
|
||||
d = float(self.devicePixelRatioF())
|
||||
d = self.dpr()
|
||||
return d if d > 0 else 1.0
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
@staticmethod
|
||||
def _physical_bounds(x, y, width, height, dpr):
|
||||
"""按矩形边界换算物理像素,避免分数 DPR 产生相邻白缝。"""
|
||||
def pixel(value):
|
||||
scaled = value * dpr
|
||||
return int(scaled + 0.5) if scaled >= 0 else int(scaled - 0.5)
|
||||
|
||||
left = pixel(x)
|
||||
top = pixel(y)
|
||||
right = pixel(x + width)
|
||||
bottom = pixel(y + height)
|
||||
return left, top, max(1, right - left), max(1, bottom - top)
|
||||
|
||||
def sync_bounds(self):
|
||||
if not self.isVisible() or not self._session.child_hwnd:
|
||||
return
|
||||
top = self.window()
|
||||
p = self.mapTo(top, QtCore.QPoint(0, 0))
|
||||
d = self._dpr()
|
||||
L, T, W, H = self._physical_bounds(
|
||||
p.x(), p.y(), self.width(), self.height(), d)
|
||||
L, T = p.x() * d, p.y() * d
|
||||
W, H = max(1, self.width() * d), max(1, self.height() * d)
|
||||
# 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏)
|
||||
# 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 ——
|
||||
# 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上;
|
||||
|
||||
+23
-75
@@ -31,40 +31,23 @@
|
||||
} catch (e) {}
|
||||
})();
|
||||
window.__APP_VER = '20260721-v7';
|
||||
// 流式逐 token 诊断默认关闭。逐 token 读取 DOM 高度/文本并写 console 会
|
||||
// 触发强制布局和跨进程日志传输,长时间运行会明显拖慢渲染器;需要排查时
|
||||
// 由 Python 显式调用 setStreamDiagnostics(true) 打开。
|
||||
window.__STREAM_DIAG = false;
|
||||
window.setStreamDiagnostics = function(enabled) {
|
||||
window.__STREAM_DIAG = !!enabled;
|
||||
if (window.__STREAM_DIAG && window.__startStreamHeartbeat) {
|
||||
window.__startStreamHeartbeat();
|
||||
}
|
||||
};
|
||||
console.log('[JS] ===== app.js 加载 ver=20260721-v7 =====');
|
||||
// 公式渲染依赖本地 KaTeX(离线);此处确认资源加载结果,缺失时打印告警便于定位
|
||||
console.log('[JS] KaTeX ' + (typeof katex !== 'undefined' ? katex.version + ' 就绪' : '缺失(公式将退化为纯文本)'));
|
||||
// 渲染器主线程心跳:dt 异常大 = 渲染器被阻塞(截图/重绘/GPU 等)
|
||||
(function() {
|
||||
var _hbLast = Date.now();
|
||||
var _started = false;
|
||||
function tick() {
|
||||
setInterval(function() {
|
||||
var _now = Date.now();
|
||||
var _dt = _now - _hbLast;
|
||||
_hbLast = _now;
|
||||
if (_dt >= 1500) {
|
||||
console.log('[JS] 心跳 dt=' + _dt + 'ms (渲染器主线程曾卡顿)');
|
||||
}
|
||||
}
|
||||
window.__startStreamHeartbeat = function() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
setInterval(tick, 1000);
|
||||
};
|
||||
}, 1000);
|
||||
})();
|
||||
window.__diag = { events: [], cap: 300, tokenN: 0, thinkN: 0 };
|
||||
function diagEvent(name, extra) {
|
||||
if (!window.__STREAM_DIAG) return;
|
||||
try {
|
||||
var d = window.__diag;
|
||||
d.events.push({ t: Date.now() % 1000000, e: name, x: extra });
|
||||
@@ -766,8 +749,7 @@ function createMessage(msgId, role, initialText, senderName, branchInfo) {
|
||||
if (role === 'user') {
|
||||
replyDiv.innerText = initialText;
|
||||
} else {
|
||||
// 🆕 助手纯文本消息(无时间线历史路径)也包进 md-segment,
|
||||
// 与时间线路径保持相同的透明正文布局
|
||||
// 🆕 助手纯文本消息(无时间线历史路径)也包进 md-segment → 与时间线路径同样有浅灰气泡背景
|
||||
var _tHtml = typeof marked !== 'undefined' ? safeHtml(marked.parse(initialText)) : safeHtml(initialText);
|
||||
replyDiv.innerHTML = '<div class="md-segment markdown-body">' + _tHtml + '</div>';
|
||||
}
|
||||
@@ -1226,8 +1208,7 @@ function syncRenderThrottled(msgId, minGapMs) {
|
||||
// Qt 看门狗入口:强制渲染(幂等,尾部无变化时开销极小)
|
||||
function forceRenderNow(msgId) {
|
||||
var _frNow = Date.now();
|
||||
if (window.__STREAM_DIAG &&
|
||||
(!window.__frLast || _frNow - window.__frLast > 1000)) {
|
||||
if (!window.__frLast || _frNow - window.__frLast > 1000) {
|
||||
window.__frLast = _frNow;
|
||||
console.log('[JS] forceRenderNow id=' + msgId + ' (看门狗)');
|
||||
}
|
||||
@@ -1255,7 +1236,7 @@ function _fullRenderSegment(el, c, isThink) {
|
||||
}
|
||||
function doStreamingRender(msgId) {
|
||||
window.__renderN = (window.__renderN || 0) + 1;
|
||||
if (window.__STREAM_DIAG && window.__renderN % 10 === 1) {
|
||||
if (window.__renderN % 10 === 1) {
|
||||
console.log('[JS] render#' + window.__renderN + ' 开始 id=' + msgId);
|
||||
}
|
||||
var buf = messageBuffer[msgId];
|
||||
@@ -1307,11 +1288,9 @@ function appendReasoning(msgId, token) {
|
||||
buf.reasoning += token;
|
||||
var wrapper = document.getElementById(msgId);
|
||||
if (!wrapper) { diagEvent('appendReasoning', 'NO_WRAPPER:' + msgId); return; }
|
||||
if (window.__STREAM_DIAG) {
|
||||
window.__diag.thinkN++;
|
||||
if (window.__diag.thinkN === 1 || window.__diag.thinkN % 25 === 0) {
|
||||
diagEvent('appendReasoning', 'n=' + window.__diag.thinkN);
|
||||
}
|
||||
window.__diag.thinkN++;
|
||||
if (window.__diag.thinkN === 1 || window.__diag.thinkN % 25 === 0) {
|
||||
diagEvent('appendReasoning', 'n=' + window.__diag.thinkN);
|
||||
}
|
||||
wrapper.classList.add('streaming');
|
||||
var tl = ensureTimeline(wrapper, buf);
|
||||
@@ -1329,11 +1308,7 @@ function appendReasoning(msgId, token) {
|
||||
buf.thinkSegs.push(tc);
|
||||
}
|
||||
tc.__buf = (tc.__buf || '') + token;
|
||||
if (window.__STREAM_DIAG) {
|
||||
console.log('[JS] think#' + window.__diag.thinkN +
|
||||
' 段buf=' + tc.__buf.length + 'c dom=' +
|
||||
(tc.textContent || '').length + 'c h=' + tc.offsetHeight);
|
||||
}
|
||||
console.log('[JS] think#' + window.__diag.thinkN + ' 段buf=' + tc.__buf.length + 'c dom=' + (tc.textContent || '').length + 'c h=' + tc.offsetHeight);
|
||||
syncRenderThrottled(msgId); // ★ 同步通道
|
||||
scheduleStreamingRender(msgId);
|
||||
}
|
||||
@@ -1344,11 +1319,9 @@ function appendToken(msgId, token) {
|
||||
buf.content += token;
|
||||
var wrapper = document.getElementById(msgId);
|
||||
if (!wrapper) { diagEvent('appendToken', 'NO_WRAPPER:' + msgId); return; }
|
||||
if (window.__STREAM_DIAG) {
|
||||
window.__diag.tokenN++;
|
||||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||||
diagEvent('appendToken', 'n=' + window.__diag.tokenN);
|
||||
}
|
||||
window.__diag.tokenN++;
|
||||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||||
diagEvent('appendToken', 'n=' + window.__diag.tokenN);
|
||||
}
|
||||
wrapper.classList.add('streaming');
|
||||
var tl = ensureTimeline(wrapper, buf);
|
||||
@@ -1372,14 +1345,9 @@ function appendToken(msgId, token) {
|
||||
} else {
|
||||
seg.__buf = (seg.__buf || '') + token;
|
||||
}
|
||||
if (window.__STREAM_DIAG) {
|
||||
console.log('[JS] token#' + window.__diag.tokenN +
|
||||
' 段buf=' + seg.__buf.length + 'c dom=' +
|
||||
(seg.textContent || '').length + 'c 段数=' +
|
||||
buf.textSegs.length + ' h=' + seg.offsetHeight);
|
||||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||||
diagEvent('tokenDOM', { buf: seg.__buf.length, dom: (seg.textContent || '').length });
|
||||
}
|
||||
console.log('[JS] token#' + window.__diag.tokenN + ' 段buf=' + seg.__buf.length + 'c dom=' + (seg.textContent || '').length + 'c 段数=' + buf.textSegs.length + ' h=' + seg.offsetHeight);
|
||||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||||
diagEvent('tokenDOM', { buf: seg.__buf.length, dom: (seg.textContent || '').length });
|
||||
}
|
||||
syncRenderThrottled(msgId); // ★ 同步通道:token 到 → 内容必现
|
||||
scheduleStreamingRender(msgId); // rAF 通道:更平滑(环境允许时)
|
||||
@@ -1389,12 +1357,7 @@ function appendToken(msgId, token) {
|
||||
function finishMessage(msgId) {
|
||||
console.log('[JS] finishMessage id=' + msgId);
|
||||
var wrapper = document.getElementById(msgId);
|
||||
if (!wrapper) {
|
||||
delete messageBuffer[msgId];
|
||||
delete __lastSyncRender[msgId];
|
||||
diagEvent('finish', 'NO_WRAPPER:' + msgId);
|
||||
return;
|
||||
}
|
||||
if (!wrapper) { delete messageBuffer[msgId]; diagEvent('finish', 'NO_WRAPPER:' + msgId); return; }
|
||||
diagEvent('finish', { id: msgId });
|
||||
var buf = messageBuffer[msgId];
|
||||
cancelStreamingRender(msgId);
|
||||
@@ -1477,7 +1440,6 @@ function finishMessage(msgId) {
|
||||
if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; }
|
||||
}
|
||||
delete messageBuffer[msgId];
|
||||
delete __lastSyncRender[msgId];
|
||||
|
||||
// --- H. 刷新自定义滚动条 ---
|
||||
reportWebScroll();
|
||||
@@ -1978,7 +1940,6 @@ function renderTimelineHistory(msgId, timelineJson) {
|
||||
// 删除 buffer → finishMessage 不再全量重渲(避免破坏时间线 DOM),
|
||||
// 但仍会执行代码高亮/滚动等收尾
|
||||
delete messageBuffer[msgId];
|
||||
delete __lastSyncRender[msgId];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[JS]: renderTimelineHistory 解析失败', e);
|
||||
@@ -2102,23 +2063,6 @@ function hideLoadingOverlay() {
|
||||
setTimeout(function() { el.style.display = 'none'; }, 480);
|
||||
}
|
||||
|
||||
// 消息从当前窗口移除时一并释放附件缓存。附件使用 msgId-att-N 作为键,
|
||||
// 只删除父消息键会让长文本和元数据跨会话持续留在内存中。
|
||||
function clearMessageStores(msgId) {
|
||||
if (!msgId) return;
|
||||
delete messageBuffer[msgId];
|
||||
delete __lastSyncRender[msgId];
|
||||
delete longTextStore[msgId];
|
||||
delete finalContentStore[msgId];
|
||||
delete attachmentMetaStore[msgId];
|
||||
var prefix = msgId + '-att-';
|
||||
[longTextStore, attachmentMetaStore].forEach(function(store) {
|
||||
Object.keys(store).forEach(function(key) {
|
||||
if (key.indexOf(prefix) === 0) delete store[key];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 历史记录与视图控制 ====================
|
||||
function clearChat() {
|
||||
var bubbles = chatContainer.querySelectorAll('.message-wrapper, .system-note');
|
||||
@@ -2129,7 +2073,6 @@ function clearChat() {
|
||||
longTextStore = {};
|
||||
finalContentStore = {};
|
||||
attachmentMetaStore = {};
|
||||
__lastSyncRender = {};
|
||||
|
||||
// 🆕 P1-01:清渲染窗口状态机(游标/缓存/未决请求/代次;配置模式与大小保留)
|
||||
if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') {
|
||||
@@ -2150,7 +2093,9 @@ function deleteMessage(msgId) {
|
||||
wrapper.style.transform = "translateY(-10px)";
|
||||
setTimeout(function() { wrapper.remove(); }, 300);
|
||||
}
|
||||
clearMessageStores(msgId);
|
||||
delete messageBuffer[msgId];
|
||||
delete longTextStore[msgId];
|
||||
delete finalContentStore[msgId];
|
||||
setTimeout(reportWebScroll, 350);
|
||||
}
|
||||
|
||||
@@ -2322,7 +2267,10 @@ function rwCaptureAnchor() {
|
||||
function rwRemoveMessageDom(msgId) {
|
||||
var wrapper = document.getElementById(msgId);
|
||||
if (wrapper) wrapper.remove();
|
||||
clearMessageStores(msgId);
|
||||
delete messageBuffer[msgId];
|
||||
delete longTextStore[msgId];
|
||||
delete finalContentStore[msgId];
|
||||
delete attachmentMetaStore[msgId];
|
||||
}
|
||||
|
||||
/* 加载入口:固定在 chat-container 首/尾;无更多消息时隐藏。 */
|
||||
|
||||
+5
-11
@@ -157,7 +157,7 @@ body, html {
|
||||
width: 85%;
|
||||
}
|
||||
/* 🆕 内层同步撑满:.reply-content 基类是 fit-content(贴内容缩),
|
||||
可见内容块(正文段/思考卡/工具卡)都在它里面 → 必须 100% 才真正恒定 */
|
||||
可见气泡(md-segment 背景/思考卡/工具卡)都在它里面 → 必须 100% 才真正恒定 */
|
||||
.assistant .reply-content { width: 100%; }
|
||||
.user .message-content { align-items: flex-end; }
|
||||
|
||||
@@ -893,16 +893,10 @@ body, html {
|
||||
}
|
||||
|
||||
|
||||
/* ========== 助手正文(流式中/完成后一致,保持透明) ========== */
|
||||
.message-wrapper.assistant .reply-content .md-segment {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
color: #30343b;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* ========== 助手正文透明气泡(流式中/完成后一致) ==========
|
||||
🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */
|
||||
/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确:
|
||||
原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */
|
||||
.message-wrapper.assistant .reply-content .md-segment + .md-segment {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user