test: update smoke and regression suites for dual-renderer paths
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.
This commit is contained in:
+187
-53
@@ -1,75 +1,209 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""问题 3 取证:右侧栏 bash 层「参数/输出」框的滚动条 QSS 现状(离屏截图 + 几何/样式打印)"""
|
"""P2-02 诊断:右侧 Bash 面板滚动条与横纵交汇角(离屏)
|
||||||
|
|
||||||
|
测量并断言:
|
||||||
|
S1 代码框(#bl_code)横滚动条实际厚度 = 8px、sizeHint 一致
|
||||||
|
S2 代码框竖滚动条实际厚度 = 8px、sizeHint 一致
|
||||||
|
S3 section 滚动区(#bl_scroll)竖滚动条实际厚度 = 8px
|
||||||
|
S4 面板滚动条箭头 extent = 0(箭头隐藏)
|
||||||
|
S5 交汇角像素 = 代码框背景 #fbfcfe(无原生亮色 corner 方块)
|
||||||
|
S6 无泄漏:未命名 QPlainTextEdit 的滚动条仍是原生口径(≠8px、箭头>0)
|
||||||
|
S7 无泄漏:附件预览滚动条保持自身 6px 口径
|
||||||
|
并生成局部截图(面板全貌 + 代码框角落放大)打印测量值。
|
||||||
|
|
||||||
|
运行: QT_QPA_PLATFORM=offscreen python tests/diag_panel_scrollbar.py
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||||
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
import core.db_manager as _dbm # noqa: E402
|
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||||
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.db")
|
from tests._test_env import isolate # noqa: E402
|
||||||
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.json")
|
_TMP = isolate("panelscroll", config={"providers": {}, "mode_switch": True})
|
||||||
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
|
_DB_TMP = _TMP["db"]
|
||||||
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
|
_CFG_TMP = _TMP["config"]
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import (QApplication, QPlainTextEdit, QStyle, # noqa: E402
|
||||||
|
QStyleOptionSlider) # noqa: E402
|
||||||
from PyQt6.QtTest import QTest # noqa: E402
|
from PyQt6.QtTest import QTest # noqa: E402
|
||||||
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
from ui.views.main_window import MainWindow # noqa: E402
|
from ui.views.main_window import MainWindow # noqa: E402
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
w = MainWindow()
|
ok = True
|
||||||
w.resize(1400, 950)
|
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "agent-handoff", "evidence")
|
||||||
w.show()
|
|
||||||
for _ in range(40):
|
|
||||||
|
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=300):
|
||||||
|
for _ in range(int(ms / 20) + 1):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
QTest.qWait(20)
|
QTest.qWait(20)
|
||||||
|
|
||||||
p = w.bash_panel
|
|
||||||
p.expand_btn.click()
|
|
||||||
for _ in range(30):
|
|
||||||
app.processEvents()
|
|
||||||
QTest.qWait(20)
|
|
||||||
|
|
||||||
# 造一个「已完成 + 长输出」的层(长输出会把垂直/水平滚动条都逼出来)
|
def arrow_extent(sb, orient=Qt.Orientation.Vertical):
|
||||||
long_cmd = "python train.py --epochs 300 --batch-size 64 --lr 0.0001 --data /data/detr/annotations.json"
|
"""滚动条箭头子控件(sub-line)的实际尺寸(px):QSS 把 add-line/sub-line 置 0 后应为 0。
|
||||||
long_out = "\n".join(
|
用 sb.style()(样式表代理风格)才能反映 QSS 效果;PyQt6 参数序 = (cc, opt, sc, widget)。
|
||||||
f"[{i:04d}] epoch loss=0.{i % 9}{i % 7} lr=0.0001 mAP=0.{40 + i % 20} "
|
返回 -2 表示无法测量(样式代理缺失等),调用方不得把 -2 当作 0。"""
|
||||||
f"very_long_tail_to_force_horizontal_scrollbar_{i}"
|
try:
|
||||||
for i in range(120))
|
st = sb.style()
|
||||||
p.on_started("q3_lay", "bash", {"command": long_cmd})
|
if orient == Qt.Orientation.Vertical:
|
||||||
p.on_finished("q3_lay", "bash", True, long_out)
|
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
|
||||||
for _ in range(20):
|
QStyleOptionSlider(),
|
||||||
app.processEvents()
|
QStyle.SubControl.SC_ScrollBarSubLine, sb).height()
|
||||||
QTest.qWait(20)
|
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
|
||||||
|
QStyleOptionSlider(),
|
||||||
|
QStyle.SubControl.SC_ScrollBarSubLine, sb).width()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (arrow_extent 测量异常: {e})", flush=True)
|
||||||
|
return -1
|
||||||
|
|
||||||
lay = p._layers["q3_lay"]
|
|
||||||
lay.toggle() # 展开层 → 显示「参数」「输出」两块
|
|
||||||
for _ in range(20):
|
|
||||||
app.processEvents()
|
|
||||||
QTest.qWait(20)
|
|
||||||
|
|
||||||
for nm, box in (("参数 arg_box", lay.arg_box), ("输出 out_box", lay.out_box)):
|
window = MainWindow()
|
||||||
vb = box.verticalScrollBar()
|
window.resize(1400, 800)
|
||||||
hb = box.horizontalScrollBar()
|
window.show()
|
||||||
print(f"--- {nm}")
|
settle(400)
|
||||||
print(f" objectName={box.objectName()} 尺寸={box.width()}x{box.height()}")
|
panel = window.bash_panel
|
||||||
print(f" 垂直滚动条: 可见={vb.isVisible()} 宽={vb.width()} 需要={vb.maximum() > 0}")
|
panel.expand_btn.click()
|
||||||
print(f" 水平滚动条: 可见={hb.isVisible()} 高={hb.height()} 需要={hb.maximum() > 0}")
|
settle(500)
|
||||||
print(f" box 自身 stylesheet = {box.styleSheet()!r}")
|
|
||||||
print(f" box 背景角色 = {box.palette().base().color().name()}")
|
|
||||||
|
|
||||||
img = lay.grab().toImage()
|
# ---- 造数据:一个展开的层,out_box 同时触发横/纵滚动条 ----
|
||||||
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_layer.png")
|
panel.on_started("sc1", "bash", {"command": "python long_report.py --all --verbose"})
|
||||||
img.save(out)
|
panel.on_finished("sc1", "bash", True,
|
||||||
print("\n层截图 ->", out, img.width(), "x", img.height())
|
"$ python long_report.py --all --verbose\n"
|
||||||
|
+ "\n".join(f"line-{i:03d}" for i in range(60))
|
||||||
|
+ "\n[exit 0] (1.2s)")
|
||||||
|
settle(300)
|
||||||
|
lay = panel._layers["sc1"]
|
||||||
|
lay.toggle()
|
||||||
|
settle(200)
|
||||||
|
# 超宽单行(NoWrap)→ 横向滚动条;再追加 60 行 → 同时触发纵向滚动条
|
||||||
|
panel._layers["sc1"].out_box.setPlainText("X" * 3000 + "\n"
|
||||||
|
+ "\n".join(f"tail-{i:03d}" for i in range(60)))
|
||||||
|
settle(200)
|
||||||
|
|
||||||
# 再抓取面板整块(看滚动条在面板里的观感)
|
ob = lay.out_box
|
||||||
img2 = p.grab().toImage()
|
sbh = ob.horizontalScrollBar()
|
||||||
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_panel.png")
|
sbv = ob.verticalScrollBar()
|
||||||
img2.save(out2)
|
check("S0.1 前置:横滚动条可见(超宽单行)", sbh.isVisible(), str(sbh.isVisible()))
|
||||||
print("面板截图 ->", out2, img2.width(), "x", img2.height())
|
check("S0.2 前置:竖滚动条可见(60+ 行超出 230px 上限)", sbv.isVisible(), str(sbv.isVisible()))
|
||||||
|
|
||||||
os.remove(_cfg)
|
# ---- S1/S2 代码框滚动条厚度 ----
|
||||||
|
check("S1 代码框横滚动条实际厚度 = 8px", sbh.height() == 8, f"h={sbh.height()}")
|
||||||
|
check("S1b 代码框横滚动条 sizeHint 厚 = 8px", sbh.sizeHint().height() == 8,
|
||||||
|
f"{sbh.sizeHint().height()}")
|
||||||
|
check("S2 代码框竖滚动条实际厚度 = 8px", sbv.width() == 8, f"w={sbv.width()}")
|
||||||
|
check("S2b 代码框竖滚动条 sizeHint 宽 = 8px", sbv.sizeHint().width() == 8,
|
||||||
|
f"{sbv.sizeHint().width()}")
|
||||||
|
|
||||||
|
# ---- S3 section 滚动区竖滚动条厚度 ----
|
||||||
|
# 让运行中栏溢出:再加 12 个已完成层(复用 P11 的溢出手法不必,层数即可)
|
||||||
|
for i in range(12):
|
||||||
|
cid = f"scf{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(300)
|
||||||
|
sdv = panel.sec_done.scroll.verticalScrollBar()
|
||||||
|
check("S0.3 前置:已完成栏溢出、竖滚动条可见", sdv.isVisible(), str(sdv.isVisible()))
|
||||||
|
check("S3 section 竖滚动条实际厚度 = 8px", sdv.width() == 8, f"w={sdv.width()}")
|
||||||
|
|
||||||
|
# ---- S4 箭头 extent ----
|
||||||
|
print(f" 测量:代码框 H 箭头 extent = {arrow_extent(sbh, Qt.Orientation.Horizontal)}px, "
|
||||||
|
f"V = {arrow_extent(sbv)}px; section V = {arrow_extent(sdv)}px",
|
||||||
|
flush=True)
|
||||||
|
check("S4 代码框横滚动条箭头 extent = 0", arrow_extent(sbh, Qt.Orientation.Horizontal) == 0,
|
||||||
|
str(arrow_extent(sbh, Qt.Orientation.Horizontal)))
|
||||||
|
check("S4b 代码框竖滚动条箭头 extent = 0", arrow_extent(sbv) == 0, str(arrow_extent(sbv)))
|
||||||
|
check("S4c section 竖滚动条箭头 extent = 0", arrow_extent(sdv) == 0, str(arrow_extent(sdv)))
|
||||||
|
|
||||||
|
# ---- S5 交汇角(render 到透明 pixmap 取样;offscreen 下文档区背景不填充,
|
||||||
|
# 但样式子控件(滚动条/边框/corner)正常渲染,可直接断言)----
|
||||||
|
from PyQt6.QtGui import QPainter, QPixmap # noqa: E402
|
||||||
|
w, h = ob.width(), ob.height()
|
||||||
|
pm = QPixmap(w, h); pm.fill(Qt.GlobalColor.transparent)
|
||||||
|
pr = QPainter(pm); ob.render(pr); pr.end()
|
||||||
|
img = pm.toImage()
|
||||||
|
def px_at(x, y):
|
||||||
|
c = img.pixelColor(x, y)
|
||||||
|
return (c.red(), c.green(), c.blue())
|
||||||
|
CORNER_BG = (0xfb, 0xfc, 0xfe) # #bl_code 背景色 = QSS 里 corner 规则的目标色
|
||||||
|
n_bg = n_white = 0
|
||||||
|
for dy in range(8):
|
||||||
|
for dx in range(8):
|
||||||
|
c = px_at(w - 1 - dx, h - 1 - dy)
|
||||||
|
if c == CORNER_BG:
|
||||||
|
n_bg += 1
|
||||||
|
if c == (255, 255, 255):
|
||||||
|
n_white += 1
|
||||||
|
# 健全性:样式确已作用到该框(文本色 #243043 与 handle #d0d0d0 应在渲染图中出现)
|
||||||
|
n_text = sum(1 for y in range(0, h, 2) for x in range(0, w, 2)
|
||||||
|
if px_at(x, y) == (0x24, 0x30, 0x43))
|
||||||
|
n_handle = sum(1 for y in range(h - 10, h) for x in range(0, w - 12, 2)
|
||||||
|
if px_at(x, y) == (0xd0, 0xd0, 0xd0))
|
||||||
|
print(f" 测量:corner 8x8 内 #fbfcfe 像素 = {n_bg},亮白(255,255,255)像素 = {n_white},"
|
||||||
|
f"文本色像素 = {n_text},handle色像素 = {n_handle}", flush=True)
|
||||||
|
check("S5 健全性:样式已作用于该框(文本色出现)", n_text > 0, f"n_text={n_text}")
|
||||||
|
check("S5b 健全性:handle #d0d0d0 出现在横滚动条带", n_handle > 0, f"n_handle={n_handle}")
|
||||||
|
check("S5c 交汇角渲染出 #fbfcfe(= 代码框背景,::corner 规则生效)",
|
||||||
|
n_bg >= 1, f"n_bg={n_bg}")
|
||||||
|
check("S5d 交汇角无原生亮白方块(255,255,255)", n_white == 0, f"n_white={n_white}")
|
||||||
|
img.save(os.path.join(OUT_DIR, "p2-02-outbox-render.png"))
|
||||||
|
|
||||||
|
# ---- S6 无泄漏:未命名 QPlainTextEdit 仍为原生口径 ----
|
||||||
|
probe = QPlainTextEdit()
|
||||||
|
probe.setPlainText("Y" * 3000)
|
||||||
|
probe.resize(200, 120)
|
||||||
|
probe.show()
|
||||||
|
settle(150)
|
||||||
|
psb = probe.horizontalScrollBar()
|
||||||
|
probe_sb_extent = arrow_extent(psb, Qt.Orientation.Horizontal)
|
||||||
|
check("S6 未命名代码框横滚动条非面板口径(原生厚≠8 或 有箭头)",
|
||||||
|
(psb.height() != 8) or probe_sb_extent > 0,
|
||||||
|
f"h={psb.height()} extent={probe_sb_extent}")
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
# ---- S7 无泄漏:附件预览滚动条保持自身 6px 口径 ----
|
||||||
|
att_sb = window.attachment_scroll_area.horizontalScrollBar()
|
||||||
|
att_hint_h = att_sb.sizeHint().height()
|
||||||
|
check("S7 附件预览横滚动条保持 6px(自身 QSS 未被面板规则覆盖)",
|
||||||
|
att_hint_h == 6, f"sizeHint.h={att_hint_h}")
|
||||||
|
|
||||||
|
# ---- 截图(局部:面板全貌 + 代码框角落放大 4x)----
|
||||||
|
# 注意:offscreen 下 ob.grab() 的文档区不填充(黑图),角落放大图从 S5 的
|
||||||
|
# render 图(样式子控件已正常渲染)裁出,才有证据价值
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
panel_path = os.path.join(OUT_DIR, "p2-02-panel.png")
|
||||||
|
panel.grab().save(panel_path)
|
||||||
|
crop = img.copy(max(0, w - 60), max(0, h - 60), 60, 60)
|
||||||
|
scaled = crop.scaled(240, 240,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.FastTransformation)
|
||||||
|
corner_path = os.path.join(OUT_DIR, "p2-02-codebox-corner-4x.png")
|
||||||
|
scaled.save(corner_path)
|
||||||
|
print(f" 截图:{panel_path}", flush=True)
|
||||||
|
print(f" 截图:{corner_path}(代码框右下角 60x60 → 4x)", flush=True)
|
||||||
|
|
||||||
|
# ---- 收尾测量汇总 ----
|
||||||
|
print(f"\n 汇总:代码框 H={sbh.height()}px V={sbv.width()}px | "
|
||||||
|
f"section V={sdv.width()}px | corner #fbfcfe 像素={n_bg} | "
|
||||||
|
f"未命名框 H={psb.height()}px extent={probe_sb_extent} | 附件 H hint={att_hint_h}px",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
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 铁律:os._exit 强制收尾(QtWebEngine 子进程可能不回收)
|
||||||
|
os._exit(0 if ok else 1)
|
||||||
|
|||||||
+251
-63
@@ -1,74 +1,262 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""问题 5 取证:会话改名覆盖层(RenameOverlay)的"聚光灯"遮罩现状"""
|
"""P2-03 结构+行为诊断:重命名遮罩 = 独立顶层透明窗(可盖住 WebView2 原生子窗)。
|
||||||
|
|
||||||
|
覆盖修复目标与硬约束:
|
||||||
|
R1 结构:顶层窗(非 bg_widget 子控件)、Tool+无边框、WA_TranslucentBackground、WA_DeleteOnClose
|
||||||
|
R2 几何:覆盖主窗口客户区(标题栏/窗口控制不被盖)、卡片居中、输入框初始全选
|
||||||
|
R3 跟随:主窗口移动/缩放/窗口状态变化 → 遮罩同步(move/resize/WindowStateChange 事件过滤器)
|
||||||
|
R4 行为:Enter 提交(renamed 信号→DB+侧栏)、Esc 关闭、点空白关闭、✕ 关闭、取消关闭
|
||||||
|
R5 释放:关闭后顶层窗口消失、无残留(deleteLater + 事件过滤器卸载)
|
||||||
|
R6 焦点(软检查,仅打印):关闭后焦点回主窗口
|
||||||
|
|
||||||
|
隔离 + offscreen;os._exit 收尾。
|
||||||
|
用法: QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software .venv/Scripts/python.exe tests/diag_rename_overlay.py
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||||
|
|
||||||
import core.db_manager as _dbm # noqa: E402
|
from tests._test_env import isolate
|
||||||
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.db")
|
isolate()
|
||||||
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.json")
|
|
||||||
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
|
|
||||||
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||||
from PyQt6.QtTest import QTest # noqa: E402
|
from PyQt6.QtTest import QTest
|
||||||
from ui.views.main_window import MainWindow, RenameOverlay, SessionContextPopup # noqa: E402
|
from ui.views.main_window import MainWindow, RenameOverlay
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
results = []
|
||||||
w = MainWindow()
|
|
||||||
w.resize(1300, 900)
|
|
||||||
w.show()
|
def check(name, cond, info=""):
|
||||||
for _ in range(40):
|
ok = bool(cond)
|
||||||
|
results.append((name, ok))
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] {name} {info}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def settle(ms=120):
|
||||||
|
t0 = time.time()
|
||||||
|
while (time.time() - t0) * 1000 < ms:
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
time.sleep(0.01)
|
||||||
|
# offscreen 无真实事件循环:processEvents 不处理 DeferredDelete,显式冲刷(生产中事件循环常驻,deleteLater 正常)
|
||||||
|
QtCore.QCoreApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete)
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def find_overlay():
|
||||||
|
for w in QtWidgets.QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, RenameOverlay):
|
||||||
|
return w
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
|
window = MainWindow()
|
||||||
|
window.resize(1400, 800)
|
||||||
|
window.show()
|
||||||
|
settle(400)
|
||||||
|
|
||||||
|
sid = window.db.create_session("旧标题A")["id"]
|
||||||
|
window.rebuild_sidebar()
|
||||||
|
settle(150)
|
||||||
|
|
||||||
|
# ---------- 打开遮罩 ----------
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(250) # 含 150ms 入场动画
|
||||||
|
ov = find_overlay()
|
||||||
|
check("R0.1 overlay 已创建且为顶层窗口", ov is not None,
|
||||||
|
"" if ov is not None else "topLevelWidgets 中找不到 RenameOverlay")
|
||||||
|
if ov is None:
|
||||||
|
print(f"\n{'='*60}\nRESULT: 1 FAIL -> FAIL\n{'='*60}", flush=True)
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
# ---------- R1 结构 ----------
|
||||||
|
print("R1 结构(独立顶层透明窗)", flush=True)
|
||||||
|
check("R1.1 isWindow()", ov.isWindow())
|
||||||
|
check("R1.2 自身即顶层窗口(非 bg_widget 内嵌子控件)", ov.window() is ov,
|
||||||
|
f"window() is ov={ov.window() is ov}")
|
||||||
|
check("R1.3 非主窗口自身", ov is not window)
|
||||||
|
check("R1.4 Tool 窗(不入任务栏)", bool(ov.windowFlags() & QtCore.Qt.WindowType.Tool))
|
||||||
|
check("R1.5 无边框", bool(ov.windowFlags() & QtCore.Qt.WindowType.FramelessWindowHint))
|
||||||
|
check("R1.6 WA_TranslucentBackground", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
|
||||||
|
check("R1.7 WA_DeleteOnClose", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose))
|
||||||
|
check("R1.8 主窗口未设 WA_TranslucentBackground(保持原生不透明底)",
|
||||||
|
not window.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
|
||||||
|
|
||||||
|
# ---------- R2 几何 ----------
|
||||||
|
print("R2 几何(覆盖客户区,标题栏可操作)", flush=True)
|
||||||
|
exp_tl = window.mapToGlobal(window.rect().topLeft())
|
||||||
|
exp_size = window.rect().size()
|
||||||
|
g = ov.geometry()
|
||||||
|
check("R2.1 覆盖客户区左上角", abs(g.left() - exp_tl.x()) <= 2 and abs(g.top() - exp_tl.y()) <= 2,
|
||||||
|
f"overlay=({g.left()},{g.top()}) expect=({exp_tl.x()},{exp_tl.y()})")
|
||||||
|
check("R2.2 覆盖客户区尺寸", abs(g.width() - exp_size.width()) <= 2 and abs(g.height() - exp_size.height()) <= 2,
|
||||||
|
f"overlay={g.width()}x{g.height()} expect={exp_size.width()}x{exp_size.height()}")
|
||||||
|
fx, fy = (g.width() - ov.form.width()) // 2, (g.height() - ov.form.height()) // 2
|
||||||
|
check("R2.3 卡片居中", abs(ov.form.x() - fx) <= 2 and abs(ov.form.y() - fy) <= 2,
|
||||||
|
f"form=({ov.form.x()},{ov.form.y()}) expect=({fx},{fy})")
|
||||||
|
check("R2.4 输入框初始全选", ov.input.hasSelectedText(), f"text={ov.input.text()!r}")
|
||||||
|
check("R2.5 输入框预填旧标题", ov.input.text() == "旧标题A", f"text={ov.input.text()!r}")
|
||||||
|
|
||||||
|
# ---------- R3 跟随 ----------
|
||||||
|
print("R3 跟随(move/resize/状态变化)", flush=True)
|
||||||
|
base = window.pos()
|
||||||
|
window.move(base.x() + 150, base.y() + 80)
|
||||||
|
settle(150)
|
||||||
|
exp_tl2 = window.mapToGlobal(window.rect().topLeft())
|
||||||
|
check("R3.1 主窗口移动→遮罩跟随", abs(ov.geometry().left() - exp_tl2.x()) <= 2
|
||||||
|
and abs(ov.geometry().top() - exp_tl2.y()) <= 2,
|
||||||
|
f"overlay_tl=({ov.geometry().left()},{ov.geometry().top()}) expect=({exp_tl2.x()},{exp_tl2.y()})")
|
||||||
|
|
||||||
|
window.resize(1200, 700)
|
||||||
|
settle(150)
|
||||||
|
g2 = ov.geometry()
|
||||||
|
check("R3.2 主窗口缩放→遮罩同步尺寸",
|
||||||
|
abs(g2.width() - 1200) <= 4 and abs(g2.height() - 700) <= 4,
|
||||||
|
f"overlay={g2.width()}x{g2.height()} expect=1200x700")
|
||||||
|
fx2 = (g2.width() - ov.form.width()) // 2
|
||||||
|
check("R3.3 缩放后卡片重新居中", abs(ov.form.x() - fx2) <= 2, f"form.x={ov.form.x()} expect={fx2}")
|
||||||
|
|
||||||
|
# WindowStateChange 分支(最大化/还原走同一条 _sync_geometry 路径;offscreen 直接投递事件验证分支)
|
||||||
|
before = ov.geometry()
|
||||||
|
QtWidgets.QApplication.sendEvent(window, QtCore.QEvent(QtCore.QEvent.Type.WindowStateChange))
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
check("R3.4 WindowStateChange 分支不崩溃且几何仍正确",
|
||||||
|
abs(ov.geometry().width() - 1200) <= 4 and ov is find_overlay(),
|
||||||
|
f"geometry={ov.geometry().width()}x{ov.geometry().height()}")
|
||||||
|
|
||||||
|
# ---------- R4 行为 ----------
|
||||||
|
print("R4 行为", flush=True)
|
||||||
|
# R4.1 Enter 提交 → renamed 信号 → DB + 侧栏
|
||||||
|
ov.input.setText("P203新会话名")
|
||||||
|
ov.confirm()
|
||||||
|
settle(250)
|
||||||
|
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||||
|
check("R4.1a confirm 后 DB 标题已更新", sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||||
|
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||||
|
side_ok = False
|
||||||
|
for i in range(window.history_list.count()):
|
||||||
|
it = window.history_list.item(i)
|
||||||
|
if it and it.data(QtCore.Qt.ItemDataRole.UserRole) == sid:
|
||||||
|
w = window.history_list.itemWidget(it)
|
||||||
|
if w and hasattr(w, "title_label") and w.title_label.text() == "P203新会话名":
|
||||||
|
side_ok = True
|
||||||
|
check("R4.1b confirm 后侧栏标题已更新", side_ok)
|
||||||
|
check("R4.1c 提交后 overlay 已从顶层窗口消失", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.2 空标题 confirm 不提交(只关闭)
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(200)
|
||||||
|
ovz = find_overlay()
|
||||||
|
if ovz:
|
||||||
|
ovz.input.clear()
|
||||||
|
ovz.confirm()
|
||||||
|
settle(200)
|
||||||
|
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||||
|
check("R4.2 空标题 confirm 不改 DB 标题",
|
||||||
|
sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||||
|
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||||
|
|
||||||
|
check("R4.2b 空标题确认后 overlay 已消失", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.3 Esc 关闭
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(200)
|
||||||
|
ov2 = find_overlay()
|
||||||
|
check("R4.3a 重开 overlay", ov2 is not None)
|
||||||
|
if ov2:
|
||||||
|
QtWidgets.QApplication.sendEvent(
|
||||||
|
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, QtCore.Qt.Key.Key_Escape,
|
||||||
|
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
|
||||||
|
QtWidgets.QApplication.sendEvent(
|
||||||
|
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyRelease, QtCore.Qt.Key.Key_Escape,
|
||||||
|
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
settle(200)
|
||||||
|
check("R4.3b Esc 关闭", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.4 点空白关闭
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(200)
|
||||||
|
ov3 = find_overlay()
|
||||||
|
check("R4.4a 重开 overlay", ov3 is not None)
|
||||||
|
if ov3:
|
||||||
|
QTest.mouseClick(ov3, QtCore.Qt.MouseButton.LeftButton,
|
||||||
|
QtCore.Qt.KeyboardModifier.NoModifier, QtCore.QPoint(10, 10))
|
||||||
|
settle(200)
|
||||||
|
check("R4.4b 点空白关闭", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.5 ✕ 关闭
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(200)
|
||||||
|
ov4 = find_overlay()
|
||||||
|
check("R4.5a 重开 overlay", ov4 is not None)
|
||||||
|
if ov4:
|
||||||
|
btn_x = None
|
||||||
|
for b in ov4.form.findChildren(QtWidgets.QPushButton):
|
||||||
|
if b.text() == "✕":
|
||||||
|
btn_x = b
|
||||||
|
break
|
||||||
|
check("R4.5b ✕ 按钮存在", btn_x is not None)
|
||||||
|
if btn_x:
|
||||||
|
btn_x.click()
|
||||||
|
settle(200)
|
||||||
|
check("R4.5c ✕ 关闭", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.6 取消按钮关闭
|
||||||
|
window._rename_session(sid)
|
||||||
|
settle(200)
|
||||||
|
ov5 = find_overlay()
|
||||||
|
if ov5:
|
||||||
|
for b in ov5.form.findChildren(QtWidgets.QPushButton):
|
||||||
|
if b.text() == "取消":
|
||||||
|
b.click()
|
||||||
|
break
|
||||||
|
settle(200)
|
||||||
|
check("R4.6 取消按钮关闭", find_overlay() is None)
|
||||||
|
|
||||||
|
# R4.7 非确认关闭(Esc/空白/✕/取消)均不改标题
|
||||||
|
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
|
||||||
|
check("R4.7 非确认关闭不改 DB 标题",
|
||||||
|
sessions.get(sid, {}).get("title") == "P203新会话名",
|
||||||
|
f"db_title={sessions.get(sid, {}).get('title')!r}")
|
||||||
|
|
||||||
|
# ---------- R5 释放 ----------
|
||||||
|
print("R5 释放(无残留)", flush=True)
|
||||||
|
check("R5.1 无残留 rename_form 顶层窗口",
|
||||||
|
not [w for w in QtWidgets.QApplication.topLevelWidgets()
|
||||||
|
if w.findChild(QtWidgets.QFrame, "rename_form") is not None])
|
||||||
|
check("R5.2 主窗口仍存活可用", window.isVisible() and window.db.get_all_sessions() is not None)
|
||||||
|
|
||||||
|
# ---------- R6 焦点(软检查) ----------
|
||||||
|
print("R6 焦点回主窗口(软检查)", flush=True)
|
||||||
|
window.activateWindow()
|
||||||
|
settle(200)
|
||||||
|
print(f" [INFO] window.isActiveWindow()={window.isActiveWindow()} "
|
||||||
|
f"(offscreen 下不可靠,仅记录;真实机器人工走查确认)", flush=True)
|
||||||
|
|
||||||
|
window.close()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
QTest.qWait(20)
|
ok = all(o for _, o in results)
|
||||||
|
print(f"\n{'='*60}", flush=True)
|
||||||
|
print(f"RESULT: {sum(o for _, o in results)}/{len(results)} -> {'ALL PASS' if ok else 'FAIL'}", flush=True)
|
||||||
|
print(f"{'='*60}", flush=True)
|
||||||
|
sys.stdout.flush()
|
||||||
|
os._exit(0 if ok else 1)
|
||||||
|
|
||||||
sid = w.db.create_session("聚光灯测试会话")["id"]
|
|
||||||
w.load_messages_to_web(sid)
|
|
||||||
for _ in range(20):
|
|
||||||
app.processEvents()
|
|
||||||
QTest.qWait(20)
|
|
||||||
|
|
||||||
print("=== SessionContextPopup 是否为独立顶层窗口 ===")
|
if __name__ == "__main__":
|
||||||
print(" windowFlags 含 Qt.Popup ?", bool(SessionContextPopup(sid, "", w).windowFlags() & 0x80000000))
|
try:
|
||||||
pop = SessionContextPopup(sid, "聚光灯测试会话", w)
|
main()
|
||||||
print(" isWindow() =", pop.isWindow(), " windowType =", pop.windowFlags())
|
except SystemExit:
|
||||||
|
raise
|
||||||
print()
|
except BaseException:
|
||||||
print("=== RenameOverlay 现状 ===")
|
import traceback
|
||||||
w._rename_session(sid)
|
traceback.print_exc()
|
||||||
for _ in range(25):
|
sys.stdout.flush()
|
||||||
app.processEvents()
|
os._exit(1)
|
||||||
QTest.qWait(20)
|
|
||||||
|
|
||||||
ovs = [c for c in w.children() if isinstance(c, RenameOverlay)]
|
|
||||||
if not ovs:
|
|
||||||
print(" ✗ 没找到 RenameOverlay")
|
|
||||||
else:
|
|
||||||
ov = ovs[0]
|
|
||||||
print(" 父对象 =", ov.parent().__class__.__name__)
|
|
||||||
print(" isWindow() =", ov.isWindow(), " (False = 主窗口的子控件,只覆盖客户区)")
|
|
||||||
print(" 几何 =", ov.geometry())
|
|
||||||
print(" 主窗口 rect =", w.rect(), " 主窗口 frameGeometry =", w.frameGeometry())
|
|
||||||
print(" WA_TranslucentBackground =", ov.testAttribute(
|
|
||||||
__import__('PyQt6.QtCore', fromlist=['Qt']).Qt.WidgetAttribute.WA_TranslucentBackground))
|
|
||||||
print(" graphicsEffect =", type(ov.graphicsEffect()).__name__ if ov.graphicsEffect() else None)
|
|
||||||
print(" 遮罩色 =", ov._overlay_color.getRgb())
|
|
||||||
print(" form 几何 =", ov.form.geometry(), " form 是否在遮罩内 =",
|
|
||||||
ov.rect().contains(ov.form.geometry()))
|
|
||||||
# 看遮罩是否盖住了标题栏 / 是否超出窗口
|
|
||||||
print(" 遮罩 top-left(全局) =", ov.mapToGlobal(ov.rect().topLeft()),
|
|
||||||
" bottom-right(全局) =", ov.mapToGlobal(ov.rect().bottomRight()))
|
|
||||||
print(" 主窗口(全局) =", w.mapToGlobal(w.rect().topLeft()), w.mapToGlobal(w.rect().bottomRight()))
|
|
||||||
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_overlay.png")
|
|
||||||
ov.grab().save(out)
|
|
||||||
print(" 覆盖层截图 ->", out, ov.width(), "x", ov.height())
|
|
||||||
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_window.png")
|
|
||||||
w.grab().save(out2)
|
|
||||||
print(" 主窗口截图 ->", out2, w.width(), "x", w.height())
|
|
||||||
|
|
||||||
os.remove(_cfg)
|
|
||||||
|
|||||||
+308
-62
@@ -1,90 +1,336 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""问题 1 取证:长会话渲染的真实 DOM 规模与耗时(离屏,临时库)"""
|
"""P1-01 渲染窗口 400 条消息规模诊断(offscreen;临时 DB + 临时配置;不启动真实 LLM)
|
||||||
|
|
||||||
|
运行: QT_QPA_PLATFORM=offscreen python tests/diag_render_scale.py [N]
|
||||||
|
N 默认 400(位置参数可覆盖)
|
||||||
|
|
||||||
|
验证项(manual 模式保证确定性;末尾追加 auto 模式抽查):
|
||||||
|
1. 初始窗口:.message-wrapper == min(N, size) 且 ≤ size(默认 40);
|
||||||
|
窗口 = 最新 size 条;load-older 可见、load-newer 隐藏;
|
||||||
|
2. 向上分页至头部:每页锚点误差 ≤ 2px(绝对顶部例外:scrollTop==0 且露出新页);
|
||||||
|
全程窗口 ≤ size;到达头部后 hasMoreOlder=false;
|
||||||
|
全部 N 条消息可达(各步渲染 id 并集 == 链 id 集),无重复 id;
|
||||||
|
3. 自头部向下回翻 2 页:锚点稳定(≤2px);
|
||||||
|
4. 规模报告:DOM 节点总数、页面高度、每页耗时采样(ms);
|
||||||
|
5. auto 模式抽查:切 auto + 滚到顶部 → 自动补页发生。
|
||||||
|
|
||||||
|
调试铁律:本脚本自带总超时(QTimer 300s),bash 侧以 timeout=360 运行。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
||||||
|
# 铁律:临时 DB + 临时配置(含 render_window_mode/size),先于任何 UI import
|
||||||
|
from tests._test_env import isolate, default_config # noqa: E402
|
||||||
|
|
||||||
|
N = int(sys.argv[1]) if len(sys.argv) > 1 else 400
|
||||||
|
_SIZE = 40
|
||||||
|
_PAGE = _SIZE // 2 # 页大小 = 半窗(与 main_window._rw_page_size 一致)
|
||||||
|
_cfg = dict(default_config())
|
||||||
|
_cfg["render_window_mode"] = "manual" # 确定性诊断;auto 行为末尾单独抽查
|
||||||
|
_cfg["render_window_size"] = _SIZE
|
||||||
|
_env = isolate("render_scale", config=_cfg)
|
||||||
|
|
||||||
import core.db_manager as _dbm # noqa: E402
|
import core.db_manager as _dbm # noqa: E402
|
||||||
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.db")
|
from core.db_manager import DBManager # noqa: E402
|
||||||
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.json")
|
|
||||||
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
|
# ---------- 造数:N 条链(含附件用户消息 / assistant 时间线 / 一个分支兄弟) ----------
|
||||||
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
|
db = DBManager()
|
||||||
|
sess = db.create_session(title=f"P1-01 scale {N}")
|
||||||
|
sid = sess["id"]
|
||||||
|
parent = None
|
||||||
|
chain_ids = []
|
||||||
|
for i in range(N):
|
||||||
|
role = "user" if i % 2 == 0 else "assistant"
|
||||||
|
content = f"消息 {i} —— " + "这是一段用于撑起 DOM 高度的填充段落。" * 6
|
||||||
|
atts = None
|
||||||
|
if role == "user" and i % 50 == 0:
|
||||||
|
atts = json.dumps({"user_text": content, "attachments": [
|
||||||
|
{"type": "pdf", "mode": "text", "name": f"spec_{i}.pdf",
|
||||||
|
"size_kb": 12, "pages": 3, "lines": 40, "content": "文本附件内容"}
|
||||||
|
]}, ensure_ascii=False)
|
||||||
|
timeline = None
|
||||||
|
if role == "assistant" and i % 75 == 3:
|
||||||
|
timeline = json.dumps([
|
||||||
|
{"t": "think", "text": f"思考片段 {i}"},
|
||||||
|
{"t": "text", "text": f"时间线正文 {i}"},
|
||||||
|
], ensure_ascii=False)
|
||||||
|
msg_id = f"scale-{i}"
|
||||||
|
db.add_message(session_id=sid, role=role, content=content, parent_id=parent,
|
||||||
|
msg_id=msg_id, attachment_metadata=atts, timeline=timeline)
|
||||||
|
parent = msg_id
|
||||||
|
chain_ids.append(msg_id)
|
||||||
|
# 分支兄弟:必须插在链中间(add_message 会把新消息设为叶子,
|
||||||
|
# 若放在主链之后会抢走叶子、截断可见链)
|
||||||
|
if i == 298 and N > 300:
|
||||||
|
db.add_message(session_id=sid, role="assistant", content="分支兄弟回复",
|
||||||
|
parent_id=parent, msg_id="scale-branch-sib")
|
||||||
|
db.mark_session_has_messages(sid)
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
from PyQt6.QtTest import QTest # noqa: E402
|
from PyQt6.QtCore import QTimer # noqa: E402
|
||||||
from ui.views.main_window import MainWindow # noqa: E402
|
from ui.views.main_window import MainWindow # noqa: E402
|
||||||
|
|
||||||
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
w = MainWindow()
|
results = []
|
||||||
w.resize(1200, 850)
|
|
||||||
w.show()
|
|
||||||
for _ in range(30):
|
def check(name, fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" PASS {name}")
|
||||||
|
results.append(True)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f" FAIL {name}: {e}")
|
||||||
|
results.append(False)
|
||||||
|
|
||||||
|
|
||||||
|
window = None
|
||||||
|
js = {"done": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_js(code, timeout_s=15):
|
||||||
|
result = {"val": None, "done": False}
|
||||||
|
|
||||||
|
def on_ret(val):
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
val = json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result["val"] = val
|
||||||
|
result["done"] = True
|
||||||
|
|
||||||
|
if hasattr(window.browser, "execute_js_async"):
|
||||||
|
window.browser.execute_js_async(code, on_ret)
|
||||||
|
else:
|
||||||
|
window.browser.page().runJavaScript(code, on_ret)
|
||||||
|
t0 = time.time()
|
||||||
|
while not result["done"] and time.time() - t0 < timeout_s:
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
QTest.qWait(20)
|
time.sleep(0.02)
|
||||||
|
assert result["done"], f"JS 执行超时: {code[:60]}"
|
||||||
|
return result["val"]
|
||||||
|
|
||||||
sid = w.db.create_session(f"长会话渲染取证 {N} 条")["id"]
|
|
||||||
parent = None
|
|
||||||
# 造 N 轮(每轮 user + assistant,assistant 带代码块 + 工具时间线,贴近真实负载)
|
|
||||||
for i in range(N // 2):
|
|
||||||
parent = w.db.add_message(session_id=sid, role="user",
|
|
||||||
content=f"第 {i} 个问题:帮我看看这段代码\n```python\nprint({i})\n```",
|
|
||||||
parent_id=parent)["id"]
|
|
||||||
tl = json.dumps([
|
|
||||||
{"t": "think", "text": "分析中…" * 20},
|
|
||||||
{"t": "text", "text": f"### 回答 {i}\n\n要点如下:\n\n- 第一条\n- 第二条\n\n```python\nfor j in range(10):\n print(j)\n```\n"},
|
|
||||||
{"t": "tool", "id": f"c{i}", "name": "bash", "args": '{"command":"echo hi"}',
|
|
||||||
"ok": True, "result": "hi\n" * 30},
|
|
||||||
], ensure_ascii=False)
|
|
||||||
parent = w.db.add_message(session_id=sid, role="assistant",
|
|
||||||
content=f"回答 {i}:见代码块与工具结果。",
|
|
||||||
parent_id=parent, timeline=tl)["id"]
|
|
||||||
|
|
||||||
chain = w.db.get_message_chain(sid)
|
def wait_until(cond_js, timeout_s=30, desc=""):
|
||||||
print(f"库内消息条数 = {len(chain)}")
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout_s:
|
||||||
|
if _run_js(cond_js, timeout_s=5):
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise AssertionError(f"等待超时: {desc}")
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
w.load_messages_to_web(sid)
|
page_metrics = {"pages": [], "heights": [], "dom_nodes": []}
|
||||||
for _ in range(80):
|
|
||||||
|
|
||||||
|
def try_load():
|
||||||
|
global window
|
||||||
|
window = MainWindow()
|
||||||
|
# offscreen 零视口(innerHeight=0)→ 给真实尺寸,滚动/锚点几何才有效
|
||||||
|
window.resize(1400, 950)
|
||||||
|
window.show()
|
||||||
|
for _ in range(10):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
QTest.qWait(25)
|
time.sleep(0.05)
|
||||||
dt = time.time() - t0
|
# 等真实视口
|
||||||
|
wait_until("window.innerHeight > 0 && window.innerWidth > 0", 30, "视口尺寸")
|
||||||
holder = {}
|
# 等 JS 就绪 + 首轮(可能零视口)渲染完成
|
||||||
done = []
|
wait_until("window.jsReady === true", 30, "jsReady")
|
||||||
|
wait_until(f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
|
||||||
|
60, "首轮渲染")
|
||||||
|
# 真实视口下走完整 load_messages_to_web 路径重新窗口化加载
|
||||||
|
window.load_messages_to_web(window.current_session_id, show_loading=False)
|
||||||
|
wait_until(f"rwState !== null && rwState.generation >= 2 && "
|
||||||
|
f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
|
||||||
|
60, "视口重载")
|
||||||
|
wait_until("(window.__rwPageRendering === false && document.getElementById('load-older') !== null) ? 1 : 0",
|
||||||
|
10, "初始窗口收尾")
|
||||||
|
run_checks()
|
||||||
|
|
||||||
|
|
||||||
def got(res):
|
def run_checks():
|
||||||
holder["dom"] = res
|
sel = "#chat-container .message-wrapper"
|
||||||
done.append(1)
|
|
||||||
|
|
||||||
|
# ---- 1) 初始窗口 ----
|
||||||
|
def initial():
|
||||||
|
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
|
||||||
|
assert cnt == _SIZE, f"初始窗口 {cnt} != {_SIZE}"
|
||||||
|
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
|
||||||
|
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer, "
|
||||||
|
"mode: rwState.mode, size: rwState.size})")
|
||||||
|
assert st["mode"] == "manual" and st["size"] == _SIZE, st
|
||||||
|
assert st["len"] == _SIZE and st["hidO"] == N - _SIZE, st
|
||||||
|
assert st["newer"] is False and st["older"] is True, st
|
||||||
|
assert _run_js("document.getElementById('load-older').hidden") is False
|
||||||
|
assert _run_js("document.getElementById('load-newer').hidden") is True
|
||||||
|
# 窗口 = 最新 size 条
|
||||||
|
first = _run_js(f"document.querySelector('{sel}').id")
|
||||||
|
assert first == chain_ids[N - _SIZE], first
|
||||||
|
# 初始贴底
|
||||||
|
assert _run_js("isNearBottom()"), "初始窗口未对齐底部"
|
||||||
|
|
||||||
w.browser.page().runJavaScript(
|
check(f"初始窗口 = 最新 {_SIZE} 条(链长 {N})", initial)
|
||||||
"JSON.stringify({wrappers: document.querySelectorAll('.message-wrapper').length,"
|
|
||||||
" nodes: document.getElementsByTagName('*').length,"
|
# ---- 2a) 中部锚点保持(真实"窗口中部换页"路径,误差 ≤2px) ----
|
||||||
" height: document.scrollingElement.scrollHeight,"
|
seen = set(chain_ids[-_SIZE:])
|
||||||
" codeBlocks: document.querySelectorAll('.code-block-wrapper').length,"
|
anchor_errors = []
|
||||||
" katex: document.querySelectorAll('.katex').length})", got)
|
|
||||||
for _ in range(60):
|
def page_older_once():
|
||||||
|
"""执行一次向上换页(调用方保证视口位置);断言窗口上限与锚点。"""
|
||||||
|
before = _run_js("JSON.stringify(rwCaptureAnchor() || {})")
|
||||||
|
before = json.loads(before) if isinstance(before, str) else (before or {})
|
||||||
|
scroll_before = _run_js("window.scrollY")
|
||||||
|
t0 = time.time()
|
||||||
|
_run_js("rwRequestPage('older')")
|
||||||
|
# 等待 pending 消费 + 批次渲染收尾
|
||||||
|
wait_until("rwState.pending === null && window.__rwPageRendering === false",
|
||||||
|
30, "换页完成")
|
||||||
|
dt = (time.time() - t0) * 1000
|
||||||
|
page_metrics["pages"].append(dt)
|
||||||
|
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
|
||||||
|
assert cnt <= _SIZE, f"窗口超限 {cnt}"
|
||||||
|
if before.get("msgId"):
|
||||||
|
el = _run_js(f"document.getElementById({json.dumps(before['msgId'])}) ? "
|
||||||
|
f"document.getElementById({json.dumps(before['msgId'])}).getBoundingClientRect().top + window.scrollY : null")
|
||||||
|
assert el is not None, f"锚点消息被裁剪: {before['msgId']}"
|
||||||
|
if scroll_before <= 1:
|
||||||
|
# 绝对顶部:停在 0 露出新页(需求定义的例外:不做锚点断言)
|
||||||
|
assert _run_js("window.scrollY") <= 1, "绝对顶部未保持"
|
||||||
|
anchor_errors.append(0.0)
|
||||||
|
else:
|
||||||
|
expect = scroll_before + (el - before["docTop"])
|
||||||
|
actual = _run_js("window.scrollY")
|
||||||
|
err = abs(expect - actual)
|
||||||
|
anchor_errors.append(err)
|
||||||
|
assert err <= 2.0, f"锚点误差 {err}px (expect={expect} actual={actual})"
|
||||||
|
else:
|
||||||
|
anchor_errors.append(0.0)
|
||||||
|
ids_now = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
|
||||||
|
for i in ids_now:
|
||||||
|
seen.add(i)
|
||||||
|
page_metrics["dom_nodes"].append(_run_js("document.querySelectorAll('body *').length"))
|
||||||
|
page_metrics["heights"].append(_run_js("document.body.scrollHeight"))
|
||||||
|
|
||||||
|
def anchor_middle():
|
||||||
|
# 滚到窗口中部(35% 处):既非绝对顶部也非贴底 → 走完整锚点数学
|
||||||
|
_run_js("window.scrollTo(0, Math.max(1, document.body.scrollHeight * 0.15)); 1")
|
||||||
|
time.sleep(0.2)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
QTest.qWait(25)
|
scroll_before = _run_js("window.scrollY")
|
||||||
if done:
|
assert scroll_before > 10, f"未到中部: {scroll_before}"
|
||||||
|
assert not _run_js("isNearBottom()"), "中部位置不应贴底(防振荡前置条件)"
|
||||||
|
page_older_once()
|
||||||
|
st = _run_js("JSON.stringify({hidO: rwState.hiddenOlder, hidN: rwState.hiddenNewer})")
|
||||||
|
assert st["hidN"] > 0, f"向上换页后应裁出较新隐藏区(无振荡): {st}"
|
||||||
|
assert st["hidO"] == N - _SIZE - _PAGE, f"窗口应上移 _PAGE 条: {st}"
|
||||||
|
|
||||||
|
check("中部锚点保持(误差≤2px,无 newer 振荡)", anchor_middle)
|
||||||
|
|
||||||
|
# ---- 2b) 自顶部循环向上分页至头部(真实"load older"点击路径) ----
|
||||||
|
def page_up_to_head():
|
||||||
|
_run_js("window.scrollTo(0, 0); 1")
|
||||||
|
time.sleep(0.2)
|
||||||
|
app.processEvents()
|
||||||
|
steps = 0
|
||||||
|
while True:
|
||||||
|
if not _run_js("rwState.hasMoreOlder"):
|
||||||
break
|
break
|
||||||
|
page_older_once()
|
||||||
|
steps += 1
|
||||||
|
assert steps <= N // _SIZE + 10, "换页次数异常(疑似死循环)"
|
||||||
|
first = _run_js(f"document.querySelector('{sel}').id")
|
||||||
|
assert first == chain_ids[0], f"未到达头部: {first}"
|
||||||
|
|
||||||
print(f"渲染耗时 ≈ {dt:.2f}s")
|
check("自顶部向上分页至头部(绝对顶部例外)", page_up_to_head)
|
||||||
print("DOM 统计 =", holder.get("dom"))
|
|
||||||
if holder.get("dom"):
|
|
||||||
d = json.loads(holder["dom"])
|
|
||||||
print(f" → 消息节点 {d['wrappers']} 个 / 全 DOM 节点 {d['nodes']} 个 / "
|
|
||||||
f"页面总高 {d['height']}px / 代码块 {d['codeBlocks']} 个")
|
|
||||||
print(f" → 平均每条消息 {d['nodes'] // max(1, d['wrappers'])} 个 DOM 节点")
|
|
||||||
|
|
||||||
os.remove(_cfg)
|
def head_state():
|
||||||
|
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
|
||||||
|
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer})")
|
||||||
|
assert st["hidO"] == 0 and st["older"] is False, st
|
||||||
|
assert st["newer"] is True and st["hidN"] == N - _SIZE, st
|
||||||
|
# 全链可达 + 无重复
|
||||||
|
all_ids = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
|
||||||
|
assert len(all_ids) == len(set(all_ids)), "DOM 出现重复消息 id"
|
||||||
|
assert len(seen) == N, f"可达 {len(seen)}/{N} 条"
|
||||||
|
|
||||||
|
check("头部状态 + 全链可达无重复", head_state)
|
||||||
|
|
||||||
|
# ---- 3) 自顶部向下回翻 2 页(窗口下移,视口留在顶部) ----
|
||||||
|
def page_down_two():
|
||||||
|
for k in range(2):
|
||||||
|
_run_js("window.scrollTo(0, 0); 1")
|
||||||
|
time.sleep(0.15)
|
||||||
|
app.processEvents()
|
||||||
|
_run_js("rwRequestPage('newer')")
|
||||||
|
wait_until("rwState.pending === null && window.__rwPageRendering === false",
|
||||||
|
30, f"向下换页{k}完成")
|
||||||
|
# 顶部回翻:锚点(最旧一条)随 trimHead 移除 → 视口留在顶部露出新页
|
||||||
|
assert _run_js("window.scrollY") <= 1, "顶部回翻应留在顶部"
|
||||||
|
first = _run_js(f"document.querySelector('{sel}').id")
|
||||||
|
assert first == chain_ids[_PAGE * (k + 1)], f"窗口未下移: {first}"
|
||||||
|
st = _run_js("rwState.hasMoreOlder")
|
||||||
|
assert st is True, "回翻后应仍有更旧消息"
|
||||||
|
|
||||||
|
check("自顶部向下回翻 2 页(窗口下移)", page_down_two)
|
||||||
|
|
||||||
|
# ---- 5) auto 模式抽查:顶部自动补页 ----
|
||||||
|
def auto_probe():
|
||||||
|
_run_js("window.scrollTo(0, 0); 1")
|
||||||
|
time.sleep(0.15)
|
||||||
|
app.processEvents()
|
||||||
|
_run_js("rwState.mode = 'auto'; 1")
|
||||||
|
hid_before = _run_js("rwState.hiddenOlder")
|
||||||
|
_run_js("rwAutoCheck(); 1") # 直接触发自动补页判定(事件接线另行覆盖)
|
||||||
|
# 给自动补页 12s(每页渲染约 0.5-2s)
|
||||||
|
t0 = time.time()
|
||||||
|
moved = False
|
||||||
|
while time.time() - t0 < 12:
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.2)
|
||||||
|
if _run_js("rwState.hiddenOlder") < hid_before:
|
||||||
|
moved = True
|
||||||
|
break
|
||||||
|
assert moved, "auto 模式顶部未自动补页"
|
||||||
|
|
||||||
|
check("auto 模式:顶部自动补页", auto_probe)
|
||||||
|
|
||||||
|
# ---- 4) 规模报告 ----
|
||||||
|
nodes = page_metrics["dom_nodes"]
|
||||||
|
heights = page_metrics["heights"]
|
||||||
|
pages_ms = page_metrics["pages"]
|
||||||
|
print("\n================ 规模报告 ================")
|
||||||
|
print(f"链长 N={N} | 窗口 size={_SIZE}")
|
||||||
|
print(f"换页数={len(pages_ms)} | 每页耗时 min/avg/max = "
|
||||||
|
f"{min(pages_ms):.0f}/{(sum(pages_ms) / len(pages_ms)):.0f}/{max(pages_ms):.0f} ms")
|
||||||
|
print(f"DOM 节点总数 min/max = {min(nodes)}/{max(nodes)}")
|
||||||
|
print(f"页面高度 min/max = {min(heights)}/{max(heights)} px")
|
||||||
|
if anchor_errors:
|
||||||
|
print(f"锚点误差 min/max = {min(anchor_errors):.2f}/{max(anchor_errors):.2f} px")
|
||||||
|
print("==========================================")
|
||||||
|
|
||||||
|
|
||||||
|
def finish(ok=None):
|
||||||
|
app.quit()
|
||||||
|
|
||||||
|
|
||||||
|
QTimer.singleShot(500, try_load)
|
||||||
|
QTimer.singleShot(300000, lambda: (print("[超时] 300s 总超时"), finish(False)))
|
||||||
|
app.exec()
|
||||||
|
try:
|
||||||
|
window.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
|
||||||
|
# QtWebEngine 退出段错误规避:汇总已打印,直接退出
|
||||||
|
os._exit(0 if all(results) else 1)
|
||||||
|
|||||||
+136
-13
@@ -17,18 +17,12 @@ os.environ.setdefault("HAOCODE_RENDER", "software")
|
|||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
# 铁律:测试不得污染真实 data/chat_history.db
|
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||||
import core.db_manager as _dbm # noqa: E402
|
# (临时配置保留本套件的 mode_switch=true 语义)
|
||||||
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_bashpanel_{os.getpid()}.db")
|
from tests._test_env import isolate # noqa: E402
|
||||||
if os.path.exists(_DB_TMP):
|
_TMP = isolate("bashpanel", config={"providers": {}, "mode_switch": True})
|
||||||
os.remove(_DB_TMP)
|
_DB_TMP = _TMP["db"]
|
||||||
_dbm._DEFAULT_DB = _DB_TMP
|
_CFG_TMP = _TMP["config"]
|
||||||
|
|
||||||
# 铁律:宽度记录会写 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.QtWidgets import QApplication # noqa: E402
|
||||||
from PyQt6.QtTest import QTest # noqa: E402
|
from PyQt6.QtTest import QTest # noqa: E402
|
||||||
@@ -606,9 +600,138 @@ for s in (sr, sd):
|
|||||||
settle(450)
|
settle(450)
|
||||||
check("P9.36 收尾:两栏均展开", (not sr.folded) and (not sd.folded))
|
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)
|
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
|
||||||
if os.path.exists(_DB_TMP):
|
if os.path.exists(_DB_TMP):
|
||||||
os.remove(_DB_TMP)
|
os.remove(_DB_TMP)
|
||||||
if os.path.exists(_CFG_TMP):
|
if os.path.exists(_CFG_TMP):
|
||||||
os.remove(_CFG_TMP)
|
os.remove(_CFG_TMP)
|
||||||
sys.exit(0 if ok else 1)
|
# offscreen 铁律:QtWebEngine 渲染/GPU 子进程在解释器退出时可能不回收 → 挂起;
|
||||||
|
# 与 smoke_offscreen 等 offscreen harness 一致,用 os._exit 强制收尾(stdout 已 flush)
|
||||||
|
os._exit(0 if ok else 1)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
||||||
|
|
||||||
|
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||||
|
from tests._test_env import isolate # noqa: E402
|
||||||
|
_TMP = isolate("midswitch") # noqa: E402
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
from PyQt6.QtCore import QTimer # noqa: E402
|
from PyQt6.QtCore import QTimer # noqa: E402
|
||||||
from ui.views.main_window import MainWindow # noqa: E402
|
from ui.views.main_window import MainWindow # noqa: E402
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import tempfile as _tf # noqa: E402
|
|||||||
import core.db_manager as _dbm # noqa: E402
|
import core.db_manager as _dbm # noqa: E402
|
||||||
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db")
|
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db")
|
||||||
|
|
||||||
|
# P1-03:QtWebEngine 独立 profile 重定向到临时目录(不占项目 data/webengine/)
|
||||||
|
os.environ.setdefault(
|
||||||
|
"HAOCODE_WEBENGINE_PROFILE_DIR",
|
||||||
|
os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}_profile"))
|
||||||
|
|
||||||
import ctypes # noqa: E402
|
import ctypes # noqa: E402
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
os.environ.setdefault("HAOCODE_RENDER", "software")
|
os.environ.setdefault("HAOCODE_RENDER", "software")
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
|
||||||
|
|
||||||
|
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||||
|
from tests._test_env import isolate # noqa: E402
|
||||||
|
_TMP = isolate("timeline") # noqa: E402
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
from PyQt6.QtCore import QTimer # noqa: E402
|
from PyQt6.QtCore import QTimer # noqa: E402
|
||||||
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入)
|
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
# P0-01:provider 用例经统一配置入口读取临时配置(测试 provider),不碰真实 data/config.json
|
||||||
|
from tests._test_env import isolate # noqa: E402
|
||||||
|
isolate("agentcore")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.agent import (Agent, AgentConfig, AgentError, ModelConfig, RetryConfig)
|
from core.agent import (Agent, AgentConfig, AgentError, ModelConfig, RetryConfig)
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ _mw_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
|
|||||||
"ui", "views", "main_window.py")
|
"ui", "views", "main_window.py")
|
||||||
with open(_mw_path, "r", encoding="utf-8") as f:
|
with open(_mw_path, "r", encoding="utf-8") as f:
|
||||||
_src = f.read()
|
_src = f.read()
|
||||||
check("T9.渲染过滤含 compaction", 'msg["role"] not in ("system", "compaction")' in _src)
|
# 注意:探针与循环变量名解耦(源码列表推导式用 `m` 或 `msg` 均可),
|
||||||
|
# 只锁死“渲染过滤排除 system/compaction”这一语义。
|
||||||
|
check("T9.渲染过滤含 compaction", '["role"] not in ("system", "compaction")' in _src)
|
||||||
|
|
||||||
# ---------- T12:Fix A — 摘要条目 timestamp → P0 失效过期锚点 ----------
|
# ---------- T12:Fix A — 摘要条目 timestamp → P0 失效过期锚点 ----------
|
||||||
# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照;
|
# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照;
|
||||||
|
|||||||
@@ -29,18 +29,11 @@ os.environ.setdefault("HAOCODE_RENDER", "software")
|
|||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
# 铁律:测试不得污染真实 data/chat_history.db
|
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
|
||||||
import core.db_manager as _dbm # noqa: E402
|
from tests._test_env import isolate # noqa: E402
|
||||||
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errpersist_{os.getpid()}.db")
|
_TMP = isolate("errpersist", config={"providers": {}})
|
||||||
if os.path.exists(_DB_TMP):
|
_DB_TMP = _TMP["db"]
|
||||||
os.remove(_DB_TMP)
|
_CFG_TMP = _TMP["config"]
|
||||||
_dbm._DEFAULT_DB = _DB_TMP
|
|
||||||
|
|
||||||
# 铁律:不得污染真实 data/config.json
|
|
||||||
_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errcfg_{os.getpid()}.json")
|
|
||||||
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
|
|
||||||
_f.write('{"providers": {}}')
|
|
||||||
os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
from PyQt6.QtTest import QTest # noqa: E402
|
from PyQt6.QtTest import QTest # noqa: E402
|
||||||
|
|||||||
Reference in New Issue
Block a user