Files
Haocode/tests/smoke_bash_panel.py
T
2026-09-17 16:30:02 +08:00

738 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)