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