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:
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tests/test_main_window_event_filter.py —— P0-02 事件策略回归
|
||||
|
||||
运行: python tests/test_main_window_event_filter.py
|
||||
(仓库惯例:无 pytest 依赖,独立可跑;GUI 走 offscreen)
|
||||
|
||||
完成证据(REPAIR_BACKLOG.md P0-02):
|
||||
1. AST 静态断言:MainWindow 只有一个 eventFilter;
|
||||
2. 四种键盘状态:Enter 可发送 / 按钮禁用时 Enter 不发送 / 流式生成时 Enter 被拦截 /
|
||||
Shift+Enter 放行换行;
|
||||
3. 一次按键对应最多一次 send_message 调用(计数 wrapper 验证);
|
||||
4. 其他键与事件继续交给父类(按 a 正常插入字符、不触发发送)。
|
||||
遵守 P0-01:临时配置 + 临时数据库,在 import MainWindow 之前完成。
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_TESTS_DIR)
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
from tests._test_env import isolate # noqa: E402
|
||||
|
||||
_TMP = isolate("winfilter") # 必须在 import MainWindow 之前
|
||||
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
print((" PASS " if cond else " FAIL ") + name +
|
||||
("" if cond else f" {extra}"), flush=True)
|
||||
if not cond:
|
||||
ok = False
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 1) AST 静态断言:MainWindow 只有一个 eventFilter
|
||||
# ======================================================================
|
||||
with open(os.path.join(_ROOT, "ui", "views", "main_window.py"),
|
||||
encoding="utf-8") as f:
|
||||
_tree = ast.parse(f.read())
|
||||
_mw = [n for n in _tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "MainWindow"][0]
|
||||
_ef_lines = [n.lineno for n in _mw.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "eventFilter"]
|
||||
check("AST:MainWindow 只有一个 eventFilter", len(_ef_lines) == 1,
|
||||
f"行号: {_ef_lines}")
|
||||
|
||||
# ======================================================================
|
||||
# 2) 四种键盘状态(真实事件派发:QApplication.sendEvent → 已安装过滤器 → 控件本身)
|
||||
# ======================================================================
|
||||
from PyQt6 import QtGui, QtCore # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402
|
||||
import core.llm_engine as le # noqa: E402
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
win = MainWindow()
|
||||
|
||||
if not win.current_session_id:
|
||||
win.on_new_chat_clicked()
|
||||
sid = win.current_session_id
|
||||
check("前置:存在当前会话", bool(sid))
|
||||
|
||||
# 计数 wrapper:包住真实 send_message(记录调用并透传执行)
|
||||
calls = []
|
||||
_orig_send = win.send_message
|
||||
|
||||
|
||||
def _spy_send(*a, **k):
|
||||
calls.append(k)
|
||||
return _orig_send(*a, **k)
|
||||
|
||||
|
||||
win.send_message = _spy_send
|
||||
|
||||
|
||||
def _press(key, shift=False, text=""):
|
||||
"""通过 Qt 事件系统向输入框派发一次真实的 KeyPress。"""
|
||||
mods = (QtCore.Qt.KeyboardModifier.ShiftModifier if shift
|
||||
else QtCore.Qt.KeyboardModifier.NoModifier)
|
||||
ev = QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, key, mods, text)
|
||||
QApplication.sendEvent(win.text_input, ev)
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _clear_stream():
|
||||
"""确保当前会话没有残留流(等待 worker 错误自清理,兜底手工清理)。"""
|
||||
for _ in range(100):
|
||||
if sid not in win._active_streams:
|
||||
return
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
st = win._active_streams.pop(sid, None)
|
||||
if st and st.get("worker") is not None:
|
||||
try:
|
||||
st["worker"].abort()
|
||||
st["worker"].wait(2000)
|
||||
except Exception:
|
||||
pass
|
||||
win.set_send_button_state(False)
|
||||
|
||||
|
||||
# ---- A) Enter 可发送(空闲 + 按钮可用 + 有文本) ----
|
||||
# 网络层桩:立即抛 ConnectionError(等价于不可达端口),走真实错误清理路径
|
||||
_REAL_OPENAI_STREAM = le.openai_stream
|
||||
|
||||
|
||||
def _fake_openai_stream(*a, **k):
|
||||
raise ConnectionError("test stub: no network")
|
||||
yield # 保持生成器函数形态
|
||||
|
||||
|
||||
le.openai_stream = _fake_openai_stream
|
||||
win.btn_send.setEnabled(True)
|
||||
win.text_input.setPlainText("p002 enter send")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("A1 一次 Enter 至多一次 send_message 调用", len(calls) == 1,
|
||||
f"调用次数: {len(calls)}")
|
||||
check("A2 Enter 走 from_enter 规则", calls and calls[0].get("from_enter") is True,
|
||||
f"kwargs: {calls}")
|
||||
check("A3 发送已同步执行(流已注册)", sid in win._active_streams)
|
||||
check("A4 输入框被清空", win.text_input.toPlainText() == "")
|
||||
_clear_stream()
|
||||
check("A5 流清理完毕(错误路径自恢复)", sid not in win._active_streams)
|
||||
le.openai_stream = _REAL_OPENAI_STREAM
|
||||
|
||||
# ---- B) 发送按钮禁用时 Enter 不发送 ----
|
||||
_clear_stream()
|
||||
win.btn_send.setEnabled(False)
|
||||
win.text_input.setPlainText("disabled should not send")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("B1 禁用时仍至多一次调用(规则在 send_message 内单一实现)",
|
||||
len(calls) <= 1, f"调用次数: {len(calls)}")
|
||||
check("B2 禁用时未发送(无流)", sid not in win._active_streams)
|
||||
check("B3 禁用时输入内容保留", win.text_input.toPlainText() == "disabled should not send")
|
||||
win.btn_send.setEnabled(True)
|
||||
|
||||
# ---- C) 流式生成时 Enter 被拦截(不发送、不触发中断) ----
|
||||
win.set_send_button_state(True) # 与真实流式状态一致(停止图标,按钮仍可用)
|
||||
_fake_stream = {"msg_id": "fake-ai", "content": "", "worker": None, "timeline": [],
|
||||
"parent_id": None, "branch_info": {"current": 1, "total": 1}}
|
||||
win._active_streams[sid] = _fake_stream
|
||||
win.text_input.setPlainText("typing while generating")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return)
|
||||
check("C1 流式时未产生真实发送(流仍是注入的假流)",
|
||||
win._active_streams.get(sid) is _fake_stream)
|
||||
check("C2 流式时未触发中断(假流字段未被改动)",
|
||||
_fake_stream["msg_id"] == "fake-ai" and _fake_stream["worker"] is None)
|
||||
check("C3 流式时输入内容保留", win.text_input.toPlainText() == "typing while generating")
|
||||
del win._active_streams[sid]
|
||||
win.set_send_button_state(False)
|
||||
|
||||
# ---- D) Shift+Enter 放行换行 ----
|
||||
win.text_input.setPlainText("abc")
|
||||
_cur = win.text_input.textCursor()
|
||||
_cur.movePosition(QtGui.QTextCursor.MoveOperation.End)
|
||||
win.text_input.setTextCursor(_cur)
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_Return, shift=True)
|
||||
check("D1 Shift+Enter 未调用 send_message", len(calls) == 0,
|
||||
f"调用次数: {len(calls)}")
|
||||
check("D2 Shift+Enter 插入换行(事件放行到输入框)",
|
||||
win.text_input.toPlainText() == "abc\n",
|
||||
repr(win.text_input.toPlainText()))
|
||||
check("D3 Shift+Enter 未发送(无流)", sid not in win._active_streams)
|
||||
|
||||
# ---- E) 其他键/事件继续交给父类 ----
|
||||
win.text_input.setPlainText("")
|
||||
calls.clear()
|
||||
_press(QtCore.Qt.Key.Key_A, text="a")
|
||||
check("E1 普通字符键正常插入", win.text_input.toPlainText() == "a",
|
||||
repr(win.text_input.toPlainText()))
|
||||
check("E2 普通字符键不触发发送", len(calls) == 0)
|
||||
|
||||
# ---- 收尾 ----
|
||||
try:
|
||||
win.close()
|
||||
except Exception:
|
||||
pass
|
||||
app.quit()
|
||||
|
||||
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||
os._exit(0 if ok else 1) # 避免 QtWebEngine offscreen 静态析构段错误(不影响结果)
|
||||
Reference in New Issue
Block a user