# -*- 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)