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:
2026-09-17 01:06:02 +08:00
parent ae9fc8dbe4
commit e0d5bad7b2
29 changed files with 4105 additions and 105 deletions
+537 -11
View File
@@ -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