diff --git a/core/config_paths.py b/core/config_paths.py index f745429..c04d4c6 100644 --- a/core/config_paths.py +++ b/core/config_paths.py @@ -14,6 +14,7 @@ """ import json import os +import tempfile ENV_KEY = "HAOCODE_CONFIG_FILE" @@ -47,6 +48,37 @@ 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 侧守卫保持一致): diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/_test_env.py b/tests/_test_env.py deleted file mode 100644 index a9ed604..0000000 --- a/tests/_test_env.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- 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} diff --git a/tests/check_db_migration.py b/tests/check_db_migration.py deleted file mode 100644 index 8ab2e52..0000000 --- a/tests/check_db_migration.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- -"""用「备份 ZIP 里的真实旧库」验证:迁移新列后旧数据必须 100% 原样保留(只读对比)。 - -用法: python tests/check_db_migration.py -""" -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) diff --git a/tests/debug_inject.py b/tests/debug_inject.py deleted file mode 100644 index d25b266..0000000 --- a/tests/debug_inject.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- 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()) diff --git a/tests/diag_live_agent.py b/tests/diag_live_agent.py deleted file mode 100644 index eaedb90..0000000 --- a/tests/diag_live_agent.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- 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) diff --git a/tests/diag_live_onscreen.py b/tests/diag_live_onscreen.py deleted file mode 100644 index afddc14..0000000 --- a/tests/diag_live_onscreen.py +++ /dev/null @@ -1,186 +0,0 @@ -# -*- 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() diff --git a/tests/diag_live_text.py b/tests/diag_live_text.py deleted file mode 100644 index 4c79a58..0000000 --- a/tests/diag_live_text.py +++ /dev/null @@ -1,187 +0,0 @@ -# -*- 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() diff --git a/tests/diag_panel_scrollbar.py b/tests/diag_panel_scrollbar.py deleted file mode 100644 index 4d657c5..0000000 --- a/tests/diag_panel_scrollbar.py +++ /dev/null @@ -1,209 +0,0 @@ -# -*- 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) diff --git a/tests/diag_rename_overlay.py b/tests/diag_rename_overlay.py deleted file mode 100644 index 677acf2..0000000 --- a/tests/diag_rename_overlay.py +++ /dev/null @@ -1,262 +0,0 @@ -# -*- 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) diff --git a/tests/diag_render_scale.py b/tests/diag_render_scale.py deleted file mode 100644 index 835f940..0000000 --- a/tests/diag_render_scale.py +++ /dev/null @@ -1,336 +0,0 @@ -# -*- 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) diff --git a/tests/inject_math_demo.py b/tests/inject_math_demo.py deleted file mode 100644 index 6892866..0000000 --- a/tests/inject_math_demo.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- 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()) diff --git a/tests/run_all.py b/tests/run_all.py deleted file mode 100644 index a878e22..0000000 --- a/tests/run_all.py +++ /dev/null @@ -1,265 +0,0 @@ -# -*- 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() diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100644 index fa934e9..0000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,33 +0,0 @@ -"""离线测试 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) diff --git a/tests/smoke_bash_panel.py b/tests/smoke_bash_panel.py deleted file mode 100644 index 5bb4974..0000000 --- a/tests/smoke_bash_panel.py +++ /dev/null @@ -1,737 +0,0 @@ -# -*- 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) diff --git a/tests/smoke_copy_session.py b/tests/smoke_copy_session.py deleted file mode 100644 index 9652ca5..0000000 --- a/tests/smoke_copy_session.py +++ /dev/null @@ -1,156 +0,0 @@ -"""会话复制 · 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) diff --git a/tests/smoke_live_guard.py b/tests/smoke_live_guard.py deleted file mode 100644 index b8228e6..0000000 --- a/tests/smoke_live_guard.py +++ /dev/null @@ -1,280 +0,0 @@ -# -*- 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() diff --git a/tests/smoke_manual.py b/tests/smoke_manual.py deleted file mode 100644 index e1fd0f4..0000000 --- a/tests/smoke_manual.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- 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] 打印)=====") diff --git a/tests/smoke_midswitch.py b/tests/smoke_midswitch.py deleted file mode 100644 index b915b04..0000000 --- a/tests/smoke_midswitch.py +++ /dev/null @@ -1,203 +0,0 @@ -# -*- 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() diff --git a/tests/smoke_mode.py b/tests/smoke_mode.py deleted file mode 100644 index cc07655..0000000 --- a/tests/smoke_mode.py +++ /dev/null @@ -1,94 +0,0 @@ -"""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) diff --git a/tests/smoke_offscreen.py b/tests/smoke_offscreen.py deleted file mode 100644 index 2e77586..0000000 --- a/tests/smoke_offscreen.py +++ /dev/null @@ -1,206 +0,0 @@ -"""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) diff --git a/tests/smoke_persist.py b/tests/smoke_persist.py deleted file mode 100644 index 17e1e6c..0000000 --- a/tests/smoke_persist.py +++ /dev/null @@ -1,297 +0,0 @@ -# -*- 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() diff --git a/tests/smoke_probe.py b/tests/smoke_probe.py deleted file mode 100644 index 34477c6..0000000 --- a/tests/smoke_probe.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- 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) diff --git a/tests/smoke_repro_real.py b/tests/smoke_repro_real.py deleted file mode 100644 index 5664ee9..0000000 --- a/tests/smoke_repro_real.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- 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) diff --git a/tests/smoke_timeline.py b/tests/smoke_timeline.py deleted file mode 100644 index 0e6f087..0000000 --- a/tests/smoke_timeline.py +++ /dev/null @@ -1,195 +0,0 @@ -# -*- 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() diff --git a/tests/test_agent_core.py b/tests/test_agent_core.py deleted file mode 100644 index ca47c81..0000000 --- a/tests/test_agent_core.py +++ /dev/null @@ -1,1323 +0,0 @@ -""" -tests/test_agent_core.py -======================== -离线确定性测试:用脚本化 mock 流函数验证 core/agent 的 pi 1:1 语义。 -运行:conda run -n haocode python -m pytest tests/test_agent_core.py -v -(无网络、无 Qt 依赖) -""" -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -# P0-01:provider 用例经统一配置入口读取临时配置(测试 provider),不碰真实 data/config.json -from tests._test_env import isolate # noqa: E402 -isolate("agentcore") - -import pytest - -from core.agent import (Agent, AgentConfig, AgentError, ModelConfig, RetryConfig) -from core.agent.compaction import ( - compact_context, find_cut_point, find_turn_start, find_valid_cut_points, - prepare_compaction, serialize_conversation, format_file_operations, - FileOperations, extract_file_ops_from_message, compute_file_lists, -) -from core.agent.context import (clamp_max_tokens_to_context, should_compact, - estimate_context_tokens) -from core.agent.recovery import AgentRunner -from core.agent.stream_fn import to_openai_messages, from_openai_messages -from core.agent.tools import default_tools -from core.agent.types import (AgentMessage, ToolCall, AgentTool, - AgentToolResult) - - -# ====================================================================== -# 脚本化 mock 流:每个 turn 一个 dict,按序消费 -# ====================================================================== -def make_scripted_stream(turns): - state = {"i": 0} - - def stream_fn(context, model, signal, max_tokens, tools=None): - turn = turns[min(state["i"], len(turns) - 1)] - state["i"] += 1 - content = turn.get("content", "") - reasoning = turn.get("reasoning", "") - tool_calls = turn.get("tool_calls", []) - chunks = max(1, turn.get("chunks", 3)) - - if turn.get("raise"): - raise turn["raise"] - - if reasoning: - yield ("event", _thinking(reasoning)) - for i in range(0, len(content), max(1, len(content) // chunks) if content else 1): - piece = content[i:i + max(1, len(content) // chunks) if content else 1] - if signal.aborted: - return # 中止:生成器提前结束(真实流行为) - yield ("event", _text(piece)) - for tc in tool_calls: - yield ("event", _tc_delta(0, tc["id"], "id")) - yield ("event", _tc_delta(0, tc["name"], "name")) - yield ("event", _tc_delta(0, _json(tc["arguments"]), "arguments")) - - usage = turn.get("usage", {"input": 10, "output": 10}) - final = AgentMessage( - role="assistant", content=content, reasoning=reasoning, - tool_calls=[ToolCall(id=tc["id"], name=tc["name"], - arguments=tc["arguments"]) for tc in tool_calls], - stop_reason=turn.get("stop_reason", "stop"), usage=usage, - ) - yield ("event", _done()) - yield ("final", final) - - return stream_fn - - -def _text(t): - from core.agent.types import AssistantMessageEvent - return AssistantMessageEvent.text_delta(t) - - -def _thinking(t): - from core.agent.types import AssistantMessageEvent - return AssistantMessageEvent.thinking_delta(t) - - -def _tc_delta(idx, d, field): - from core.agent.types import AssistantMessageEvent - return AssistantMessageEvent.toolcall_delta(idx, d, field=field) - - -def _done(): - from core.agent.types import AssistantMessageEvent - return AssistantMessageEvent.done() - - -def _json(o): - import json - return json.dumps(o, ensure_ascii=False) - - -def make_agent(turns, **cfg_kw): - model = ModelConfig(provider="mock", name="mock-model", - context_window=cfg_kw.pop("context_window", 100000), - max_tokens=cfg_kw.pop("max_tokens", 32768), - api_key="x", base_url="http://mock") - cfg = AgentConfig(model=model, tools=cfg_kw.pop("tools", []), **cfg_kw) - agent = Agent(cfg) - agent.set_stream_fn(make_scripted_stream(turns)) - return agent - - -def run_events(agent): - events = [] - agent.subscribe(lambda e: events.append(e)) - return events - - -# ====================================================================== -# 1. 基础对话 + 事件顺序(对照 pi 事件流) -# ====================================================================== -def test_basic_chat_event_order(): - agent = make_agent([{"content": "你好呀!", "chunks": 4}]) - events = run_events(agent) - result = agent.prompt("hi") - types = [e.type for e in events] - assert types[0] == "agent_start" - assert types[1] == "turn_start" - assert "message_start" in types and "message_end" in types - assert types.count("message_update") == 4 # 4 个 chunk → 4 次增量 - assert types[-1] == "agent_end" - assert result.stop_reason == "stop" - assert agent.state.messages[0].role == "user" - assert agent.state.messages[1].role == "assistant" - assert agent.state.messages[1].content == "你好呀!" - assert not agent.state.is_streaming - - -# ====================================================================== -# 2. 工具循环:bash 调用 → 执行 → 第二轮回答(对照 pi tool 管线) -# ====================================================================== -def test_tool_loop_executes_and_continues(): - agent = make_agent( - [{"tool_calls": [{"id": "call_1", "name": "bash", - "arguments": {"command": "echo hi-from-tool"}}], - "stop_reason": "stop"}, - {"content": "命令执行完了。"}], - tools=default_tools(), - ) - events = run_events(agent) - result = agent.prompt("执行一下") - types = [e.type for e in events] - assert "tool_execution_start" in types - assert "tool_execution_end" in types - # 工具结果消息进了上下文 - trs = [m for m in agent.state.messages if m.role == "toolResult"] - assert len(trs) == 1 - assert trs[0].tool_name == "bash" - tr_text = trs[0].content if isinstance(trs[0].content, str) else \ - "".join(c.get("text", "") for c in trs[0].content) - assert "hi-from-tool" in tr_text - # 两轮:2 个 assistant - assistants = [m for m in agent.state.messages if m.role == "assistant"] - assert len(assistants) == 2 - assert assistants[1].content == "命令执行完了。" - assert result.stop_reason == "stop" - # end 事件带错误标记=False - end_evt = [e for e in events if e.type == "tool_execution_end"][0] - assert end_evt.is_error is False - - -# ====================================================================== -# 3. length 截断 + 工具调用 → 一律失败不执行(对照 failToolCalls) -# ====================================================================== -def test_length_truncation_fails_tool_calls(): - agent = make_agent( - [{"tool_calls": [{"id": "c1", "name": "bash", - "arguments": {"command": "echo should-not-run"}}], - "stop_reason": "length"}, - {"content": "恢复回答。"}], - tools=default_tools(), - ) - events = run_events(agent) - result = agent.prompt("go") - trs = [m for m in agent.state.messages if m.role == "toolResult"] - assert len(trs) == 1 - assert trs[0].is_error is True - tr_text = trs[0].content if isinstance(trs[0].content, str) else \ - "".join(c.get("text", "") for c in trs[0].content) - assert "未执行" in tr_text - # 循环继续(hasMoreToolCalls=True)→ 第二轮 - assistants = [m for m in agent.state.messages if m.role == "assistant"] - assert len(assistants) == 2 - assert result.stop_reason == "stop" - - -# ====================================================================== -# 4. 可恢复 length:删除坏消息 + 压缩 + 重试一次(对照 isRecoverableLength) -# ====================================================================== -def test_recoverable_length_compact_retry(): - # 预填 30 条大历史(~60K token)使 1:1 切点(保留 20K)有可摘要内容 - agent = make_agent( - [{"content": "", "stop_reason": "length", - "usage": {"input": 100, "output": 50}}, # 50 < max_tokens → 可恢复 - {"content": "压缩后恢复。"}], - max_tokens=100, - ) - for i in range(30): - agent.state.messages.append( - AgentMessage(role="user", - content=f"历史消息 {i} " + "长" * 2000)) - summarize_calls = [] - - def summarize(prompt, system_prompt, max_tokens): - summarize_calls.append((prompt, system_prompt, max_tokens)) - return "SUMMARY-OK" - - runner = AgentRunner(agent, summarize_fn=summarize) - events = run_events(agent) - result = runner.run("新问题") - assert result.stop_reason == "stop" - assert runner.last_action == "length_compact" - assert len(summarize_calls) == 1 - # pi 原版摘要提示词(逐字)进入了调用 - prompt, system, mt = summarize_calls[0] - assert "" in prompt and "## Goal" in prompt - assert "context summarization assistant" in system - assert mt == min(int(0.8 * 16384), 100) # min(0.8×reserve, model.maxTokens) - # 上下文里有压缩摘要(kind 标记,对照 pi compaction 条目) - sums = [m for m in agent.state.messages if m.kind == "compaction_summary"] - assert len(sums) == 1 and "SUMMARY-OK" in sums[0].content - # 保留尾巴非空且不含被摘要的历史 - assert len(agent.state.messages) > 1 - # 坏的 length 助手消息被移除(只剩恢复后的那个) - assistants = [m for m in agent.state.messages if m.role == "assistant"] - assert all(m.stop_reason != "length" for m in assistants) - - -# ====================================================================== -# 5. 可重试错误:退避重试(对照 isRetryable + 1600ms×1.6^n) -# ====================================================================== -def test_retryable_error_backoff_retry(): - agent = make_agent( - [{"raise": AgentError(message="429 Too Many Requests", - kind="rate_limit", status_code=429, - recoverable=True)}, - {"content": "重试成功。"}], - retry=RetryConfig(max_attempts=3, base_delay_ms=10, factor=2.0, - max_delay_ms=50), - ) - runner = AgentRunner(agent) - result = runner.run("hi") - assert result.stop_reason == "stop" - assert runner._retry_attempt == 1 - assert runner.last_action == "retry" - assert agent.state.messages[-1].content == "重试成功。" - - -# ====================================================================== -# 6. 不可重试错误:立即结束(401 auth) -# ====================================================================== -def test_auth_error_stops_immediately(): - agent = make_agent( - [{"raise": AgentError(message="401 Unauthorized", kind="auth", - status_code=401)}]) - runner = AgentRunner(agent, - summarize_fn=lambda *a: "S") # 压缩可用也不该触发 - result = runner.run("hi") - assert result.stop_reason == "error" - assert result.error.kind == "auth" - assert runner.last_action in ("none",) - - -# ====================================================================== -# 7. steering:turn 边界注入(对照 getSteeringMessages) -# ====================================================================== -def test_steering_injected_at_turn_boundary(): - agent = make_agent( - [{"content": "第一段回答。"}, - {"content": "看到转向了。"}], - ) - events = run_events(agent) - # 第一轮的 message_end 之后注入 steering(只注入一次) - steered = {"done": False} - - def on_event(e): - if e.type == "message_end" and e.message is not None \ - and e.message.role == "assistant" and not steered["done"]: - steered["done"] = True - agent.steer("插一句:改方向") - agent.subscribe(on_event) - result = agent.prompt("原始问题") - contents = [m.content for m in agent.state.messages if m.role == "user"] - assert "插一句:改方向" in contents - assistants = [m for m in agent.state.messages if m.role == "assistant"] - assert len(assistants) == 2 - assert result.stop_reason == "stop" - - -# ====================================================================== -# 8. followUp:run 结束前注入 → 自动续跑(对照外层循环) -# ====================================================================== -def test_follow_up_continues_run(): - agent = make_agent( - [{"content": "第一段。"}, - {"content": "第二段(followUp 触发)。"}], - ) - seen = {"n": 0} - - def on_event(e): - if e.type == "message_end" and e.message is not None \ - and e.message.role == "assistant": - seen["n"] += 1 - if seen["n"] == 1: - agent.follow_up("追加一问") - agent.subscribe(on_event) - result = agent.prompt("第一问") - user_msgs = [m for m in agent.state.messages if m.role == "user"] - assert len(user_msgs) == 2 - assistants = [m for m in agent.state.messages if m.role == "assistant"] - assert len(assistants) == 2 - assert result.stop_reason == "stop" - - -# ====================================================================== -# 9. abort:流中中止 → stop_reason=aborted → agent_end(对照 pi 中止语义) -# ====================================================================== -def test_abort_mid_stream(): - agent = make_agent([{"content": "ABCDEFGH", "chunks": 8}, - {"content": "不应到达"}]) - events = run_events(agent) - fired = {"n": 0} - - def on_event(e): - if e.type == "message_update": - fired["n"] += 1 - if fired["n"] == 2: # 收到第 2 个 chunk 后中止 - agent.abort() - agent.subscribe(on_event) - result = agent.prompt("hi") - assert result.stop_reason == "aborted" - types = [e.type for e in events] - assert types[-1] == "agent_end" - # 中止消息保留已输出部分 - last_a = [m for m in agent.state.messages if m.role == "assistant"][-1] - assert last_a.stop_reason == "aborted" - assert "AB" in last_a.content - # 第二轮未发生 - assert len([m for m in agent.state.messages if m.role == "assistant"]) == 1 - - -# ====================================================================== -# 10. 输出预算钳制公式(对照 simple-options clampMaxTokensToContext) -# ====================================================================== -def test_clamp_max_tokens_formula(): - model = ModelConfig(context_window=100000, max_tokens=32768) - # 构造已知输入:50000 个 ASCII 字符 ≈ 12500 token(/4)+ 每消息 4 - msg = AgentMessage(role="user", content="a" * 50000) - clamped, input_tokens = clamp_max_tokens_to_context(model, [msg]) - # 12500 + 4 = 12504;max_output = 100000 - 12504 - 4096 = 833... 不,= 83400 - assert clamped == min(32768, 100000 - input_tokens - 4096) - # 输入几乎占满窗口 → 输出预算被压到 1 - big = AgentMessage(role="user", content="a" * 400000) # 100004 token - assert clamp_max_tokens_to_context(model, [big]) is None # 溢出 - mid = AgentMessage(role="user", content="a" * 392000) # 98004 token - clamped2, _ = clamp_max_tokens_to_context(model, [mid]) - assert clamped2 == 1 # 100000-98004-4096 < 0 → 下限 1 - - -# ====================================================================== -# 11. 压缩切分点(1:1 对照 pi findCutPoint):token 预算回扫 + 有效切点 + 断轮 -# ====================================================================== -def test_compaction_split_point_alignment(): - msgs = [AgentMessage(role="user", content=f"u{i}") for i in range(4)] - a = AgentMessage(role="assistant", content="", tool_calls=[ - ToolCall(id="c1", name="bash", arguments={})]) - msgs.append(a) - msgs.append(AgentMessage(role="toolResult", tool_call_id="c1", - tool_name="bash", content="r")) - msgs.extend([AgentMessage(role="assistant", content=f"a{i}") for i in range(4)]) - # 有效切点:user/assistant 位置;toolResult(index 5) 绝不能做切点 - cuts = find_valid_cut_points(msgs, 0, len(msgs)) - assert 5 not in cuts - assert set(cuts) == {0, 1, 2, 3, 4, 6, 7, 8, 9} - # 小预算 → 尾部回扫立刻达标 → 切在 index 9(assistant)→ 断轮 - cut = find_cut_point(msgs, 0, len(msgs), keep_recent_tokens=1) - assert cut.first_kept_index == 9 - assert cut.is_split_turn is True - # 轮起点 = 向前最近的 user(index 3) - assert cut.turn_start_index == 3 - assert find_turn_start(msgs, 9, 0) == 3 - # 大预算(够不着)→ 切点落在第一个有效切点 index 0(user)→ 不断轮 - cut2 = find_cut_point(msgs, 0, len(msgs), keep_recent_tokens=10 ** 6) - assert cut2.first_kept_index == 0 and cut2.is_split_turn is False - - -# ====================================================================== -# 11b. should_compact 触发公式(1:1 对照 pi: tokens > window - reserve) -# ====================================================================== -def test_should_compact_formula(): - model = ModelConfig(context_window=100000, max_tokens=32768) - # 330000 ASCII 字符 ≈ 82500 + 4 = 82504 token < 100000-16384=83616 → 不触发 - small = AgentMessage(role="user", content="a" * 330000) - ok, t1 = should_compact([small], model, reserve_tokens=16384) - assert not ok and t1 == 82504 - # 335000 ASCII ≈ 83754 > 83616 → 触发 - big = AgentMessage(role="user", content="a" * 335000) - ok2, t2 = should_compact([big], model, reserve_tokens=16384) - assert ok2 and t2 == 83754 - - -# ====================================================================== -# 11c. usage 锚定估算(1:1 对照 pi estimateContextTokens) -# ====================================================================== -def test_usage_anchored_estimation(): - a = AgentMessage(role="assistant", content="x" * 100, - stop_reason="stop", usage={"totalTokens": 5000}) - tail = AgentMessage(role="user", content="b" * 4000) # 1000 + 4 = 1004 - est = estimate_context_tokens([a, tail]) - assert est.usage_tokens == 5000 - assert est.trailing_tokens == 1004 - assert est.tokens == 6004 - assert est.last_usage_index == 0 - # aborted/error 的 assistant usage 无效(对照 pi getAssistantUsage) - a2 = AgentMessage(role="assistant", content="y", - stop_reason="error", usage={"totalTokens": 9999}) - est2 = estimate_context_tokens([a2, tail]) - assert est2.last_usage_index is None - assert est2.tokens == 1004 + (1 + 4) - - -# ====================================================================== -# 11d. serialize_conversation 格式(1:1 对照 pi utils.ts) -# ====================================================================== -def test_serialize_conversation_format(): - msgs = [ - AgentMessage(role="user", content="做个工具"), - AgentMessage(role="assistant", reasoning="想一想…", content="好,", - tool_calls=[ToolCall(id="c1", name="bash", - arguments={"command": "ls"})]), - AgentMessage(role="toolResult", tool_call_id="c1", tool_name="bash", - content="R" * 3000), - ] - s = serialize_conversation(msgs) - assert "[User]: 做个工具" in s - assert "[Assistant thinking]: 想一想…" in s - assert "[Assistant]: 好," in s - assert "[Assistant tool calls]: bash(command=\"ls\")" in s - # 工具结果 2000 字符截断(pi TOOL_RESULT_MAX_CHARS) - assert "[Tool result]: " + "R" * 2000 in s - assert "[... 1000 more characters truncated]" in s - - -# ====================================================================== -# 11e. 文件操作提取与附录(1:1 对照 pi extractFileOps*/formatFileOperations) -# ====================================================================== -def test_file_ops_extraction_and_appendix(): - ops = FileOperations() - extract_file_ops_from_message(AgentMessage( - role="assistant", tool_calls=[ - ToolCall(id="1", name="read", arguments={"path": "/a/f.py"}), - ToolCall(id="2", name="write", arguments={"path": "/a/g.py"}), - ToolCall(id="3", name="edit", arguments={"path": "/a/f.py"}), - ]), ops) - read_files, modified = compute_file_lists(ops) - assert read_files == [] # f.py 被改过 → 不算只读 - assert modified == ["/a/f.py", "/a/g.py"] - appendix = format_file_operations(read_files, modified) - assert "\n/a/f.py\n/a/g.py\n" in appendix - # 压缩后摘要尾部带文件附录 - calls = [] - - def summarize(prompt, system, mt): - calls.append((prompt, mt)) - return "S" - msgs = [AgentMessage(role="user", content="u" + "x" * 40000), - AgentMessage(role="assistant", - tool_calls=[ToolCall(id="1", name="read", - arguments={"path": "/a/f.py"})]), - AgentMessage(role="user", content="u2" + "x" * 40000), - AgentMessage(role="user", content="u3" + "x" * 40000)] - out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) - assert out is not None - assert out[0].kind == "compaction_summary" - assert "\n/a/f.py\n" in out[0].content - - -# ====================================================================== -# 11f. 迭代式 previousSummary 更新(1:1 对照 pi UPDATE_SUMMARIZATION_PROMPT) -# ====================================================================== -def test_iterative_summary_update(): - calls = [] - - def summarize(prompt, system, mt): - calls.append(prompt) - return "UPDATED" - prev = AgentMessage(role="user", content="旧摘要内容ABC", - kind="compaction_summary") - msgs = [prev, - AgentMessage(role="user", content="n" + "x" * 40000), - AgentMessage(role="user", content="n2" + "x" * 40000), - AgentMessage(role="user", content="n3" + "x" * 40000)] - out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) - assert out is not None and out[0].kind == "compaction_summary" - assert "UPDATED" in out[0].content - # 旧摘要进 ,且用 UPDATE 提示词 - p = calls[0] - assert "\n旧摘要内容ABC\n" in p - assert "NEW conversation messages to incorporate" in p - - -# ====================================================================== -# 11g. 断轮双摘要拼接(1:1 对照 pi compact() 的 split-turn 分支) -# ====================================================================== -def test_split_turn_double_summary(): - calls = [] - - def summarize(prompt, system, mt): - calls.append(prompt) - return f"SUMMARY-{len(calls)}" - # 轮1: u1/a1(40K 各一);轮2: u2/a2/a3/a4(a 连续 → 切点必落 assistant → 断轮) - msgs = [AgentMessage(role="user", content="u1" + "x" * 40000), - AgentMessage(role="assistant", content="a1" + "x" * 40000), - AgentMessage(role="user", content="u2" + "x" * 40000), - AgentMessage(role="assistant", content="a2" + "x" * 40000), - AgentMessage(role="assistant", content="a3" + "x" * 40000), - AgentMessage(role="assistant", content="a4" + "x" * 40000)] - out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) - assert out is not None - assert len(calls) == 2 # 历史摘要 + 轮前缀摘要(pi split-turn 双调用) - assert "PREFIX of a turn" in calls[1] - c = out[0].content - assert "SUMMARY-1" in c and "SUMMARY-2" in c - assert "---" in c - assert "**Turn Context (split turn):**" in c - # 保留尾巴 = 切点之后的 a3/a4 - assert [m.content[:2] for m in out[1:]] == ["a3", "a4"] - - -# ====================================================================== -# 12. pi 消息 → OpenAI 消息转换(tool 回传格式) -# ====================================================================== -def test_to_openai_messages_format(): - a = AgentMessage(role="assistant", content="", tool_calls=[ - ToolCall(id="c1", name="bash", arguments={"command": "ls"})]) - t = AgentMessage(role="toolResult", tool_call_id="c1", tool_name="bash", - content="ok") - out = to_openai_messages([ - AgentMessage(role="user", content="hi"), a, t, - ]) - assert out[0] == {"role": "user", "content": "hi"} - assert out[1]["role"] == "assistant" - assert out[1]["tool_calls"][0]["function"]["name"] == "bash" - assert out[2]["role"] == "tool" - assert out[2]["tool_call_id"] == "c1" - assert out[2]["content"] == "ok" - - -# ====================================================================== -# 12b. reasoning 回传(thinking 模式,DeepSeek 400 修复) -# ====================================================================== -def test_reasoning_passthrough_toggle(): - """thinking 模式要求 assistant 的 reasoning_content 原样回传 → 默认开启; - 显式 False 关闭(特殊网关逃生阀)。只影响带 reasoning 的 assistant 条目。""" - from core.agent.types import ModelConfig - a = AgentMessage(role="assistant", content="答", reasoning="思考过程...", - tool_calls=[ToolCall(id="c1", name="bash", - arguments={"command": "ls"})]) - msgs = [AgentMessage(role="user", content="hi"), a, - AgentMessage(role="toolResult", tool_call_id="c1", - tool_name="bash", content="ok")] - # 默认:reasoning_content 回传 - out_def = to_openai_messages(msgs) - assert out_def[1]["reasoning_content"] == "思考过程..." - assert out_def[1]["content"] == "答" - assert out_def[1]["tool_calls"][0]["function"]["name"] == "bash" - assert out_def[2]["role"] == "tool" - # 显式关闭(逃生阀):不注入 - out_off = to_openai_messages(msgs, pass_reasoning=False) - assert "reasoning_content" not in out_off[1] - # 无 reasoning 的 assistant 条目不产生空字段 - b = AgentMessage(role="assistant", content="无思考", tool_calls=[]) - out_mix = to_openai_messages([b], pass_reasoning=True) - assert "reasoning_content" not in out_mix[0] - # ModelConfig 默认开 - mc = ModelConfig(name="t") - assert mc.pass_reasoning is True - - -# ====================================================================== -# 12c. bash 秒级读秒 + 真超时(进程树杀,修 Windows 孤儿进程假超时) -# ====================================================================== -def test_bash_timer_ticks_and_real_timeout(): - """Popen 秒级滴答:on_timer 每秒回调(气泡读秒); - 到期杀进程树 → 真中断(修复前:Windows 孤儿子进程跑满全时长才报超时)。""" - import time as _t - import core.agent.tools as T - from core.agent.types import AbortSignal - ticks = [] - ctx = {"cwd": os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "shell": True, "on_timer": lambda el, to: ticks.append((el, to))} - # 1) 3s 命令 timeout=5 → 正常结束 + 收到 1、2 滴 - r = T.tool_bash("tb1", {"command": r'python -c "import time; time.sleep(3)"', - "timeout": 5}, AbortSignal(), None, ctx) - assert not r.is_error, r.as_text() - assert "[exit 0]" in r.as_text(), r.as_text() - els = [x[0] for x in ticks] - assert 1 in els and 2 in els, f"读秒缺失: {ticks}" - assert all(x[1] == 5 for x in ticks), ticks - # 2) 5s 命令 timeout=1 → 真杀:总耗时 < 4s(修复前孤儿进程要跑满 5s) - ticks.clear() - t0 = _t.time() - r2 = T.tool_bash("tb2", {"command": r'python -c "import time; time.sleep(5)"', - "timeout": 1}, AbortSignal(), None, ctx) - real = _t.time() - t0 - assert r2.is_error and "超时" in r2.as_text(), r2.as_text() - assert real < 4.0, f"进程树杀不生效,实际阻塞 {real:.1f}s" - assert [x[0] for x in ticks] == [1], f"超前应读 1 秒: {ticks}" - - -# ====================================================================== -# 13. 同一 Agent 多次 prompt(会话记忆累积,对照 pi Agent 复用) -# ====================================================================== -def test_agent_reuse_across_prompts(): - agent = make_agent( - [{"content": "第一次。"}, {"content": "第二次。"}]) - r1 = agent.prompt("问题1") - r2 = agent.prompt("问题2") - users = [m for m in agent.state.messages if m.role == "user"] - assert len(users) == 2 - # 第二轮上下文包含第一轮全部消息 - assert r1.stop_reason == "stop" and r2.stop_reason == "stop" - - -# ====================================================================== -# 14. AgentWorker 接线回归(P0:必须注入 stream_fn,否则"未配置 stream_fn") -# ====================================================================== -def test_agent_worker_stream_fn_wiring(): - """AgentWorker.run() 必须把 openai_stream 注入 Agent(对照 pi 的 - agentLoopConfig.streamFn 注入点)。用假流函数验证整条接线。""" - import core.llm_engine as le - from PyQt6.QtCore import QCoreApplication - - QCoreApplication.instance() or QCoreApplication([]) - - chunks, errors = [], [] - real_stream = le.openai_stream - - def fake_stream(context, model, signal, max_tokens, tools=None): - yield ("event", _text("你好,")) - yield ("event", _text("世界!")) - yield ("final", AgentMessage(role="assistant", content="你好,世界!", - stop_reason="stop", - usage={"input": 5, "output": 5})) - - le.openai_stream = fake_stream - try: - cfg = le._load_config() - providers = list(cfg.get("providers", {}).keys()) - assert providers, "config.json 里没有 provider" - w = le.AgentWorker(providers[0], "any-model", - [{"role": "user", "content": "hi"}]) - w.chunk_received.connect(lambda t: chunks.append(t)) - w.error_occurred.connect(lambda e: errors.append(e)) - w.run() # 同步跑(不走线程),验证接线 - finally: - le.openai_stream = real_stream - - assert not errors, f"出现错误(stream_fn 未注入?): {errors}" - assert "".join(chunks) == "你好,世界!" - - -# ====================================================================== -# 15. 系统提示词:请求头部注入 + 不入历史 + SYSTEM_PROMPT.md 加载 -# ====================================================================== -def test_system_prompt_injected_at_request_head(): - seen = {} - - def spy_stream(context, model, signal, max_tokens, tools=None): - seen["context"] = list(context) - yield ("event", _text("ok")) - yield ("final", AgentMessage(role="assistant", content="ok", - stop_reason="stop", - usage={"input": 1, "output": 1})) - - model = ModelConfig(provider="mock", name="m", context_window=100000, - max_tokens=8192, api_key="x", base_url="http://mock") - cfg = AgentConfig(model=model, tools=[], system_prompt="SYS-TEST-123") - agent = Agent(cfg) - agent.set_stream_fn(spy_stream) - agent.prompt("hi") - - ctx = seen["context"] - assert ctx[0].role == "system" and ctx[0].content == "SYS-TEST-123" - # 系统提示词不能混进会话历史(不占压缩/持久化) - assert all(m.role != "system" for m in agent.state.messages) - # to_openai_messages:头部 system 放行,中途 system 跳过 - from core.agent.stream_fn import to_openai_messages - msgs = [ctx[0], - AgentMessage(role="user", content="u"), - AgentMessage(role="system", content="stray")] - out = to_openai_messages(msgs) - assert out[0] == {"role": "system", "content": "SYS-TEST-123"} - assert sum(1 for m in out if m["role"] == "system") == 1 - - -def test_system_prompt_md_file_loaded(): - from core.llm_engine import load_system_prompt - prompt = load_system_prompt() - assert "haocode" in prompt and len(prompt) > 200 - - -# ====================================================================== -# 16. tools 必须进 API 请求(回归:模型因此"文字演戏"工具调用) -# ====================================================================== -def test_openai_stream_sends_tools(): - import types as _pytypes - import core.agent.stream_fn as sf - from core.agent.stream_fn import AbortSignal - from core.agent.tools import default_tools - - captured = {} - - class _FakeStream: - def __init__(self, chunks): - self._it = iter(chunks) - - def __iter__(self): - return self - - def __next__(self): - return next(self._it) - - def close(self): - pass - - def _chunk(choices, usage=None): - return _pytypes.SimpleNamespace(usage=usage, choices=choices) - - def _delta(content=None, tool_calls=None): - return _pytypes.SimpleNamespace( - content=content, tool_calls=tool_calls, - **{"reasoning_content": None}) - - _tc = _pytypes.SimpleNamespace( - index=0, id="call_1", - function=_pytypes.SimpleNamespace(name="bash", - arguments='{"command":"echo hi"}')) - chunks = [ - _chunk([_pytypes.SimpleNamespace(delta=_delta(tool_calls=[_tc]), - finish_reason="tool_calls")]), - _chunk([], usage=_pytypes.SimpleNamespace(prompt_tokens=3, - completion_tokens=2)), - ] - - class _FakeCompletions: - def create(self, **params): - captured.update(params) - return _FakeStream(chunks) - - real_openai = sf.OpenAI - sf.OpenAI = lambda **kw: _pytypes.SimpleNamespace( - chat=_pytypes.SimpleNamespace(completions=_FakeCompletions())) - try: - model = ModelConfig(provider="t", name="m", context_window=100000, - max_tokens=100, api_key="k", base_url="http://x") - sig = AbortSignal() - out = list(sf.openai_stream([AgentMessage(role="user", content="hi")], - model, sig, 100, tools=default_tools())) - finally: - sf.OpenAI = real_openai - - tools = captured.get("tools") - assert tools, "tools 没进请求体!" - names = {t["function"]["name"] for t in tools} - assert names == {"read", "bash", "write", "edit"} - assert tools[0]["type"] == "function" - - final = [p for k, p in out if k == "final"][0] - assert final.tool_calls and final.tool_calls[0].name == "bash" - assert final.tool_calls[0].arguments == {"command": "echo hi"} - - -# ====================================================================== -# 17. 循环层兜底:文字"演"的工具调用被解析执行 -# ====================================================================== -def test_text_tool_calls_loop_fallback(): - turns = [ - {"content": "我来看看。echo haocode-test 稍等", - "tool_calls": []}, - {"content": "命令输出正常。"}, - ] - agent = make_agent(turns, tools=default_tools()) - result = agent.prompt("看一下") - - msgs = agent.state.messages - asst1 = next(m for m in msgs - if m.role == "assistant" and m.tool_calls) - assert asst1.tool_calls[0].name == "bash" - assert asst1.tool_calls[0].arguments["command"] == "echo haocode-test" - tr = next(m for m in msgs if m.role == "toolResult") - assert "haocode-test" in (tr.content if isinstance(tr.content, str) - else str(tr.content)) - assert result.stop_reason == "stop" - - -# ====================================================================== -# 18. from_openai_messages 全保真(worker 模式上下文丢失 bug 的回归守护) -# ====================================================================== -def test_from_openai_messages_preserves_tool_history(): - """build_api_context 产物(含 tool 消息 + assistant tool_calls)经 - from_openai_messages → to_openai_messages 往返后必须保真: - 工具历史不能丢,残缺参数 JSON 必须兑底成合法 "{}"。""" - import json as _json - src = [ - {"role": "user", "content": "帮我运行 main.py"}, - {"role": "assistant", "content": "我来读一下。"}, - {"role": "assistant", "content": None, "tool_calls": [ - {"id": "c1", "type": "function", - "function": {"name": "read", "arguments": '{"path": "main.py"}'}}]}, - {"role": "tool", "tool_call_id": "c1", "content": "print('hello')"}, - {"role": "assistant", "content": None, "tool_calls": [ - {"id": "c2", "type": "function", - "function": {"name": "bash", - "arguments": '{"command": "python main.py"}'}}]}, - {"role": "tool", "tool_call_id": "c2", "content": "hello"}, - {"role": "assistant", "content": "运行成功。"}, - {"role": "user", "content": "刚才的代码是什么?"}, - ] - msgs = from_openai_messages(src) - # 角色序列保真(8 条,无丢失、无空 assistant) - assert [m.role for m in msgs] == [ - "user", "assistant", "assistant", "toolResult", - "assistant", "toolResult", "assistant", "user"] - # tool_call 保真 - assert msgs[2].tool_calls[0].id == "c1" - assert msgs[2].tool_calls[0].name == "read" - assert msgs[2].tool_calls[0].arguments == {"path": "main.py"} - assert msgs[4].tool_calls[0].name == "bash" - # toolResult 保真 - assert msgs[3].tool_call_id == "c1" - assert msgs[3].content == "print('hello')" - # 纯工具轮不再是空 assistant - assert msgs[2].content == "" and len(msgs[2].tool_calls) == 1 - - # 往返:to_openai_messages(from_openai_messages(x)) 角色序一致 + JSON 合法 - rt = to_openai_messages(msgs) - assert [m["role"] for m in rt] == [m["role"] for m in src] - for m in rt: - if m["role"] == "assistant" and m.get("tool_calls"): - for tc in m["tool_calls"]: - _json.loads(tc["function"]["arguments"]) # 必须是合法 JSON - - # 残缺参数 JSON 兑底(中断导致的历史截断场景) - bad = [{"role": "user", "content": "hi"}, - {"role": "assistant", "content": None, "tool_calls": [ - {"id": "c9", "type": "function", - "function": {"name": "bash", "arguments": '{"command": "ls -'}}]}, - {"role": "tool", "tool_call_id": "c9", "content": "out"}] - bmsgs = from_openai_messages(bad) - assert bmsgs[1].tool_calls[0].arguments == {} - assert bmsgs[1].tool_calls[0].raw_arguments == "{}" - brt = to_openai_messages(bmsgs) - _json.loads(brt[1]["tool_calls"][0]["function"]["arguments"]) - - # token 估算器能消化 toolResult/tool_calls(不崩 + 计入工具历史) - est_full = estimate_context_tokens(msgs).tokens - est_notools = estimate_context_tokens( - [m for m in msgs if m.role != "toolResult"]).tokens - assert est_full > est_notools - - -def test_agent_worker_second_turn_sees_tool_history(): - """集成:AgentWorker 第 2 轮真实发出的 context 必须包含第 1 轮的全部 - 工具历史(toolResult + assistant tool_calls)——任务完成后追问不丢上下文。""" - import core.llm_engine as le - from PyQt6.QtCore import QCoreApplication - - QCoreApplication.instance() or QCoreApplication([]) - seen = {} - real_stream = le.openai_stream - - def fake_stream(context, model, signal, max_tokens, tools=None): - seen["context"] = list(context) - yield ("event", _text("ok")) - yield ("final", AgentMessage(role="assistant", content="ok", - stop_reason="stop", - usage={"input": 1, "output": 1})) - - le.openai_stream = fake_stream - try: - cfg = le._load_config() - providers = list(cfg.get("providers", {}).keys()) - assert providers, "config.json 里没有 provider" - w = le.AgentWorker(providers[0], "any-model", [ - {"role": "user", "content": "帮我运行 main.py"}, - {"role": "assistant", "content": "我来读一下。"}, - {"role": "assistant", "content": None, "tool_calls": [ - {"id": "c1", "type": "function", - "function": {"name": "read", - "arguments": '{"path": "main.py"}'}}]}, - {"role": "tool", "tool_call_id": "c1", - "content": "print('hello')"}, - {"role": "assistant", "content": "运行成功。"}, - {"role": "user", "content": "刚才的代码是什么?"}, - ]) - errors = [] - w.error_occurred.connect(lambda e: errors.append(e)) - w.run() # 同步跑 - finally: - le.openai_stream = real_stream - - assert not errors, f"AgentWorker 报错: {errors}" - ctx = seen["context"] - # 工具历史必须完整在场 - trs = [m for m in ctx if m.role == "toolResult"] - assert len(trs) == 1 and trs[0].tool_call_id == "c1" - assert "print('hello')" in trs[0].content - asst_tc = [m for m in ctx if m.role == "assistant" and m.tool_calls] - assert len(asst_tc) == 1 and asst_tc[0].tool_calls[0].name == "read" - # 末尾是本次提问 - assert ctx[-1].role == "user" and ctx[-1].content == "刚才的代码是什么?" - - -# ====================================================================== -# 🆕 P0: usage 锚点时效校验(对照 pi getLastAssistantUsageInfo) -# ====================================================================== -def test_usage_anchor_stale_after_compaction_summary(): - """压缩后:保留窗里 assistant 的旧 usage(来自压缩前)必须失效, - 否则下一次 should_compact 会锚定到过期 60K → 误触发二次压缩。""" - stale = AgentMessage(role="assistant", content="旧回答", - timestamp=2000, stop_reason="stop", - usage={"input": 55000, "output": 5000}) - # 对照组:无摘要 → 应锚定到 stale usage - est_ctrl = estimate_context_tokens([ - AgentMessage(role="user", content="旧问题", timestamp=1000), - stale, - ]) - assert est_ctrl.last_usage_index == 1 - assert est_ctrl.usage_tokens == 60000 - - # 实验组:新摘要消息(timestamp 更晚)插到前面 → 旧 usage 失效 - summary = AgentMessage(role="user", content="(压缩摘要)", - timestamp=9000, kind="compaction_summary") - est = estimate_context_tokens([summary, - AgentMessage(role="user", content="旧问题", timestamp=1000), - stale]) - assert est.last_usage_index is None, f"过期 usage 未被失效: {est}" - assert est.usage_tokens == 0 - - -def test_usage_anchor_recovers_after_new_assistant(): - """压缩后第一个新 assistant 回复到达 → 新 usage 自动成为锚点""" - msgs = [ - AgentMessage(role="user", content="(压缩摘要)", timestamp=9000, - kind="compaction_summary"), - AgentMessage(role="assistant", content="旧回答", timestamp=2000, - stop_reason="stop", usage={"input": 55000, "output": 5000}), - AgentMessage(role="user", content="新问题", timestamp=10000), - AgentMessage(role="assistant", content="新回答", timestamp=11000, - stop_reason="stop", usage={"input": 20000, "output": 300}), - ] - est = estimate_context_tokens(msgs) - assert est.last_usage_index == 3, f"应锚定到新回复: {est}" - assert est.usage_tokens == 20300 - - -def test_estimate_no_anchor_without_timestamps_backward_compatible(): - """无 timestamp 信息(全 0,旧 DB 回放)→ 行为等同旧版「取最后一条有效」""" - msgs = [ - AgentMessage(role="user", content="q1"), - AgentMessage(role="assistant", content="a1", stop_reason="stop", - usage={"input": 100, "output": 10}), - AgentMessage(role="user", content="q2"), - AgentMessage(role="assistant", content="a2", stop_reason="stop", - usage={"input": 200, "output": 20}), - ] - est = estimate_context_tokens(msgs) - assert est.last_usage_index == 3 - assert est.usage_tokens == 220 - - -# ====================================================================== -# 🆕 P2: 无锚点时 system 提示词 + 工具 schema 计入估算/钳制 -# ====================================================================== -class _FakeTool: - name = "read" - description = "读取文件内容" - parameters = {"type": "object", "properties": {"path": {"type": "string"}}} - - -def test_estimate_includes_system_and_tools_without_anchor(): - sysp = "你是一个编程助手。" * 100 # 900 字(。U+3002 不在 CJK 类 → 估算 ≈825) - base_msgs = [AgentMessage(role="user", content="hi")] - plain = estimate_context_tokens(list(base_msgs)) - full = estimate_context_tokens(list(base_msgs), - system_prompt=sysp, tools=[_FakeTool()]) - # system(≈825) + 工具 schema(>30) 必须被计入 - assert full.tokens >= plain.tokens + 800, f"{plain} -> {full}" - - # 有锚点时:usage 已精确覆盖 system+tools,不重复计 - anchor_msgs = [AgentMessage(role="assistant", content="x", - stop_reason="stop", - usage={"input": 1000, "output": 100})] - a1 = estimate_context_tokens(list(anchor_msgs)) - a2 = estimate_context_tokens(list(anchor_msgs), - system_prompt=sysp, tools=[_FakeTool()]) - assert a1.tokens == a2.tokens == 1100, f"{a1} vs {a2}" - - -def test_clamp_counts_system_tools(): - # 小窗口(8K):钳制结果受「窗口-输入-4096」约束,system+tools 计入后必须缩小 - model = ModelConfig(provider="p", name="m", - context_window=8000, max_tokens=8192) - msgs = [AgentMessage(role="user", content="hi")] - c1 = clamp_max_tokens_to_context(model, list(msgs)) - c2 = clamp_max_tokens_to_context(model, list(msgs), - system_prompt="x" * 4000, - tools=[_FakeTool()]) - # 输入估算上升 → 输出预算如实缩小(对照 pi 传完整 Context) - assert c2[1] > c1[1] + 900, f"input: {c1[1]} -> {c2[1]}" - assert c2[0] < c1[0], f"max_tokens: {c1[0]} -> {c2[0]}" - - -# ====================================================================== -# 🆕 M1/M2: 流式字段提取(reasoning 三字段 / choice.usage 兜底) -# ====================================================================== -def test_pick_reasoning_field_priority(): - from types import SimpleNamespace as NS - from core.agent.stream_fn import _pick_reasoning - # reasoning_content 优先(vLLM/Qwen) - assert _pick_reasoning(NS(reasoning="a", reasoning_content="b", - reasoning_text="c")) == "b" - # 其次 reasoning(llama.cpp) - assert _pick_reasoning(NS(reasoning="a")) == "a" - # 再次 reasoning_text - assert _pick_reasoning(NS(reasoning_text="c")) == "c" - # 全空 - assert _pick_reasoning(NS()) == "" - assert _pick_reasoning(None) == "" - # pydantic 对象的 model_dump 兜底 - class _D: - def model_dump(self): - return {"reasoning_text": "z"} - assert _pick_reasoning(_D()) == "z" - - -def test_pick_usage_choice_fallback(): - from types import SimpleNamespace as NS - from core.agent.stream_fn import _pick_usage - u1 = object() - assert _pick_usage(NS(usage=u1)) is u1 - # chunk.usage 为空 → choice.usage 兜底(Moonshot 系) - u2 = object() - assert _pick_usage(NS(usage=None, choices=[NS(usage=u2)])) is u2 - assert _pick_usage(NS(usage=None, choices=[])) is None - assert _pick_usage(NS(usage=None)) is None - - -# ====================================================================== -# 🆕 M3: AgentRunner 重试回调(onRetryScheduled / onRetryFinished) -# ====================================================================== -def test_agent_runner_retry_callbacks(): - from core.agent.recovery import AgentRunner - from core.agent.types import AgentConfig, ModelConfig, RetryConfig, RunResult - - events = [] - - class _State: - def __init__(self): - self.messages = [ - AgentMessage(role="user", content="q"), - AgentMessage(role="assistant", stop_reason="error", - error_message="connection error"), - ] - - class _Agent: - def __init__(self): - self.state = _State() - self.config = AgentConfig( - model=ModelConfig(provider="p", name="m", - context_window=128000, max_tokens=8192), - retry=RetryConfig(max_attempts=3, base_delay_ms=1)) - self.continued = False - - def has_queued(self): - return False - - def continue_(self): - self.continued = True - self.state.messages[-1] = AgentMessage( - role="assistant", stop_reason="stop", content="ok") - return RunResult(stop_reason="stop") - - agent = _Agent() - runner = AgentRunner( - agent, summarize_fn=None, - on_retry_scheduled=lambda a, m, d, r: events.append(("sched", a, m, r)), - on_retry_finished=lambda ok: events.append(("fin", ok))) - - runner._post_loop(RunResult(stop_reason="stop")) - - assert agent.continued, "可重试错误未触发 continue" - kinds = [e[0] for e in events] - assert "sched" in kinds and "fin" in kinds, f"events={events}" - sched = next(e for e in events if e[0] == "sched") - assert sched[1] == 1 and sched[2] == 3 - assert sched[3] == "connection error", f"reason 未透传: {sched}" - assert events[-1] == ("fin", True) - - -# ====================================================================== -# 🆕 P1: usage/timestamp 入库回放链路 -# ====================================================================== -def test_from_openai_maps_usage_and_timestamp(): - msgs = from_openai_messages([ - {"role": "user", "content": "q", "timestamp": 111}, - {"role": "assistant", "content": "a", "timestamp": 222, - "usage": {"input": 100, "output": 50}}, - {"role": "tool", "tool_call_id": "c1", "content": "r", - "timestamp": 333}, - ]) - assert msgs[0].timestamp == 111 - assert msgs[1].timestamp == 222 - assert msgs[1].usage == {"input": 100, "output": 50} - assert msgs[2].timestamp == 333 - est = estimate_context_tokens(msgs) - assert est.last_usage_index == 1 and est.usage_tokens == 150 - - -def test_db_usage_roundtrip(): - """add_message(usage=...) → get_message_chain 行带 usage → 锚定生效""" - import json as _json - import tempfile - import time as _time - from core.db_manager import DBManager - - tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) - tmp.close() - try: - db = DBManager(tmp.name) - sid = db.create_session("P1 usage 测试")["id"] - uid = db.add_message(sid, "user", "问题一", None) - _time.sleep(1.1) # 保证 created_at(秒)递增,模拟真实时序 - db.add_message(sid, "assistant", "回答一(很短)", uid["id"], - usage=_json.dumps({"input": 30000, "output": 2000})) - chain = db.get_message_chain(sid) - assert [m["role"] for m in chain] == ["user", "assistant"], \ - f"chain={[(m['role'], m['parent_id']) for m in chain]}" - assert _json.loads(chain[1]["usage"]) == {"input": 30000, "output": 2000} - - # 模拟 build_api_context 的回放(timestamp/usage 挂到 OpenAI dict) - oai = [{"role": m["role"], "content": m["content"], - "timestamp": int(m["created_at"]) * 1000, - **({"usage": _json.loads(m["usage"])} - if m.get("usage") else {})} - for m in chain] - agent_msgs = from_openai_messages(oai) - est = estimate_context_tokens(agent_msgs) - assert est.last_usage_index == 1, f"usage 未锚定: {est}" - assert est.usage_tokens == 32000 - finally: - try: - os.remove(tmp.name) - except OSError: - pass - - -# ====================================================================== -# 轮中主动压缩检查(🆕 haocode 增强,偏离 pi 1:1) -# 内层循环每次 LLM 请求前跑 should_compact 同公式,超阈值先压缩再发。 -# ====================================================================== - -def _mid_tool(name, text): - """确定性工具:返回固定文本(避免真实 bash)""" - def execute(tool_call_id, args, signal, on_update, context): - return AgentToolResult.text(text) - return AgentTool( - name=name, description=name, - parameters={"type": "object", "properties": {}}, - execute=execute, label=name) - - -def _prefill_history(agent, n=8, chars=2000): - for i in range(n): - agent.state.messages.append( - AgentMessage(role="user", content=f"hist{i} " + "长" * chars)) - - -def _capture_requests(agent, captured): - """包一层 stream_fn,记录每次请求的 api 上下文""" - orig = agent._stream_fn - - def wrapper(context, model, signal, max_tokens, tools=None): - captured.append(list(context)) - yield from orig(context, model, signal, max_tokens, tools) - - agent._stream_fn = wrapper - - -def test_mid_turn_compaction_fires_on_growth(): - # 窗口 40000 → 阈值 23616;预填 8×2000 字 ≈ 16k(轮首检查不触发)。 - # ⚠️ 脚本流的 usage 必须真实(P0 锚点机制:估算锚定到最后一条 - # assistant 的真实 usage.input + 其后新增消息)。turn1 请求时真实 - # 输入 ≈ 16053 → usage.input=16000。 - agent = make_agent( - [{"tool_calls": [{"id": "c1", "name": "midtool", - "arguments": {}}], "stop_reason": "stop", - "usage": {"input": 16000, "output": 10}}, - {"content": "done.", - "usage": {"input": 20000, "output": 10}}], - tools=[_mid_tool("midtool", "长" * 8000)], # 8k → 锚定后 ≈ 24k > 23616 - context_window=40000, - ) - _prefill_history(agent) - summarize_calls = [] - - def summarize(prompt, system_prompt, max_tokens): - summarize_calls.append(1) - return "SUMMARY-MID" - - runner = AgentRunner(agent, summarize_fn=summarize) - agent.config.compact_fn = runner.compact_if_needed # 模拟 llm_engine 接线 - captured = [] - _capture_requests(agent, captured) - - result = runner.run("go") - assert result.stop_reason == "stop" - # 轮中压缩恰好触发一次(轮首 16k 不触发;压缩后 ≈20k 不再触发) - assert len(summarize_calls) == 1 - assert runner.compactions_performed == 1 - assert any(ev.get("path") == "mid_turn" for ev in runner.compaction_events) - # 状态首条是摘要,最早两条历史被摘要掉 - assert agent.state.messages[0].kind == "compaction_summary" - assert "SUMMARY-MID" in agent.state.messages[0].content - all_text = "".join(str(getattr(m, "content", "")) for m in agent.state.messages) - assert "hist0" not in all_text and "hist1" not in all_text - assert "hist7" in all_text - # 第二次请求(压缩后的那个)上下文首条就是摘要 - assert len(captured) == 2 - assert captured[1][0].kind == "compaction_summary" - - -def test_mid_turn_compaction_failure_capped(): - # 单条巨型工具结果(30k ≥ keep_recent 20k)在尾部 → 无有效切点 - # (cut_pinned_at_zero)→ 压缩失败;连败 2 次后本轮不再尝试(防刷屏护栏)。 - # 窗口 100000 → 阈值 83616;预填 34×2000 ≈ 68k。usage 必须真实 - # (锚点估算 = 最后 asst 的 usage.input + 其后新增)。 - import core.agent.recovery as rec - calls = [] - orig = rec.compact_context - - def counting(*a, **kw): - calls.append(1) - return orig(*a, **kw) - - rec.compact_context = counting - try: - agent = make_agent( - [{"tool_calls": [{"id": "c1", "name": "midtool", - "arguments": {}}], "stop_reason": "stop", - "usage": {"input": 60000, "output": 10}}, - {"tool_calls": [{"id": "c2", "name": "midtool", - "arguments": {}}], "stop_reason": "stop", - "usage": {"input": 65000, "output": 10}}, - {"content": "done.", - "usage": {"input": 50000, "output": 10}}], - tools=[_mid_tool("midtool", "长" * 30000)], - context_window=100000, - ) - for i in range(34): - agent.state.messages.append( - AgentMessage(role="user", content=f"hist{i} " + "长" * 2000)) - - def summarize(prompt, system_prompt, max_tokens): - raise AssertionError("prepare 返回 None → 不应调用 LLM 摘要") - - runner = AgentRunner(agent, summarize_fn=summarize) - agent.config.compact_fn = runner.compact_if_needed - result = runner.run("go") - assert result.stop_reason == "stop" - # 3 次请求机会:第 2、3 次前尝试压缩(均失败),第… 耗尽后跳过 - # turn2 前: 60000+30004=90004 > 83616 → 尝试#1 失败 - # turn3 前: 65000+30004=95004 > 83616 → 尝试#2 失败 → 耗尽 - assert len(calls) == 2, f"compact_context 应恰好尝试 2 次,实际 {len(calls)}" - assert runner._mid_turn_exhausted is True - assert runner.compactions_performed == 0 - # 状态未被修改(无摘要),原样继续 - assert not any(getattr(m, "kind", None) == "compaction_summary" - for m in agent.state.messages) - finally: - rec.compact_context = orig - - -def test_mid_turn_no_compaction_below_threshold(): - # 工具结果小(2k)→ 锚定后 16000+2004 ≈ 18k < 23616 → 不压缩,正常完成 - agent = make_agent( - [{"tool_calls": [{"id": "c1", "name": "midtool", - "arguments": {}}], "stop_reason": "stop", - "usage": {"input": 16000, "output": 10}}, - {"content": "done.", - "usage": {"input": 18000, "output": 10}}], - tools=[_mid_tool("midtool", "长" * 2000)], - context_window=40000, - ) - _prefill_history(agent) - summarize_calls = [] - - def summarize(prompt, system_prompt, max_tokens): - summarize_calls.append(1) - return "SHOULD-NOT-RUN" - - runner = AgentRunner(agent, summarize_fn=summarize) - agent.config.compact_fn = runner.compact_if_needed - result = runner.run("go") - assert result.stop_reason == "stop" - assert summarize_calls == [] - assert runner.compactions_performed == 0 - assert not any(getattr(m, "kind", None) == "compaction_summary" - for m in agent.state.messages) - - -if __name__ == "__main__": - sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/test_bash_stream.py b/tests/test_bash_stream.py deleted file mode 100644 index abc75cc..0000000 --- a/tests/test_bash_stream.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- 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) diff --git a/tests/test_compaction_persist.py b/tests/test_compaction_persist.py deleted file mode 100644 index 48345e0..0000000 --- a/tests/test_compaction_persist.py +++ /dev/null @@ -1,289 +0,0 @@ -# -*- 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) diff --git a/tests/test_config_isolation.py b/tests/test_config_isolation.py deleted file mode 100644 index 5d2b62a..0000000 --- a/tests/test_config_isolation.py +++ /dev/null @@ -1,259 +0,0 @@ -# -*- 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) diff --git a/tests/test_copy_session.py b/tests/test_copy_session.py deleted file mode 100644 index c7eb720..0000000 --- a/tests/test_copy_session.py +++ /dev/null @@ -1,336 +0,0 @@ -# -*- 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) diff --git a/tests/test_cross_platform_shell.py b/tests/test_cross_platform_shell.py deleted file mode 100644 index 57171a0..0000000 --- a/tests/test_cross_platform_shell.py +++ /dev/null @@ -1,253 +0,0 @@ -# -*- 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 \"\"", - 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 ", - 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) diff --git a/tests/test_debug_window.py b/tests/test_debug_window.py deleted file mode 100644 index 308b002..0000000 --- a/tests/test_debug_window.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- 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) diff --git a/tests/test_error_persist.py b/tests/test_error_persist.py deleted file mode 100644 index 01c2d5e..0000000 --- a/tests/test_error_persist.py +++ /dev/null @@ -1,361 +0,0 @@ -# -*- 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) diff --git a/tests/test_file_attach.py b/tests/test_file_attach.py deleted file mode 100644 index eb144e7..0000000 --- a/tests/test_file_attach.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- 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() diff --git a/tests/test_global_hotkey_platforms.py b/tests/test_global_hotkey_platforms.py deleted file mode 100644 index e7646c0..0000000 --- a/tests/test_global_hotkey_platforms.py +++ /dev/null @@ -1,279 +0,0 @@ -# -*- 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) diff --git a/tests/test_main_window_event_filter.py b/tests/test_main_window_event_filter.py deleted file mode 100644 index 8e70948..0000000 --- a/tests/test_main_window_event_filter.py +++ /dev/null @@ -1,195 +0,0 @@ -# -*- 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 静态析构段错误(不影响结果) diff --git a/tests/test_math_extract.js b/tests/test_math_extract.js deleted file mode 100644 index aa1ca46..0000000 --- a/tests/test_math_extract.js +++ /dev/null @@ -1,123 +0,0 @@ -// -*- 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, '>'), - katex: { renderToString: (tex, opts) => '' + tex + '' }, - marked: { parse: (t) => '' + 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(//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); diff --git a/tests/test_pdf_reader.py b/tests/test_pdf_reader.py deleted file mode 100644 index 425935c..0000000 --- a/tests/test_pdf_reader.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- 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() diff --git a/tests/test_render_window.js b/tests/test_render_window.js deleted file mode 100644 index 1305a4a..0000000 --- a/tests/test_render_window.js +++ /dev/null @@ -1,347 +0,0 @@ -// -*- 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); diff --git a/tests/test_renderer_matrix.py b/tests/test_renderer_matrix.py deleted file mode 100644 index bfe5221..0000000 --- a/tests/test_renderer_matrix.py +++ /dev/null @@ -1,242 +0,0 @@ -# -*- 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"ok {{pid}}") - -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) diff --git a/tests/test_screen_capture_platforms.py b/tests/test_screen_capture_platforms.py deleted file mode 100644 index 12d8c8a..0000000 --- a/tests/test_screen_capture_platforms.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- 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) diff --git a/tests/test_think_code_neutral.py b/tests/test_think_code_neutral.py deleted file mode 100644 index f8ba920..0000000 --- a/tests/test_think_code_neutral.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- 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) diff --git a/tests/test_tool_params.py b/tests/test_tool_params.py deleted file mode 100644 index 9188ed3..0000000 --- a/tests/test_tool_params.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- 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 不能为空'(旧版报'文件不存在: ')", - 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) diff --git a/tests/test_wv2_guard.py b/tests/test_wv2_guard.py deleted file mode 100644 index dc6e316..0000000 --- a/tests/test_wv2_guard.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- 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) diff --git a/tests/tune_mode_popup.py b/tests/tune_mode_popup.py deleted file mode 100644 index 4e0de55..0000000 --- a/tests/tune_mode_popup.py +++ /dev/null @@ -1,186 +0,0 @@ -# -*- 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() diff --git a/tests/tune_model_popup.py b/tests/tune_model_popup.py deleted file mode 100644 index b5d0f3b..0000000 --- a/tests/tune_model_popup.py +++ /dev/null @@ -1,399 +0,0 @@ -# -*- 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() diff --git a/tests/verify_math_render.py b/tests/verify_math_render.py deleted file mode 100644 index c3d3e75..0000000 --- a/tests/verify_math_render.py +++ /dev/null @@ -1,151 +0,0 @@ -# -*- 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) diff --git a/tests/verify_onscreen.py b/tests/verify_onscreen.py deleted file mode 100644 index dd85190..0000000 --- a/tests/verify_onscreen.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- 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() diff --git a/ui/views/main_window.py b/ui/views/main_window.py index 9365e9e..bbd70c7 100644 --- a/ui/views/main_window.py +++ b/ui/views/main_window.py @@ -1,4 +1,5 @@ import sys +import copy from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtWebEngineWidgets import QWebEngineView # 引入我们刚才写的线程工作类 @@ -422,20 +423,27 @@ 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.config_data = config_data or {} + 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._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.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) self.overlay = QtWidgets.QWidget(self) self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);") @@ -465,7 +473,8 @@ class SettingsWindow(QtWidgets.QWidget): body_layout.addWidget(self.sidebar) self.content_stack = QtWidgets.QStackedWidget() - self.content_stack.addWidget(self._build_provider_section()) + self.provider_page = self._build_provider_section() + self.content_stack.addWidget(self.provider_page) self.content_stack.addWidget(self._build_placeholder_section()) body_layout.addWidget(self.content_stack, 1) @@ -473,13 +482,24 @@ 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) # ------------------------------------------------------------------ @@ -573,6 +593,8 @@ 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) @@ -590,6 +612,8 @@ 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) @@ -608,7 +632,9 @@ 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;" @@ -635,6 +661,9 @@ 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("+模型") @@ -647,11 +676,15 @@ 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 = config.get("models", []) + models = self._model_names(config) if models: models_wrap = QtWidgets.QWidget() models_layout = QtWidgets.QVBoxLayout(models_wrap) @@ -659,7 +692,9 @@ class SettingsWindow(QtWidgets.QWidget): models_layout.setSpacing(5) for model_name in models: - mbtn = QtWidgets.QPushButton(model_name) + mbtn = QtWidgets.QPushButton(str(model_name)) + mbtn.setProperty("providerName", name) + mbtn.setProperty("modelName", model_name) mbtn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开 mbtn.setStyleSheet( @@ -672,7 +707,8 @@ class SettingsWindow(QtWidgets.QWidget): "QPushButton:hover { background-color: #d0ebff; }" ) mbtn.clicked.connect( - lambda checked, m=model_name: print(f"[设置] 选中模型: {m}") + lambda checked=False, provider=name, model=model_name: + self._select_model(provider, model) ) models_layout.addWidget(mbtn) @@ -686,6 +722,192 @@ 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() @@ -727,11 +949,14 @@ class SettingsWindow(QtWidgets.QWidget): # ------------------------------------------------------------------ def resize_to_parent(self): - parent = self.parentWidget() - if not parent: + if self._main is None: return - self.setFixedSize(parent.size()) - self.overlay.setFixedSize(self.size()) + + # 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()) W = int(self.width() * 0.8) H = int(self.height() * 0.88) @@ -743,72 +968,66 @@ class SettingsWindow(QtWidgets.QWidget): # ------------------------------------------------------------------ def _setup_animation(self): - self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity") - self._anim.setDuration(500) + self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + self._anim.setDuration(160) self._anim.setStartValue(0.0) self._anim.setEndValue(1.0) # ------------------------------------------------------------------ def show_with_animation(self): - # 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下 - self.setFixedSize(self.parentWidget().size()) - self.overlay.setFixedSize(self.size()) - self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect - - # 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入 + self.resize_to_parent() + self.setWindowOpacity(0.0) 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: - for attr_name in dir(parent): - try: - if getattr(parent, attr_name, None) is self: - setattr(parent, attr_name, None) - break - except Exception: - pass + if parent is not None and getattr(parent, "_settings_win", None) is self: + parent._settings_win = None self.deleteLater() self.close_requested.emit() # ------------------------------------------------------------------ def _on_close_finished(self): - 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() + self._do_close() # ------------------------------------------------------------------ def keyPressEvent(self, event): @@ -1725,6 +1944,7 @@ 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() @@ -1820,13 +2040,62 @@ 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() + 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(): - models = info.get("models", []) + if not isinstance(provider_name, str) or not provider_name.strip(): + continue + models = self._model_names(info) if not models: continue # --- 🆕 供应商分组头(整行可点:展开/收起抽屉) --- @@ -1922,6 +2191,38 @@ 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 段(整体下滑回座按钮锚点) @@ -1946,7 +2247,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: @@ -1960,12 +2261,11 @@ class ModelSelectPopup(QtWidgets.QWidget): # SVG 箭头切换(展开=朝下 / 收起=朝右) g["chevron"].setPixmap( self._arrow_collapsed if not g["expanded"] else self._arrow_expanded) - if not self.isVisible(): - # 防御:弹窗未显示时直接应用终态(不播动画) - self._apply_group_state(g) - self._apply_height_and_position() - return - self._start_drawer(g) + self._clear_fx() + self._apply_group_state(g) + self.list_widget.doItemsLayout() + self._apply_height_and_position() + self.list_widget.viewport().update() def _apply_group_state(self, g): """应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)""" @@ -5446,6 +5746,13 @@ 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) @@ -5458,19 +5765,34 @@ 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)) @@ -5489,6 +5811,16 @@ 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 @@ -6106,14 +6438,20 @@ class MainWindow(QtWidgets.QMainWindow): return widget def show_settings(self): - if hasattr(self, '_settings_win') and self._settings_win is not None: - self._settings_win.deleteLater() - self._settings_win = None + existing = getattr(self, '_settings_win', None) + if existing is not None: + existing._close(immediate=True) + + if hasattr(self, "model_popup"): + self.model_popup.hide() 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): """🌟 核心:处理用户点击左右箭头切换分支""" diff --git a/ui/views/system_tools/screen_capture.py b/ui/views/system_tools/screen_capture.py index 8d8ca07..ea567b9 100644 --- a/ui/views/system_tools/screen_capture.py +++ b/ui/views/system_tools/screen_capture.py @@ -74,13 +74,44 @@ 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(0, 0, self._full_pixmap) + painter.drawPixmap(self.rect(), self._full_pixmap, self._full_pixmap.rect()) # 2. 半透明遮罩 painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100)) @@ -92,15 +123,17 @@ 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, rect) + painter.drawPixmap(rect, self._full_pixmap, native_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"{rect.width()} x {rect.height()}" + # 标签显示最终文件的真实像素,而不是受系统缩放影响的逻辑尺寸。 + size_text = f"{native_rect.width()} x {native_rect.height()} px" font = painter.font() font.setPointSize(9) painter.setFont(font) @@ -177,15 +210,12 @@ class ScreenCaptureOverlay(QtWidgets.QWidget): def _on_confirm(self): """确认截图:裁剪并发射信号""" if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5: - 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) + 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) self.close() def _on_cancel(self): diff --git a/ui/views/wv2_view.py b/ui/views/wv2_view.py index edb646b..fd1b59e 100644 --- a/ui/views/wv2_view.py +++ b/ui/views/wv2_view.py @@ -152,19 +152,32 @@ class WebView2View(QtWidgets.QWidget): # ---------- 几何同步 ---------- def _dpr(self): try: - d = self.dpr() + d = float(self.devicePixelRatioF()) 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 = p.x() * d, p.y() * d - W, H = max(1, self.width() * d), max(1, self.height() * d) + L, T, W, H = self._physical_bounds( + p.x(), p.y(), self.width(), self.height(), d) # 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏) # 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 —— # 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上; diff --git a/ui/web/style.css b/ui/web/style.css index 608b3a9..5d0a597 100644 --- a/ui/web/style.css +++ b/ui/web/style.css @@ -893,10 +893,16 @@ body, html { } -/* ========== 助手正文透明气泡(流式中/完成后一致) ========== - 🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */ -/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确: - 原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */ +/* ========== 助手正文气泡(流式中/完成后一致) ========== */ +.message-wrapper.assistant .reply-content .md-segment { + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + color: #30343b; + background-color: #f6f7f9; + border: 1px solid #eceff3; + border-radius: 8px; +} .message-wrapper.assistant .reply-content .md-segment + .md-segment { margin-top: 6px; }