# -*- 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") # 铁律:测试不得污染真实 data/chat_history.db import core.db_manager as _dbm # noqa: E402 _DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_bashpanel_{os.getpid()}.db") if os.path.exists(_DB_TMP): os.remove(_DB_TMP) _dbm._DEFAULT_DB = _DB_TMP # 铁律:宽度记录会写 config.json → 测试指向临时配置,绝不碰真实 data/config.json _CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_cfg_{os.getpid()}.json") with open(_CFG_TMP, "w", encoding="utf-8") as _f: _f.write('{"providers": {}, "mode_switch": true}') os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP 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)) 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) sys.exit(0 if ok else 1)