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:
@@ -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)
|
||||
Reference in New Issue
Block a user