chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+614
View File
@@ -0,0 +1,614 @@
# -*- 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)