fix(ui): main window fixes - event filter, bash layer order, scrollbar styles, rename overlay

- merge duplicated eventFilter paths and gate send-by-Enter (P0-02)
- display bash task layers in reverse start-time order while reusing
  layer instances and preserving scroll/expand state (P2-01)
- add scoped 8px scrollbars with matching corner for the bash code box
  (P2-02)
- rewrite RenameOverlay as a top-level transparent tool window so it
  can cover the native WebView2 child HWND (P2-03)
This commit is contained in:
2026-09-17 16:40:04 +08:00
parent ef00f64435
commit 95024785ae
3 changed files with 592 additions and 182 deletions
+195
View File
@@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
"""tests/test_main_window_event_filter.py —— P0-02 事件策略回归
运行: python tests/test_main_window_event_filter.py
(仓库惯例:无 pytest 依赖,独立可跑;GUI 走 offscreen
完成证据(REPAIR_BACKLOG.md P0-02):
1. AST 静态断言:MainWindow 只有一个 eventFilter
2. 四种键盘状态:Enter 可发送 / 按钮禁用时 Enter 不发送 / 流式生成时 Enter 被拦截 /
Shift+Enter 放行换行;
3. 一次按键对应最多一次 send_message 调用(计数 wrapper 验证);
4. 其他键与事件继续交给父类(按 a 正常插入字符、不触发发送)。
遵守 P0-01:临时配置 + 临时数据库,在 import MainWindow 之前完成。
"""
import ast
import os
import sys
import time
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
sys.path.insert(0, _ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from tests._test_env import isolate # noqa: E402
_TMP = isolate("winfilter") # 必须在 import MainWindow 之前
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
# ======================================================================
# 1) AST 静态断言:MainWindow 只有一个 eventFilter
# ======================================================================
with open(os.path.join(_ROOT, "ui", "views", "main_window.py"),
encoding="utf-8") as f:
_tree = ast.parse(f.read())
_mw = [n for n in _tree.body
if isinstance(n, ast.ClassDef) and n.name == "MainWindow"][0]
_ef_lines = [n.lineno for n in _mw.body
if isinstance(n, ast.FunctionDef) and n.name == "eventFilter"]
check("ASTMainWindow 只有一个 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 静态析构段错误(不影响结果)