feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签
一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
.../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
/api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
响应下发(/api/login 与 /api/me 都带),客户端不用手填。
二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。
三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。
四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
静默吞掉):带身份快照,不匹配即丢弃。
五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
(手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。
测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
(装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
This commit is contained in:
+537
-11
@@ -15,6 +15,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu")
|
||||
|
||||
import json
|
||||
import gc
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -27,8 +28,8 @@ from helpers import VFSTestCase, make_tree, quiet
|
||||
from sqlite_vfs.core import SQLiteVFS
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QSettings, Qt, QUrl
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, QSettings, Qt, QTimer, QUrl
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - WebEngine 探测
|
||||
|
||||
HAVE_QT = True
|
||||
@@ -108,6 +109,36 @@ class _ApiHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeLoginWindow:
|
||||
"""登录窗替身:``exec()`` 立刻返回,``session`` 就是预设的会话(None = 用户关掉了窗)。
|
||||
|
||||
``visible_probe`` 在 exec() 期间被调用一次,用来断言"进登录门时主窗口已经
|
||||
关掉了"这件事——真去驱动一个模态登录窗要绕一大圈(见
|
||||
test_client_login_ui.py 里的 QTimer 轮询写法),这里只需要登录门的时序。
|
||||
"""
|
||||
|
||||
def __init__(self, session, visible_probe=None):
|
||||
self.session = session
|
||||
self.shown = False
|
||||
self.raised = False
|
||||
self._visible_probe = visible_probe
|
||||
self.main_window_visible_during = None
|
||||
|
||||
def show(self):
|
||||
self.shown = True
|
||||
|
||||
def raise_(self):
|
||||
self.raised = True
|
||||
|
||||
def activateWindow(self): # noqa: N802 - 模拟 Qt 命名
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
if self._visible_probe is not None:
|
||||
self.main_window_visible_during = self._visible_probe()
|
||||
return 0
|
||||
|
||||
|
||||
def start_api_server():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
@@ -152,6 +183,30 @@ if HAVE_QT:
|
||||
_APP = QApplication.instance() or create_app([sys.argv[0]])
|
||||
_APP.setQuitOnLastWindowClosed(False)
|
||||
|
||||
def tearDownModule():
|
||||
"""退出前把 WebEngine 对象主动拆掉(否则进程会以段错误收场)。
|
||||
|
||||
留着窗口不管,Chromium 的 profile / 渲染子进程就要等解释器退出时才拆,
|
||||
而 offscreen 下那条路径会以 access violation 结束——测试全绿、退出码却是
|
||||
139,CI 照样判失败(本机 3.13 + PySide6 6.11.2 实测)。close() 走的是
|
||||
MainWindow.closeEvent 那条正常销毁顺序:收插件页面 → 停工作线程 → 删
|
||||
profile;这里再跑一轮 deferred delete 让 deleteLater 真正落地。
|
||||
"""
|
||||
global _APP
|
||||
# 收掉本模块残留的窗口,让各窗口自己的 WebEngine 资源在**这里**释放,
|
||||
# 而不是全堆到解释器退出时一起析构。注意别在这里 gc.collect() 或
|
||||
# sendPostedEvents:那会把几十个 profile 的析构挤到同一时刻,实测直接崩。
|
||||
#
|
||||
# 即便这样,解释器退出路径在 offscreen + WebEngine 下仍会段错误(与
|
||||
# 被测代码无关),所以测试统一走 tests/run_tests.py —— 它按 unittest 的
|
||||
# 结果 os._exit,绕开那条会崩的清理路径。
|
||||
for widget in list(QApplication.topLevelWidgets()):
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
for _ in range(30):
|
||||
_APP.processEvents()
|
||||
time.sleep(0.01)
|
||||
|
||||
class BridgeTest(VFSTestCase):
|
||||
"""桥的权限模型与存储隔离(不经过 WebView)。"""
|
||||
|
||||
@@ -517,8 +572,11 @@ if HAVE_QT:
|
||||
self.assertTrue(pump(_APP, predicate=lambda: self.window._startup_resolved))
|
||||
|
||||
def tearDown(self):
|
||||
self.window._thread.quit()
|
||||
self.window._thread.wait(5000)
|
||||
# 必须走 close() 而不是直接 deleteLater():只有 closeEvent 会拆掉
|
||||
# WebEngine profile、协议层句柄与工作线程。不拆的话它们全留到
|
||||
# 解释器退出时一起析构,而 offscreen 下那条路径会崩(实测)。
|
||||
# 每个用例各拆各的,也避免了"几十个 profile 同时析构"。
|
||||
self.window.close()
|
||||
self.window.deleteLater()
|
||||
pump(_APP)
|
||||
super().tearDown()
|
||||
@@ -780,7 +838,8 @@ if HAVE_QT:
|
||||
self._install_extra_plugin(other)
|
||||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||||
|
||||
# 默认规则:无配置、无 last_plugin → 按 id 排序第一个启用的插件
|
||||
# 默认规则:无配置、无 last_plugin → 显示顺序里第一个启用的插件
|
||||
# (默认顺序 = 按名称:"Other" 在 "SVFS 桥调试台" 前面)
|
||||
self.assertEqual(
|
||||
self.window._resolve_startup_plugin(), min(pid, other)
|
||||
)
|
||||
@@ -808,6 +867,151 @@ if HAVE_QT:
|
||||
self.assertIsNone(self.window.set_startup_plugin(""))
|
||||
_ui_settings().remove("ui/last_plugin")
|
||||
|
||||
def test_plugin_order_arrangement(self):
|
||||
"""编排插件顺序:↑/↓ 改显示顺序并落盘,侧栏/管理页/启动默认都跟随。"""
|
||||
from svfs_client import settings as client_settings
|
||||
from svfs_client.shell import _ui_settings
|
||||
|
||||
_ui_settings().remove("ui/last_plugin")
|
||||
self.window.set_startup_plugin("")
|
||||
self.window.apply_payload(self._submit_and_wait("install_sample", {}))
|
||||
other = "com.other.app"
|
||||
self._install_extra_plugin(other)
|
||||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||||
|
||||
ids = [entry["id"] for entry in self.window._plugins]
|
||||
self.assertEqual(len(ids), 2, "多插件场景没建起来,用例前提不成立")
|
||||
self.assertEqual(client_settings.plugin_order(self.base), [])
|
||||
self.assertFalse(self.window.manager.order_reset_button.isVisible())
|
||||
|
||||
top, bottom = ids[0], ids[-1]
|
||||
# 边界:首位不能上移、末位不能下移
|
||||
self.assertFalse(
|
||||
self.window.manager.cards[top].up_button.isEnabled())
|
||||
self.assertFalse(
|
||||
self.window.manager.cards[bottom].down_button.isEnabled())
|
||||
self.assertTrue(
|
||||
self.window.manager.cards[bottom].up_button.isEnabled())
|
||||
|
||||
# 上移一位:列表顺序、侧栏顺序、落盘的顺序三者一致
|
||||
self.window.manager.cards[bottom].up_button.click()
|
||||
pump(_APP)
|
||||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||||
[bottom, top])
|
||||
self.assertEqual(client_settings.plugin_order(self.base),
|
||||
[bottom, top])
|
||||
sidebar = [
|
||||
self.window.plugin_list.item(row).data(Qt.ItemDataRole.UserRole)
|
||||
for row in range(self.window.plugin_list.count())
|
||||
]
|
||||
self.assertEqual(sidebar, [bottom, top])
|
||||
# 编排过 → "恢复默认顺序"出现
|
||||
self.assertTrue(self.window.manager.order_reset_button.isVisible())
|
||||
|
||||
# 启动直显的默认规则跟着编排走(不再按 id/名称)
|
||||
self.assertEqual(self.window._resolve_startup_plugin(), bottom)
|
||||
|
||||
# worker 重新拉列表时也按编排顺序(不是只有界面上排了一下)
|
||||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||||
[bottom, top])
|
||||
|
||||
# 恢复默认顺序:回到按名称("Other" 在前)、设置清空、按钮消失
|
||||
self.window.manager.order_reset_button.click()
|
||||
pump(_APP)
|
||||
self.assertEqual(client_settings.plugin_order(self.base), [])
|
||||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||||
[other, client_sample.PLUGIN_ID])
|
||||
self.assertFalse(self.window.manager.order_reset_button.isVisible())
|
||||
|
||||
# 收尾:别把启动配置留给后续用例
|
||||
self.window.set_startup_plugin("")
|
||||
_ui_settings().remove("ui/last_plugin")
|
||||
|
||||
def test_settings_dialog_mqtt_config(self):
|
||||
"""设置里配 broker:落盘 + 立即重连(不用等下次登录)。"""
|
||||
from svfs_client.dialogs import SettingsDialog
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
applied = []
|
||||
dialog = SettingsDialog(
|
||||
self.base, apply_base_url=self.window.set_base_url,
|
||||
session_provider=lambda: self.window._session,
|
||||
on_mqtt_settings_changed=lambda: (applied.append(1),
|
||||
self.window._restart_messaging()),
|
||||
)
|
||||
self.addCleanup(dialog.deleteLater)
|
||||
# 没配 broker、服务端也没下发 → 显示"未启用"
|
||||
self.assertIn("未启用", dialog.mqtt_status.text())
|
||||
|
||||
dialog.mqtt_host_edit.setText("mqtts://broker.acme:8883")
|
||||
dialog.mqtt_prefix_edit.setText("acme")
|
||||
dialog.org_edit.setText("acme")
|
||||
dialog.mqtt_user_edit.setText("svc-client")
|
||||
dialog._save_mqtt_settings()
|
||||
pump(_APP)
|
||||
|
||||
# 落盘 + 回调(重连)
|
||||
data = client_settings.load(self.base)
|
||||
self.assertEqual(data["mqtt_host"], "mqtts://broker.acme:8883")
|
||||
self.assertEqual(data["mqtt_prefix"], "acme")
|
||||
self.assertEqual(data["org"], "acme")
|
||||
self.assertEqual(data["mqtt_username"], "svc-client")
|
||||
self.assertEqual(applied, [1])
|
||||
# 壳层确实按新配置重连了(tls 与端口由 URL 推出来)
|
||||
self.assertEqual(self.window.mqtt.config["host"], "broker.acme")
|
||||
self.assertEqual(self.window.mqtt.config["port"], 8883)
|
||||
self.assertTrue(self.window.mqtt.config["tls"])
|
||||
# 状态行显示生效配置
|
||||
self.assertIn("broker.acme", dialog.mqtt_status.text())
|
||||
self.assertIn("已保存并重连", dialog.mqtt_status.text())
|
||||
|
||||
# 清空 broker 地址 = 回到"不启用推送"(同步仍然工作)
|
||||
dialog.mqtt_host_edit.setText("")
|
||||
dialog._save_mqtt_settings()
|
||||
pump(_APP)
|
||||
self.assertFalse(self.window.mqtt.config["enabled"])
|
||||
|
||||
def test_settings_dialog_shows_server_announced_broker(self):
|
||||
"""服务端下发的 broker 也算生效配置(设置里留空时状态行要说明来源)。"""
|
||||
from svfs_client.dialogs import SettingsDialog
|
||||
|
||||
session = {"username": "admin", "token": "tok", "org": "acme",
|
||||
"mqtt": {"host": "broker.acme", "port": 8883, "tls": True}}
|
||||
dialog = SettingsDialog(
|
||||
self.base, apply_base_url=self.window.set_base_url,
|
||||
session_provider=lambda: session,
|
||||
)
|
||||
self.addCleanup(dialog.deleteLater)
|
||||
self.assertIn("broker.acme", dialog.mqtt_status.text())
|
||||
self.assertIn("服务端下发", dialog.mqtt_status.text())
|
||||
|
||||
def test_settings_dialog_tabs_group_by_type(self):
|
||||
"""设置项按类型分页签,而不是堆成一长条。"""
|
||||
from svfs_client.dialogs import SettingsDialog
|
||||
|
||||
dialog = SettingsDialog(self.base, apply_base_url=self.window.set_base_url)
|
||||
self.addCleanup(dialog.deleteLater)
|
||||
self.assertEqual(
|
||||
[dialog.tabs.tabText(i) for i in range(dialog.tabs.count())],
|
||||
["插件", "服务器", "存储", "网络", "诊断"])
|
||||
# 默认停在第一页:别的页上的控件此刻不可见
|
||||
self.assertTrue(dialog.startup_combo.isVisibleTo(dialog))
|
||||
self.assertFalse(dialog.server_edit.isVisibleTo(dialog))
|
||||
|
||||
# 各控件待在各自那一页
|
||||
for title, attr in (("服务器", "server_edit"), ("服务器", "mqtt_host_edit"),
|
||||
("服务器", "credentials_label"), ("存储", "cache_edit"),
|
||||
("存储", "cache_label"), ("网络", "proxy_edit"),
|
||||
("网络", "timeout_spin"), ("诊断", "log_check"),
|
||||
("插件", "startup_combo")):
|
||||
self.assertTrue(dialog.show_tab(title), title)
|
||||
widget = getattr(dialog, attr)
|
||||
self.assertTrue(widget.isVisibleTo(dialog),
|
||||
f"{attr} 不在「{title}」页签上")
|
||||
# 找不到的页签:不炸,返回 False
|
||||
self.assertFalse(dialog.show_tab("不存在的页"))
|
||||
|
||||
def test_settings_dialog_startup_and_cache(self):
|
||||
from svfs_client.dialogs import SettingsDialog
|
||||
|
||||
@@ -1072,6 +1276,197 @@ if HAVE_QT:
|
||||
self.assertEqual(self.window.status_label.text(), "服务端返回 500")
|
||||
self.assertTrue(self.window.manager.banner.isVisible())
|
||||
|
||||
@staticmethod
|
||||
def _white_pixels(image, x0, y0, width, height):
|
||||
"""数一块区域里的近白像素(扫原始字节,比逐点 pixelColor 快很多)。"""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
if image.format() != QImage.Format.Format_RGB32:
|
||||
image = image.convertToFormat(QImage.Format.Format_RGB32)
|
||||
raw = bytes(image.constBits())
|
||||
stride = image.bytesPerLine()
|
||||
count = 0
|
||||
for y in range(y0, y0 + height):
|
||||
start = y * stride + x0 * 4
|
||||
row = raw[start:start + width * 4]
|
||||
for x in range(0, len(row), 4):
|
||||
blue, green, red = row[x], row[x + 1], row[x + 2]
|
||||
if red > 230 and green > 230 and blue > 230:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def test_collapsed_sidebar_leaves_no_white_gap(self):
|
||||
"""收缩侧栏后不该留下没重绘的白块。
|
||||
|
||||
曾复现:主题只给 QMainWindow/QDialog 设了背景,页面栈是透明的,
|
||||
侧栏让出的那一条在重绘时序里露出白底——表现为页面左上角一块
|
||||
44×30 的纯白方块(`#ffffff`)。修法是给 QStackedWidget 不透明底色
|
||||
(见 app.create_app 里的样式)。这条用例在图上一把尺子,两个页面都量。
|
||||
"""
|
||||
self.window._set_sidebar_collapsed(True, animate=False)
|
||||
for page in (self.window.manager, self.window.messages_page):
|
||||
self.window.stack.setCurrentWidget(page)
|
||||
pump(_APP)
|
||||
image = self.window.grab().toImage()
|
||||
# 量侧栏右侧那片(白块出现的地方)——竖着量满高,横向量 300px
|
||||
whites = self._white_pixels(image, self.window.sidebar.width(), 0,
|
||||
300, image.height())
|
||||
self.assertEqual(
|
||||
whites, 0,
|
||||
f"{type(page).__name__} 在收缩侧栏后出现了 {whites} 个近白像素")
|
||||
self.window._set_sidebar_collapsed(False, animate=False)
|
||||
|
||||
def test_push_routes_host_and_plugin_messages(self):
|
||||
"""同一条总线:主机消息进消息中心,插件消息只进插件队列。"""
|
||||
from svfs_client import topics
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
bus = self.window.bus
|
||||
host_topic = topics.user_message_topic(bus.prefix, bus.org, "admin")
|
||||
plugin_topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin",
|
||||
"com.example.dash")
|
||||
|
||||
self.window._on_push(host_topic, '{"id": "m1", "title": "备份完成"}')
|
||||
self.window._on_push(plugin_topic, '{"cpu": 0.5}')
|
||||
pump(_APP)
|
||||
|
||||
self.assertEqual(len(bus.log), 1)
|
||||
self.assertEqual(self.window.messages_page.cards_box.count(),
|
||||
2) # 一张卡片 + 末尾 stretch
|
||||
self.assertEqual(self.window.messages_button.text(), "✉1")
|
||||
self.assertIn("1", self.window.messages_button.toolTip())
|
||||
# 插件消息没进消息中心,而是留在队列里等页面打开
|
||||
self.assertEqual(len(bus.log), 1)
|
||||
self.assertEqual(bus.pending("com.example.dash"), 1)
|
||||
self.assertEqual(self.window.messages_button.text(), "✉1")
|
||||
|
||||
def test_messages_center_mark_read_and_clear(self):
|
||||
from svfs_client import topics
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||||
self.window.bus.org, "admin")
|
||||
for index in range(2):
|
||||
self.window._on_push(topic, '{"id": "m%d", "body": "x"}' % index)
|
||||
pump(_APP)
|
||||
self.assertEqual(self.window.messages_button.text(), "✉2")
|
||||
|
||||
self.window._show_messages()
|
||||
pump(_APP)
|
||||
# 进消息中心即算读过(徽标不该一直挂着)
|
||||
self.assertEqual(self.window.bus.log.unread(), 0)
|
||||
self.assertEqual(self.window.messages_button.text(), "✉")
|
||||
self.assertIs(self.window.stack.currentWidget(),
|
||||
self.window.messages_page)
|
||||
|
||||
self.window.messages_page.clear_button.click()
|
||||
pump(_APP)
|
||||
self.assertEqual(len(self.window.bus.log), 0)
|
||||
self.assertTrue(self.window.messages_page.empty.isVisible())
|
||||
self.assertFalse(self.window.messages_page.read_button.isEnabled())
|
||||
|
||||
def test_plugin_push_is_buffered_until_page_opens(self):
|
||||
"""插件页面没开时消息先攒着,页面建好后一次性补投。"""
|
||||
from svfs_client import topics
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
bus = self.window.bus
|
||||
topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin", "com.dash")
|
||||
self.window._on_push(topic, '{"cpu": 1}')
|
||||
self.assertEqual(bus.pending("com.dash"), 1)
|
||||
|
||||
pushed = []
|
||||
|
||||
class _FakeView(QWidget):
|
||||
def push(self, payload):
|
||||
pushed.append(payload)
|
||||
|
||||
def shutdown(self): # close_plugin 会调(见 tearDownModule)
|
||||
pass
|
||||
|
||||
self.window._views["com.dash"] = _FakeView()
|
||||
self.window._flush_plugin_pushes("com.dash")
|
||||
self.assertEqual(pushed, ['{"cpu": 1}'])
|
||||
self.assertEqual(bus.pending("com.dash"), 0)
|
||||
# 页面已经开着时是直接投递,不进队列
|
||||
self.window._on_push(topic, '{"cpu": 2}')
|
||||
self.assertEqual(pushed, ['{"cpu": 1}', '{"cpu": 2}'])
|
||||
self.assertEqual(bus.pending("com.dash"), 0)
|
||||
|
||||
def test_login_sync_feeds_bus_and_dedupes_with_push(self):
|
||||
"""登录后先同步:补齐的消息与推送走同一条总线、按 id 去重。"""
|
||||
from svfs_client import topics
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||||
self.window.bus.org, "admin")
|
||||
# 推送先到一条,补齐(HTTP)又把同一条送回来 → 只留一条
|
||||
self.window._on_push(topic, '{"id": "m1", "title": "备份完成"}')
|
||||
self.window._on_messages_synced(
|
||||
{"messages": [{"id": "m1", "title": "备份完成"},
|
||||
{"id": "m2", "title": "另一条"}],
|
||||
"fetched": 2}, "admin")
|
||||
pump(_APP)
|
||||
self.assertEqual([m["id"] for m in self.window.bus.log.items()],
|
||||
["m1", "m2"])
|
||||
self.assertEqual(len(self.window.messages_page.cards_box.count() and
|
||||
self.window.bus.log.items()), 2)
|
||||
|
||||
# 没配服务器地址时同步不报错,只是拿不到东西(登录后的自动同步
|
||||
# 在测试环境里走的正是这条)
|
||||
self.window._pending_sync = False
|
||||
result = self.window.client.call_blocking("sync_messages", {})
|
||||
self.assertEqual(result["messages"], [])
|
||||
self.assertFalse(result["fetched"])
|
||||
|
||||
def test_stale_sync_result_is_discarded(self):
|
||||
"""在途的补齐结果在退出登录/换账号后必须丢掉(不能喂给新会话)。"""
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
self.window._on_messages_synced(
|
||||
{"messages": [{"id": "m1", "body": "上一个人的消息"}]}, "admin")
|
||||
self.assertEqual(len(self.window.bus.log), 1)
|
||||
|
||||
# 退出登录:身份没了 → 在途结果丢弃(这里不抛异常也算断言的一部分)
|
||||
self.window.logout()
|
||||
pump(_APP)
|
||||
self.window._on_messages_synced(
|
||||
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
|
||||
self.assertEqual(len(self.window.bus.log), 0)
|
||||
|
||||
# 换了账号:同一条在途结果属于旧身份 → 也丢弃
|
||||
self.window.set_session({"username": "bob", "token": "tok2"})
|
||||
self.window._on_messages_synced(
|
||||
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
|
||||
self.assertEqual(len(self.window.bus.log), 0)
|
||||
|
||||
def test_logout_stops_push_and_clears_messages(self):
|
||||
from svfs_client import topics
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||||
self.window.bus.org, "admin")
|
||||
self.window._on_push(topic, '{"id": "m1", "body": "x"}')
|
||||
self.assertEqual(len(self.window.bus.log), 1)
|
||||
|
||||
self.window.logout()
|
||||
pump(_APP)
|
||||
# 上一个身份的消息不能留给下一个人看
|
||||
self.assertEqual(len(self.window.bus.log), 0)
|
||||
self.assertEqual(self.window.messages_button.text(), "✉")
|
||||
# 断开后推来的东西也进不来(总线身份已清空)
|
||||
self.assertEqual(
|
||||
self.window.bus.deliver(topic, '{"body": "x"}')["action"],
|
||||
"forbidden")
|
||||
|
||||
def test_mqtt_state_is_shown_in_message_center(self):
|
||||
from svfs_client import mqtt
|
||||
|
||||
# 走服务自己的状态机:它先更新状态、再发信号(生产路径就是这样)
|
||||
self.window.mqtt._on_state(mqtt.STATE_ERROR, "认证失败")
|
||||
pump(_APP)
|
||||
self.assertIn("连接失败", self.window.messages_page.transport_label.text())
|
||||
self.assertIn("消息推送", self.window.status_label.text())
|
||||
|
||||
def test_diagnostics_log_collects_errors(self):
|
||||
from svfs_client import diagnostics
|
||||
|
||||
@@ -1124,10 +1519,12 @@ if HAVE_QT:
|
||||
self.window.manager.server_url_label.full_text(),
|
||||
"http://10.0.0.9:9000",
|
||||
)
|
||||
# 非法地址:报错、不落盘
|
||||
# 非法地址:报错、不落盘(服务器地址在"服务器"页签上)
|
||||
dialog.show_tab("服务器")
|
||||
dialog.server_edit.setText("ftp://nope")
|
||||
dialog._apply_base_url_clicked()
|
||||
self.assertTrue(dialog.server_error.isVisibleTo(dialog))
|
||||
self.assertIn("http", dialog.server_error.text())
|
||||
self.assertEqual(
|
||||
client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000"
|
||||
)
|
||||
@@ -1152,16 +1549,112 @@ if HAVE_QT:
|
||||
self.assertEqual(dialog.state_badge.text(), "已登录")
|
||||
self.assertTrue(dialog.logout_button.isEnabled())
|
||||
self.assertTrue(dialog.relogin_button.objectName() != "Primary")
|
||||
# 退出登录(真回调):会话清空、界面回到未登录态
|
||||
# 退出登录(真回调):凭据/会话清空,对话框关掉自己并把界面去向
|
||||
# 交给主窗口(登录门在那个模态循环外面跑)
|
||||
self.assertFalse(dialog.logout_requested)
|
||||
dialog.logout_button.click()
|
||||
self.assertIsNone(self.window._session)
|
||||
self.assertEqual(dialog.user_label.text(), "未登录")
|
||||
self.assertFalse(dialog.logout_button.isEnabled())
|
||||
self.assertEqual(dialog.relogin_button.objectName(), "Primary")
|
||||
self.assertEqual(dialog.state_badge.text(), "未登录")
|
||||
self.assertTrue(dialog.logout_requested)
|
||||
self.assertEqual(dialog.result(), QDialog.DialogCode.Accepted)
|
||||
# 侧栏头像同步回到「未登录」
|
||||
self.assertEqual(self.window.user_button.text(), "未登录")
|
||||
|
||||
def test_logout_closes_window_and_returns_to_login(self):
|
||||
"""退出登录 = 关主窗口 + 回登录界面(强制登录下没有"未登录的主窗口")。"""
|
||||
from svfs_client import credentials as client_credentials
|
||||
|
||||
payload = self._submit_and_wait("install_sample", {})
|
||||
self.window.apply_payload(payload)
|
||||
self.window.open_plugin(client_sample.PLUGIN_ID)
|
||||
pump(_APP)
|
||||
self.assertTrue(self.window._views, "插件页面没建起来,用例前提不成立")
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
client_credentials.save(self.base, "tok",
|
||||
base_url="http://127.0.0.1:1",
|
||||
username="admin")
|
||||
|
||||
fake = _FakeLoginWindow({"username": "bob", "token": "new"},
|
||||
visible_probe=lambda: self.window.isVisible())
|
||||
self._patch_login_window(fake)
|
||||
self.window._logout_with_gate()
|
||||
pump(_APP)
|
||||
|
||||
# 凭据清干净、插件页面收掉(不该留着上一个账号的界面状态)
|
||||
self.assertEqual(client_credentials.load(self.base), "")
|
||||
self.assertEqual(self.window._views, {})
|
||||
# 登录期间主窗口是关掉的,登录成功后带着新会话回来
|
||||
self.assertFalse(fake.main_window_visible_during)
|
||||
self.assertEqual(self.window._session["username"], "bob")
|
||||
self.assertTrue(self.window.isVisible())
|
||||
self.assertEqual(self.window.user_button.text(), "bob")
|
||||
|
||||
def test_logout_cancelled_login_exits_client(self):
|
||||
"""用户关掉登录窗:强制登录下没有可去的界面 → 关窗口并退出客户端。"""
|
||||
fake = _FakeLoginWindow(None,
|
||||
visible_probe=lambda: self.window.isVisible())
|
||||
self._patch_login_window(fake)
|
||||
app = QApplication.instance()
|
||||
quits = []
|
||||
original_quit = app.quit
|
||||
app.quit = lambda: quits.append(1)
|
||||
self.addCleanup(setattr, app, "quit", original_quit)
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
self.window._logout_with_gate()
|
||||
pump(_APP)
|
||||
|
||||
self.assertFalse(fake.main_window_visible_during)
|
||||
self.assertFalse(self.window.isVisible())
|
||||
self.assertEqual(quits, [1], "没有请求退出客户端")
|
||||
self.assertIsNone(self.window._session)
|
||||
|
||||
def test_logout_with_real_login_window_cancelled(self):
|
||||
"""真登录窗(不填表,直接关掉):主窗口关掉、客户端请求退出。
|
||||
|
||||
上面两条用替身验证登录门的时序;这条用真窗口走一遍
|
||||
show/exec/close,确认"隐藏主窗口 + 模态登录窗"在真 Qt 对象上不炸。
|
||||
"""
|
||||
from svfs_client import login as client_login
|
||||
|
||||
self.window.set_session({"username": "admin", "token": "tok"})
|
||||
app = QApplication.instance()
|
||||
quits = []
|
||||
original_quit = app.quit
|
||||
app.quit = lambda: quits.append(1)
|
||||
self.addCleanup(setattr, app, "quit", original_quit)
|
||||
|
||||
# 登录门是模态的(exec 阻塞),只能在嵌套循环里用定时器关掉它
|
||||
seen = {"login": False}
|
||||
|
||||
def close_login():
|
||||
for widget in QApplication.topLevelWidgets():
|
||||
if isinstance(widget, client_login.LoginWindow):
|
||||
seen["login"] = True
|
||||
widget.reject()
|
||||
return
|
||||
|
||||
timer = QTimer()
|
||||
timer.setInterval(50)
|
||||
timer.timeout.connect(close_login)
|
||||
timer.start()
|
||||
self.addCleanup(timer.stop)
|
||||
|
||||
self.window._logout_with_gate()
|
||||
pump(_APP)
|
||||
|
||||
self.assertTrue(seen["login"], "退出登录后没有出现登录窗")
|
||||
self.assertFalse(self.window.isVisible(), "主窗口没关掉")
|
||||
self.assertEqual(quits, [1], "没有请求退出客户端")
|
||||
self.assertIsNone(self.window._session)
|
||||
|
||||
def _patch_login_window(self, fake):
|
||||
"""把登录门里的登录窗换成替身(登录门内部才 import,patch 源头即可)。"""
|
||||
from svfs_client import login as client_login
|
||||
|
||||
original = client_login.LoginWindow
|
||||
client_login.LoginWindow = lambda *args, **kwargs: fake
|
||||
self.addCleanup(setattr, client_login, "LoginWindow", original)
|
||||
|
||||
|
||||
if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0":
|
||||
|
||||
@@ -1323,6 +1816,39 @@ if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0":
|
||||
self.assertIn("pong", net_result["body"])
|
||||
self.assertIsNone(window.set_base_url(""))
|
||||
|
||||
# 消息通道端到端(页面级):插件订阅 → 宿主推送 → 回调收到。
|
||||
# 这条覆盖 v5 的 messages 能力(页面里的 svfs.messages 与
|
||||
# window.__svfsPush 的投递路径)
|
||||
msgs = {}
|
||||
page.runJavaScript(
|
||||
"(function(){ window.__msg=null;"
|
||||
" if (!window.svfs || !window.svfs.messages) { return 'no-api'; }"
|
||||
" window.svfs.messages.subscribe(function (m) {"
|
||||
" window.__msg = m.text; });"
|
||||
" return 'ok'; })()",
|
||||
0, lambda r: msgs.update(api=r),
|
||||
)
|
||||
self.assertTrue(
|
||||
pump(_APP, predicate=lambda: msgs.get("api") is not None,
|
||||
timeout=15),
|
||||
"messages 桥探针没有拿到结果",
|
||||
)
|
||||
self.assertEqual(msgs.get("api"), "ok",
|
||||
"示例插件声明了 messages 权限,却拿不到 svfs.messages")
|
||||
|
||||
view.push('{"kind": "dash", "value": 7}')
|
||||
|
||||
def pushed_ready():
|
||||
page.runJavaScript("window.__msg", 0,
|
||||
lambda r: msgs.update(received=r))
|
||||
return msgs.get("received") is not None
|
||||
|
||||
self.assertTrue(
|
||||
pump(_APP, predicate=pushed_ready, timeout=15),
|
||||
f"推送没有到达插件页面: {msgs}",
|
||||
)
|
||||
self.assertIn("dash", msgs["received"])
|
||||
|
||||
# 渲染进程崩溃 → 崩溃占位页(而不是永久白屏)
|
||||
from svfs_client import diagnostics
|
||||
|
||||
|
||||
Reference in New Issue
Block a user