Offscreen harnesses now resize+show before load, use per-instance webengine profile dirs and explicit exit codes; compaction and bash stream suites follow the renamed internals.
738 lines
38 KiB
Python
738 lines
38 KiB
Python
# -*- 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)
|