2026-09-21 14:06:55 +08:00
|
|
|
|
"""GUI 冒烟测试(无头运行,驱动真实控件而非只构造窗口)。
|
|
|
|
|
|
|
|
|
|
|
|
覆盖:生成密钥对(含 0600 权限)→ 私钥状态跨页同步 → 解析申请码(含 weak 标注)
|
|
|
|
|
|
→ 插件列表自动发现 → 载荷预览 → 签发 → 查看/校验(目标机通过、异机拒绝)
|
|
|
|
|
|
→ 有效期三种模式的取值。
|
|
|
|
|
|
|
|
|
|
|
|
运行(需要 PySide6,故用本包的独立环境)::
|
|
|
|
|
|
|
|
|
|
|
|
cd utils/license-gui
|
|
|
|
|
|
uv run python smoke_test.py
|
|
|
|
|
|
|
|
|
|
|
|
注意:界面里的信息框/确认框会**阻塞事件循环等待点击**,无头环境下必须先替换掉,
|
|
|
|
|
|
否则测试会挂住。这也是本测试唯一需要 monkeypatch 的地方。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-09-22 16:08:12 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-09-21 14:06:55 +08:00
|
|
|
|
import sys
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from PySide6.QtCore import QSettings
|
|
|
|
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
|
|
|
|
|
|
|
|
failures: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
|
|
|
|
|
print(f"[{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
|
|
|
|
|
if not cond:
|
|
|
|
|
|
failures.append(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 16:08:12 +08:00
|
|
|
|
def _synthetic_sources() -> dict[str, str]:
|
|
|
|
|
|
"""非 Linux 平台上的替代指纹(已按库的规则归一化:小写、仅 [0-9a-z])。
|
|
|
|
|
|
|
|
|
|
|
|
含 1 个 weak 来源(machine_id),以便覆盖"weak 不参与判定"的标注逻辑。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"board_serial": "cnx7b2a4419e5",
|
|
|
|
|
|
"product_uuid": "4c4c4544003734108033b7c04f4a4d32",
|
|
|
|
|
|
"mac": "001b44113ab7",
|
|
|
|
|
|
"machine_id": "d9b8f0e6a1c34b2f9e7d5a6c8b0f1e2d",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _count_colors(widget, colors, step: int = 4) -> dict[str, int]:
|
|
|
|
|
|
"""统计渲染结果里各颜色的采样点数(验证主题确实画上去了)。
|
|
|
|
|
|
|
|
|
|
|
|
样式表写错时 Qt 往往**静默**忽略规则,界面只会退化回原生外观而不报错 ——
|
|
|
|
|
|
所以这里直接对抓下来的图取色,比只看 QSS 字符串可靠。
|
|
|
|
|
|
"""
|
|
|
|
|
|
image = widget.grab().toImage()
|
|
|
|
|
|
wanted = {c.lower(): 0 for c in colors}
|
|
|
|
|
|
for y in range(0, image.height(), step):
|
|
|
|
|
|
for x in range(0, image.width(), step):
|
|
|
|
|
|
name = image.pixelColor(x, y).name()
|
|
|
|
|
|
if name in wanted:
|
|
|
|
|
|
wanted[name] += 1
|
|
|
|
|
|
return wanted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rejects(call) -> bool:
|
|
|
|
|
|
"""调用是否按预期抛异常(用于校验"非法输入被拒绝")。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
call()
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _error_text(call) -> str | None:
|
|
|
|
|
|
"""调用并返回异常文本;没抛异常则返回 None。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
call()
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
return str(exc)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakePgConn:
|
|
|
|
|
|
"""假连接:记录执行过的 SQL,用来在**没有服务器**的环境下验证 PG 适配层。
|
|
|
|
|
|
|
|
|
|
|
|
只实现被 PostgresRegistry 用到的几个方法,不解释 SQL。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, rows: list | None = None) -> None:
|
|
|
|
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
|
|
|
|
self.rows = rows or []
|
|
|
|
|
|
|
|
|
|
|
|
def execute(self, sql, params=None): # noqa: ANN001, ANN201
|
|
|
|
|
|
self.calls.append((sql, tuple(params or ())))
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def fetchall(self):
|
|
|
|
|
|
return self.rows
|
|
|
|
|
|
|
|
|
|
|
|
def fetchone(self):
|
|
|
|
|
|
return self.rows[0] if self.rows else None
|
|
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pg_registry(fake, old=None):
|
|
|
|
|
|
"""绕过 __init__(不连数据库)构造一个可用的 PostgresRegistry。"""
|
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
|
|
from chongming_license_gui.pg_store import PgConfig, PostgresRegistry
|
|
|
|
|
|
|
|
|
|
|
|
reg = object.__new__(PostgresRegistry)
|
|
|
|
|
|
reg.config = PgConfig(host="h", port=5432, dbname="d", user="u")
|
|
|
|
|
|
reg.password = ""
|
|
|
|
|
|
reg.path = reg.config.display()
|
|
|
|
|
|
reg._lock = threading.RLock()
|
|
|
|
|
|
reg._schema = '"public"'
|
|
|
|
|
|
reg._table = '"public"."issued_licenses"'
|
|
|
|
|
|
reg._meta = '"public"."chongming_schema"'
|
|
|
|
|
|
reg._conn = fake
|
|
|
|
|
|
if old is not None:
|
|
|
|
|
|
# 模拟"台账里已经有这条":save() 的预读由它返回
|
|
|
|
|
|
reg.get = lambda lic_id, _old=old: _old
|
|
|
|
|
|
return reg
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 14:06:55 +08:00
|
|
|
|
def main() -> int:
|
2026-09-22 16:08:12 +08:00
|
|
|
|
# QApplication 实例必须保活,否则控件无法创建;引用由 Qt 内部持有,这里也留一份
|
|
|
|
|
|
app = QApplication.instance() or QApplication([])
|
2026-09-21 14:06:55 +08:00
|
|
|
|
|
|
|
|
|
|
from chongming_license import (
|
|
|
|
|
|
build_license,
|
|
|
|
|
|
collect_fingerprints,
|
|
|
|
|
|
encode_request_code,
|
|
|
|
|
|
machine_code,
|
|
|
|
|
|
read_license,
|
|
|
|
|
|
write_license,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
from chongming_license_gui import (
|
|
|
|
|
|
common,
|
|
|
|
|
|
inspect_tab,
|
|
|
|
|
|
issue_tab,
|
|
|
|
|
|
keys_tab,
|
|
|
|
|
|
registry_tab,
|
|
|
|
|
|
)
|
|
|
|
|
|
from chongming_license_gui.app import MainWindow
|
|
|
|
|
|
|
2026-09-22 16:08:12 +08:00
|
|
|
|
# 配置必须隔离到临时目录:**不能**指望 QSettings.setDefaultFormat 就够了 ——
|
|
|
|
|
|
# 实测两参数构造仍然写注册表 HKCU\Software\chongming\license-gui,
|
|
|
|
|
|
# 于是"清空初始状态"会清掉用户真实设置,"设个路径"会写到用户配置里。
|
|
|
|
|
|
settings = common.isolated_settings(tempfile.mkdtemp(prefix="clg-smoke-"))
|
|
|
|
|
|
common.assert_isolated_settings(settings, Path(settings.fileName()).parent)
|
|
|
|
|
|
settings.clear() # 现在清的是临时配置,初始状态确定
|
|
|
|
|
|
|
2026-09-21 14:06:55 +08:00
|
|
|
|
# 把模态弹窗换成记录器(否则会阻塞等待点击)
|
|
|
|
|
|
dialogs: list[tuple[str, str]] = []
|
|
|
|
|
|
|
|
|
|
|
|
def _mk(kind: str):
|
|
|
|
|
|
def _f(parent, title, text, *a, **k):
|
|
|
|
|
|
dialogs.append((kind, title))
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return _f
|
|
|
|
|
|
|
|
|
|
|
|
for mod in (keys_tab, issue_tab, inspect_tab, registry_tab):
|
|
|
|
|
|
mod.info = _mk("info")
|
|
|
|
|
|
mod.warn = _mk("warn")
|
|
|
|
|
|
mod.error = _mk("error")
|
|
|
|
|
|
if hasattr(mod, "ask_overwrite"):
|
|
|
|
|
|
mod.ask_overwrite = _mk("ask")
|
|
|
|
|
|
common.ask_overwrite = _mk("ask")
|
|
|
|
|
|
|
2026-09-22 16:08:12 +08:00
|
|
|
|
w = MainWindow(settings)
|
|
|
|
|
|
check("配置已隔离到临时目录(不碰真实配置)",
|
|
|
|
|
|
Path(w.settings.fileName()).is_relative_to(Path(settings.fileName()).parent),
|
|
|
|
|
|
w.settings.fileName())
|
|
|
|
|
|
# 显式给一个够大的窗口再取色:默认尺寸是"打开即最小"(940x640),
|
|
|
|
|
|
# 那样主按钮可能落在滚动区外,像素断言会跟着"应用默认窗口多大"漂移
|
|
|
|
|
|
w.resize(1240, 940)
|
2026-09-21 14:06:55 +08:00
|
|
|
|
w.show()
|
|
|
|
|
|
|
|
|
|
|
|
check("四个页面就绪",
|
|
|
|
|
|
[w.tabs.tabText(i) for i in range(w.tabs.count())]
|
|
|
|
|
|
== ["签发", "台账", "密钥", "查看 / 校验"],
|
|
|
|
|
|
str([w.tabs.tabText(i) for i in range(w.tabs.count())]))
|
|
|
|
|
|
|
2026-09-22 16:08:12 +08:00
|
|
|
|
# ---- 0) 界面主题:确认美化真的画到了界面上(而不是 QSS 里写了却没生效)----
|
|
|
|
|
|
from PySide6.QtWidgets import QPushButton
|
|
|
|
|
|
|
|
|
|
|
|
from chongming_license_gui import icons, theme
|
|
|
|
|
|
|
|
|
|
|
|
app.processEvents() # 让布局落位后再抓图取色
|
|
|
|
|
|
|
|
|
|
|
|
check("主题样式表已应用", bool(app.styleSheet()) and theme.ACCENT in app.styleSheet())
|
|
|
|
|
|
check("图标已渲染(非空 QIcon)",
|
|
|
|
|
|
not icons.icon("issue").isNull() and icons.svg_available(),
|
|
|
|
|
|
f"svg_available={icons.svg_available()}")
|
|
|
|
|
|
|
|
|
|
|
|
def _button_texts(parent) -> set[str]:
|
|
|
|
|
|
return {b.text() for b in parent.findChildren(QPushButton)}
|
|
|
|
|
|
|
|
|
|
|
|
def _fill_at(widget, x_ratio: float) -> str:
|
|
|
|
|
|
"""取控件内某个水平位置的颜色(避开边框与文字)。"""
|
|
|
|
|
|
image = widget.grab().toImage()
|
|
|
|
|
|
return image.pixelColor(int(image.width() * x_ratio), image.height() // 2).name()
|
|
|
|
|
|
|
|
|
|
|
|
def _has_color(widget, color: str, step: int = 2) -> bool:
|
|
|
|
|
|
image = widget.grab().toImage()
|
|
|
|
|
|
for y in range(0, image.height(), step):
|
|
|
|
|
|
for x in range(0, image.width(), step):
|
|
|
|
|
|
if image.pixelColor(x, y).name() == color.lower():
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
check("主操作按钮用主题主色填充",
|
|
|
|
|
|
_fill_at(w.issue_tab.issue_btn, 0.06) == theme.ACCENT.lower(),
|
|
|
|
|
|
_fill_at(w.issue_tab.issue_btn, 0.06))
|
|
|
|
|
|
secondary = next(
|
|
|
|
|
|
b for b in w.issue_tab.findChildren(QPushButton) if b.text() == "刷新预览"
|
|
|
|
|
|
)
|
|
|
|
|
|
check("次按钮保持白底(未误用主色)",
|
|
|
|
|
|
_fill_at(secondary, 0.06) == theme.SURFACE.lower(),
|
|
|
|
|
|
f"次按钮填充={_fill_at(secondary, 0.06)} 主色={theme.ACCENT}")
|
|
|
|
|
|
danger_button = next(
|
|
|
|
|
|
b for b in w.registry_tab.findChildren(QPushButton) if b.text() == "删除记录"
|
|
|
|
|
|
)
|
|
|
|
|
|
check("「删除记录」用危险色文字",
|
|
|
|
|
|
_has_color(danger_button, theme.DANGER),
|
|
|
|
|
|
f"含危险色={_has_color(danger_button, theme.DANGER)} 填充={_fill_at(danger_button, 0.06)}")
|
|
|
|
|
|
|
|
|
|
|
|
check("台账页按钮齐全",
|
|
|
|
|
|
{"续期选中…", "改插件…", "编辑备注", "查看详情", "删除记录"} <= _button_texts(w.registry_tab),
|
|
|
|
|
|
str(sorted(_button_texts(w.registry_tab))))
|
|
|
|
|
|
|
|
|
|
|
|
verdict_image = w.inspect_tab.verdict.grab().toImage()
|
|
|
|
|
|
check("结论横幅已渲染(非空)", verdict_image.width() > 40 and verdict_image.height() > 10,
|
|
|
|
|
|
f"{verdict_image.width()}x{verdict_image.height()}")
|
|
|
|
|
|
|
|
|
|
|
|
# 指示器是"出错时最安静"的地方:QSS 的 image 加载失败时 Qt 不报错,
|
|
|
|
|
|
# 勾选框会直接变成空白 —— 曾经因为占位符拼接写错而真的踩过一次。
|
|
|
|
|
|
check("样式表无未替换的占位符",
|
|
|
|
|
|
re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet()) is None,
|
|
|
|
|
|
str(re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet())))
|
|
|
|
|
|
check("勾选态指示器按主题渲染(指示灯不会变空白)",
|
|
|
|
|
|
_has_color(w.issue_tab.record_check, theme.ACCENT)
|
|
|
|
|
|
and _has_color(w.issue_tab.rb_days, theme.ACCENT),
|
|
|
|
|
|
"复选框/单选框均命中主色")
|
|
|
|
|
|
|
|
|
|
|
|
covered = _count_colors(w, [theme.ACCENT, theme.SURFACE, theme.BG, theme.WARN])
|
|
|
|
|
|
check("主色/卡片底/页面底/警示色都已渲染",
|
|
|
|
|
|
covered[theme.ACCENT] > 10 and covered[theme.SURFACE] > 200
|
|
|
|
|
|
and covered[theme.BG] > 50 and covered[theme.WARN] > 5,
|
|
|
|
|
|
str(covered))
|
|
|
|
|
|
|
2026-09-21 14:06:55 +08:00
|
|
|
|
# ---- 1) 生成密钥对 ----
|
|
|
|
|
|
tmp = Path(tempfile.mkdtemp())
|
|
|
|
|
|
priv = tmp / "priv.pem"
|
|
|
|
|
|
w.keys_tab.out_edit.setText(str(priv))
|
|
|
|
|
|
w.keys_tab._generate()
|
|
|
|
|
|
|
|
|
|
|
|
check("私钥已生成并加载", w.signing_key.loaded and priv.is_file())
|
2026-09-22 16:08:12 +08:00
|
|
|
|
# Windows 无 POSIX 权限位(chmod 退化为 0o666),只在 POSIX 平台断言
|
|
|
|
|
|
mode = oct(priv.stat().st_mode & 0o777)
|
|
|
|
|
|
check("私钥权限为 0600", (priv.stat().st_mode & 0o777) == 0o600 or os.name == "nt",
|
|
|
|
|
|
mode + ("(Windows 无 POSIX 权限位,跳过)" if os.name == "nt" else ""))
|
2026-09-21 14:06:55 +08:00
|
|
|
|
check("公钥指纹已算出", len(w.signing_key.fingerprint) == 16,
|
|
|
|
|
|
w.signing_key.fingerprint)
|
|
|
|
|
|
check("标题显示公钥指纹", w.signing_key.fingerprint in w.windowTitle(),
|
|
|
|
|
|
w.windowTitle())
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 2) 私钥状态跨页同步 ----
|
|
|
|
|
|
check("签发页同步到私钥", w.signing_key.fingerprint in w.issue_tab.key_fp.text(),
|
|
|
|
|
|
w.issue_tab.key_fp.text())
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 3) 解析申请码 ----
|
|
|
|
|
|
src = collect_fingerprints()
|
2026-09-22 16:08:12 +08:00
|
|
|
|
if not src:
|
|
|
|
|
|
# 硬件指纹来源全是 Linux 侧的(DMI / /proc/cpuinfo / eMMC CID / systemd
|
|
|
|
|
|
# machine-id),Windows/macOS 上必然采集为空。这里用合成指纹继续跑:
|
|
|
|
|
|
# 被测的是界面流程(解析→勾选→预览→签发→校验→台账),指纹真假不影响覆盖度。
|
|
|
|
|
|
src = _synthetic_sources()
|
|
|
|
|
|
print("[NOTE] 本平台采集不到硬件指纹,改用合成指纹继续(界面流程覆盖不受影响)")
|
2026-09-21 14:06:55 +08:00
|
|
|
|
rc = encode_request_code(src)
|
|
|
|
|
|
it = w.issue_tab
|
|
|
|
|
|
it.rc_edit.setPlainText(rc)
|
|
|
|
|
|
it._parse_request_code()
|
|
|
|
|
|
|
|
|
|
|
|
check("解析出机器码", it._machine_code == machine_code(src), it._machine_code)
|
|
|
|
|
|
check("解析出来源指纹", it._sources == src, str(sorted(it._sources)))
|
|
|
|
|
|
check("weak 来源被标注为不参与判定",
|
|
|
|
|
|
"不参与判定" in it.target_result.text(), it.target_result.text())
|
|
|
|
|
|
check("来源明细已展示",
|
|
|
|
|
|
len(it.sources_view.toPlainText().splitlines()) == len(src))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 4) 插件列表自动发现 ----
|
|
|
|
|
|
types = [it.plugin_list.item(i).text() for i in range(it.plugin_list.count())]
|
|
|
|
|
|
check("从 config/workers 自动列出插件", types == ["aibox_sophon_adapter", "ip_sound_column"],
|
|
|
|
|
|
str(types))
|
|
|
|
|
|
|
|
|
|
|
|
it._set_all_checked(True)
|
|
|
|
|
|
check("全选生效", it._checked_plugins() == types, str(it._checked_plugins()))
|
|
|
|
|
|
|
|
|
|
|
|
# 未勾选插件时应拒绝生成
|
|
|
|
|
|
it._set_all_checked(False)
|
|
|
|
|
|
it._refresh_preview()
|
|
|
|
|
|
check("未勾选插件时预览给出提示",
|
|
|
|
|
|
"至少勾选一个" in it.preview.toPlainText(), it.preview.toPlainText()[:40])
|
|
|
|
|
|
it._set_all_checked(True)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 5) 载荷预览 ----
|
|
|
|
|
|
it.customer_edit.setText("GUI 测试客户")
|
|
|
|
|
|
it.note_edit.setText("合同备注")
|
|
|
|
|
|
it.days_spin.setValue(30)
|
|
|
|
|
|
it._refresh_preview()
|
|
|
|
|
|
preview = json.loads(it.preview.toPlainText())
|
|
|
|
|
|
check("预览含全部授权插件", preview["plugins"] == types, str(preview["plugins"]))
|
|
|
|
|
|
check("预览含客户名", preview["customer"] == "GUI 测试客户")
|
|
|
|
|
|
check("预览含来源指纹", preview["sources"] == src)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 6) 签发 ----
|
|
|
|
|
|
out = tmp / "license.lic"
|
|
|
|
|
|
payload = it._build_payload()
|
|
|
|
|
|
doc = build_license(payload, w.signing_key.key, kid="gui-test")
|
|
|
|
|
|
write_license(doc, out)
|
|
|
|
|
|
check("license 文件已落盘", out.is_file(), str(out))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 7) 查看/校验 ----
|
|
|
|
|
|
vt = w.inspect_tab
|
|
|
|
|
|
vt.lic_edit.setText(str(out))
|
|
|
|
|
|
vt.pub_edit.setText(w.signing_key.public_b64)
|
|
|
|
|
|
|
|
|
|
|
|
vt._show_only()
|
|
|
|
|
|
check("仅查看不验签也能看到编号", payload["lic_id"] in vt.detail.text())
|
|
|
|
|
|
|
|
|
|
|
|
vt.rb_remote.setChecked(True)
|
|
|
|
|
|
vt.rc_edit.setText(rc)
|
|
|
|
|
|
vt._run()
|
|
|
|
|
|
check("目标机器校验通过", "✓" in vt.verdict.text(), vt.verdict.text())
|
|
|
|
|
|
|
|
|
|
|
|
other = {k: ("ffffffff" if k == "mac" else v) for k, v in src.items()}
|
|
|
|
|
|
other["machine_id"] = "anothervalueanothervalue"
|
|
|
|
|
|
vt.rc_edit.setText(encode_request_code(other))
|
|
|
|
|
|
vt._run()
|
|
|
|
|
|
check("异机校验被拒绝", "✗" in vt.verdict.text(), vt.verdict.text())
|
|
|
|
|
|
|
|
|
|
|
|
# 无公钥时应提示而不是静默失败
|
|
|
|
|
|
vt.pub_edit.setText("")
|
|
|
|
|
|
before = len(dialogs)
|
|
|
|
|
|
vt._run()
|
|
|
|
|
|
check("缺公钥时给出提示", len(dialogs) > before, str(dialogs[-1] if dialogs else None))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 8) 有效期三种模式 ----
|
|
|
|
|
|
it.rb_forever.setChecked(True)
|
|
|
|
|
|
it._refresh_preview()
|
|
|
|
|
|
check("永久模式 expires_at 为 None",
|
|
|
|
|
|
json.loads(it.preview.toPlainText())["expires_at"] is None)
|
|
|
|
|
|
|
|
|
|
|
|
it.rb_days.setChecked(True)
|
|
|
|
|
|
it._refresh_preview()
|
|
|
|
|
|
days_value = json.loads(it.preview.toPlainText())["expires_at"]
|
|
|
|
|
|
check("N 天模式给出到期日", bool(days_value), str(days_value))
|
|
|
|
|
|
|
|
|
|
|
|
it.rb_date.setChecked(True)
|
|
|
|
|
|
it._refresh_preview()
|
|
|
|
|
|
check("指定日期模式生效",
|
|
|
|
|
|
json.loads(it.preview.toPlainText())["expires_at"]
|
|
|
|
|
|
== it.date_edit.date().toString("yyyy-MM-dd"))
|
|
|
|
|
|
check("不勾选时对应输入框被禁用",
|
|
|
|
|
|
it.days_spin.isEnabled() is False and it.date_edit.isEnabled() is True)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 9) 台账页:签发即入库、检索、续期、导出 ----
|
|
|
|
|
|
rt = w.registry_tab
|
|
|
|
|
|
db = tmp / "licenses.db"
|
|
|
|
|
|
rt.db_edit.setText(str(db))
|
|
|
|
|
|
rt._open_db(silent=True)
|
|
|
|
|
|
check("台账库已打开", w.registry.is_open and w.registry.path == db, str(w.registry.path))
|
|
|
|
|
|
check("新库为空", rt.table.rowCount() == 0, str(rt.table.rowCount()))
|
|
|
|
|
|
|
|
|
|
|
|
# 签发页的"签发即入库"是台账的数据来源,这里直接调 holder 模拟该动作
|
|
|
|
|
|
check("签发页默认勾选「记入台账」", it.record_check.isChecked())
|
|
|
|
|
|
w.registry.save(doc, public_key_fp=w.signing_key.fingerprint, source="smoke")
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
check("入库后台账可见", rt.table.rowCount() == 1, str(rt.table.rowCount()))
|
|
|
|
|
|
|
|
|
|
|
|
row0 = [rt.table.item(0, c).text() for c in range(9)]
|
|
|
|
|
|
check("台账显示客户", row0[1] == "GUI 测试客户", row0[1])
|
|
|
|
|
|
check("台账显示机器码", row0[2] == it._machine_code, row0[2])
|
|
|
|
|
|
check("台账显示状态与剩余天数", "即将到期" in row0[4] and "d)" in row0[4], row0[4])
|
|
|
|
|
|
check("台账显示备注", row0[7] == "合同备注", row0[7])
|
2026-09-22 16:08:12 +08:00
|
|
|
|
check("状态列用主题状态色",
|
|
|
|
|
|
rt.table.item(0, 4).foreground().color().name()
|
|
|
|
|
|
== theme.status_color(rt.records[0].status()).lower(),
|
|
|
|
|
|
f"{rt.table.item(0, 4).foreground().color().name()} / {rt.records[0].status()}")
|
|
|
|
|
|
check("状态列加粗(急缓一眼可辨)", rt.table.item(0, 4).font().bold())
|
|
|
|
|
|
check("机器码列用等宽字体(便于逐字符核对)",
|
|
|
|
|
|
rt.table.item(0, 2).font().fixedPitch())
|
2026-09-21 14:06:55 +08:00
|
|
|
|
|
|
|
|
|
|
# 筛选
|
|
|
|
|
|
rt.machine_edit.setText(it._machine_code[:12])
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
check("按机器码筛选命中", rt.table.rowCount() == 1, str(rt.table.rowCount()))
|
|
|
|
|
|
rt.machine_edit.setText("ZZZZ-NOT-EXIST")
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
check("筛选无结果时为空表", rt.table.rowCount() == 0, str(rt.table.rowCount()))
|
|
|
|
|
|
rt._reset_filters()
|
|
|
|
|
|
check("重置后恢复全量", rt.table.rowCount() == 1, str(rt.table.rowCount()))
|
|
|
|
|
|
|
|
|
|
|
|
# 续期对话框:默认生成新编号、复用原指纹
|
|
|
|
|
|
from chongming_license_gui.registry_tab import RenewDialog
|
|
|
|
|
|
|
|
|
|
|
|
rec = rt.records[0]
|
|
|
|
|
|
dlg = RenewDialog(rec)
|
|
|
|
|
|
check("续期默认生成新编号(新旧都留台账)", dlg.values()["keep_lic_id"] is False)
|
|
|
|
|
|
dlg.rb_keep_id.setChecked(True)
|
|
|
|
|
|
check("可切换为沿用原编号", dlg.values()["keep_lic_id"] is True)
|
|
|
|
|
|
dlg.rb_keep_id.setChecked(False)
|
|
|
|
|
|
dlg.days_spin.setValue(365)
|
|
|
|
|
|
vals = dlg.values()
|
|
|
|
|
|
check("续期预填原插件", vals["plugins"] == rec.plugins, str(vals["plugins"]))
|
|
|
|
|
|
check("续期复用原指纹(无需再要申请码)", rec.sources == it._sources)
|
|
|
|
|
|
|
|
|
|
|
|
# 执行续期(走与界面相同的构造路径)
|
|
|
|
|
|
from chongming_license import build_license as _bl, build_payload as _bp, write_license as _wl
|
|
|
|
|
|
|
|
|
|
|
|
np = _bp(machine_code=rec.machine_code, plugins=vals["plugins"], sources=rec.sources,
|
|
|
|
|
|
customer=vals["customer"], note=vals["note"], expires_at=vals["expires_at"])
|
|
|
|
|
|
ndoc = _bl(np, w.signing_key.key, kid=rec.kid)
|
|
|
|
|
|
_wl(ndoc, tmp / "renewed.lic")
|
|
|
|
|
|
new_rec = w.registry.save(ndoc, public_key_fp=w.signing_key.fingerprint, source="renew")
|
|
|
|
|
|
reg = w.registry.ensure_open()
|
|
|
|
|
|
reg.update_note(rec.lic_id, f"{rec.note};已续期 → {new_rec.lic_id}")
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
check("续期后新旧记录都在台账", rt.table.rowCount() == 2, str(rt.table.rowCount()))
|
|
|
|
|
|
check("新授权为有效状态", new_rec.status() == "active", new_rec.status())
|
|
|
|
|
|
check("旧记录留下续期链",
|
|
|
|
|
|
f"已续期 → {new_rec.lic_id}" in reg.get(rec.lic_id).note,
|
|
|
|
|
|
reg.get(rec.lic_id).note)
|
|
|
|
|
|
check("续期后文件可独立验签",
|
|
|
|
|
|
read_license(tmp / "renewed.lic", w.signing_key.public_b64,
|
|
|
|
|
|
sources=it._sources).ok)
|
|
|
|
|
|
|
|
|
|
|
|
# 编辑备注:人工批注不被重新导入覆盖
|
|
|
|
|
|
reg.update_note(new_rec.lic_id, "人工批注")
|
|
|
|
|
|
reg.save(ndoc, public_key_fp=w.signing_key.fingerprint)
|
|
|
|
|
|
check("重新入库不覆盖人工批注",
|
|
|
|
|
|
reg.get(new_rec.lic_id).note == "人工批注", reg.get(new_rec.lic_id).note)
|
|
|
|
|
|
|
|
|
|
|
|
# 导出
|
|
|
|
|
|
files = reg.export_licenses(tmp / "archive", rt.records, subdir_by_customer=True)
|
|
|
|
|
|
check("导出 .lic 数量正确", len(files) == 2, str([f.name for f in files]))
|
|
|
|
|
|
csv_path = reg.export_csv(tmp / "台账.csv", rt.records)
|
|
|
|
|
|
check("CSV 含表头 + 数据行",
|
|
|
|
|
|
len(csv_path.read_text(encoding="utf-8-sig").splitlines()) == 3)
|
|
|
|
|
|
check("CSV 为 UTF-8 BOM(Excel 中文不乱码)",
|
|
|
|
|
|
csv_path.read_bytes()[:3] == b"\xef\xbb\xbf")
|
|
|
|
|
|
|
|
|
|
|
|
# 删除
|
|
|
|
|
|
check("删除记录", reg.delete(new_rec.lic_id) is True)
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
check("删除后台账少一条", rt.table.rowCount() == 1, str(rt.table.rowCount()))
|
|
|
|
|
|
|
2026-09-22 16:08:12 +08:00
|
|
|
|
# ---- 9b) 改授权插件:只动白名单,有效期与生效日逐字写回 ----
|
|
|
|
|
|
# 授权文件是签名过的,改插件只能重签一份新的;这组断言是那个契约的护栏。
|
|
|
|
|
|
from PySide6.QtWidgets import QDialog
|
|
|
|
|
|
|
|
|
|
|
|
from chongming_license_gui.registry_tab import ChangePluginsDialog
|
|
|
|
|
|
|
|
|
|
|
|
base = rt.records[0]
|
|
|
|
|
|
cdlg = ChangePluginsDialog(base, rt.settings)
|
|
|
|
|
|
check("改插件默认预勾选原白名单",
|
|
|
|
|
|
cdlg.values()["plugins"] == list(base.plugins), str(cdlg.values()["plugins"]))
|
|
|
|
|
|
check("改插件默认生成新编号(新旧都留台账)", cdlg.values()["keep_lic_id"] is False)
|
|
|
|
|
|
check("改插件没有有效期选项(改插件不该顺手改到期日)",
|
|
|
|
|
|
not hasattr(cdlg, "days_spin") and not hasattr(cdlg, "date_edit"))
|
|
|
|
|
|
check("改插件未动时差异显示「与原来一致」",
|
|
|
|
|
|
cdlg.diff_label.text() == "与原来一致", cdlg.diff_label.text())
|
|
|
|
|
|
check("改插件未动时不亮拒载告警", not cdlg._plugin_form.isRowVisible(cdlg.remove_hint))
|
|
|
|
|
|
|
|
|
|
|
|
# 移掉一个 → 差异行与被移除插件的拒载告警都要出来
|
|
|
|
|
|
cdlg.plugin_picker.set_checked(base.plugins[:1])
|
|
|
|
|
|
check("改插件差异行列出移除项", "移除" in cdlg.diff_label.text(), cdlg.diff_label.text())
|
|
|
|
|
|
check("改插件移除插件时亮出拒载告警", cdlg._plugin_form.isRowVisible(cdlg.remove_hint))
|
|
|
|
|
|
|
|
|
|
|
|
# 一个都不勾 → 拒绝提交(与签发页同一条规则)
|
|
|
|
|
|
cdlg.plugin_picker.set_all(False)
|
|
|
|
|
|
cdlg.accept()
|
|
|
|
|
|
check("改插件未勾选任何插件时拒绝提交",
|
|
|
|
|
|
cdlg.result() != QDialog.DialogCode.Accepted)
|
|
|
|
|
|
|
|
|
|
|
|
# 项目里已经没有的 type 也要能补回列表(老白名单必须始终看得见、勾得回来)
|
|
|
|
|
|
cdlg.plugin_picker.ensure_types(["legacy_plugin"])
|
|
|
|
|
|
cdlg.plugin_picker.set_checked([base.plugins[0], "legacy_plugin"])
|
|
|
|
|
|
cvals = cdlg.values()
|
|
|
|
|
|
check("改插件按列表顺序返回新白名单",
|
|
|
|
|
|
cvals["plugins"] == [base.plugins[0], "legacy_plugin"], str(cvals["plugins"]))
|
|
|
|
|
|
|
|
|
|
|
|
# 走与界面相同的构造路径重签(界面上只多一步"另存为"对话框)
|
|
|
|
|
|
np = _bp(machine_code=base.machine_code, plugins=cvals["plugins"], sources=base.sources,
|
|
|
|
|
|
customer=cvals["customer"], note=cvals["note"],
|
|
|
|
|
|
not_before=base.not_before, expires_at=base.expires_at)
|
|
|
|
|
|
ndoc = _bl(np, w.signing_key.key, kid=base.kid)
|
|
|
|
|
|
_wl(ndoc, tmp / "plugins.lic")
|
|
|
|
|
|
changed_rec = w.registry.save(ndoc, public_key_fp=w.signing_key.fingerprint, source="plugins")
|
|
|
|
|
|
reg.update_note(base.lic_id, f"{base.note};已变更插件 → {changed_rec.lic_id}")
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
check("改插件后新旧记录都在台账", rt.table.rowCount() == 2, str(rt.table.rowCount()))
|
|
|
|
|
|
check("改插件不改到期日", changed_rec.expires_at == base.expires_at,
|
|
|
|
|
|
f"{changed_rec.expires_at} / {base.expires_at}")
|
|
|
|
|
|
check("改插件复用台账指纹(无需再索取申请码)", changed_rec.sources == base.sources)
|
|
|
|
|
|
check("改插件后台账里的白名单已更新", changed_rec.plugins == cvals["plugins"],
|
|
|
|
|
|
str(changed_rec.plugins))
|
|
|
|
|
|
check("旧记录留下变更批注",
|
|
|
|
|
|
f"已变更插件 → {changed_rec.lic_id}" in reg.get(base.lic_id).note,
|
|
|
|
|
|
reg.get(base.lic_id).note)
|
|
|
|
|
|
|
|
|
|
|
|
# 载荷级别的证据:只有白名单该变,其余字段逐字未变
|
|
|
|
|
|
# (编号/签发时间天然会变;备注是"仅展示"字段,沿用台账批注 —— 与续期一致)
|
|
|
|
|
|
old_payload = dict(base.license_doc()["payload"])
|
|
|
|
|
|
new_payload = dict(ndoc["payload"])
|
|
|
|
|
|
check("改插件后载荷里的白名单就是新勾选",
|
|
|
|
|
|
new_payload.get("plugins") == cvals["plugins"], str(new_payload.get("plugins")))
|
|
|
|
|
|
check("改插件沿用台账备注(人工批注不随重签丢失)",
|
|
|
|
|
|
new_payload.get("note") == base.note, f"{new_payload.get('note')!r} / {base.note!r}")
|
|
|
|
|
|
for key in ("lic_id", "issued_at", "note", "plugins"):
|
|
|
|
|
|
old_payload.pop(key, None)
|
|
|
|
|
|
new_payload.pop(key, None)
|
|
|
|
|
|
check("改插件只改白名单:载荷其余字段逐字不变(含 not_before/expires_at/sources)",
|
|
|
|
|
|
old_payload == new_payload,
|
|
|
|
|
|
str([k for k in old_payload if old_payload[k] != new_payload.get(k)]))
|
|
|
|
|
|
|
|
|
|
|
|
plugins_col = {
|
|
|
|
|
|
rt.table.item(r, 0).text(): rt.table.item(r, 3).text()
|
|
|
|
|
|
for r in range(rt.table.rowCount())
|
|
|
|
|
|
}
|
|
|
|
|
|
check("改插件后台账「授权插件」列已更新",
|
|
|
|
|
|
plugins_col.get(changed_rec.lic_id) == ", ".join(cvals["plugins"]),
|
|
|
|
|
|
str(plugins_col))
|
|
|
|
|
|
vstatus = read_license(tmp / "plugins.lic", w.signing_key.public_b64, sources=it._sources)
|
|
|
|
|
|
check("改插件后的文件可独立验签且白名单已换",
|
|
|
|
|
|
vstatus.ok and vstatus.licensed_plugins == set(cvals["plugins"]),
|
|
|
|
|
|
f"ok={vstatus.ok} plugins={sorted(vstatus.licensed_plugins)}")
|
|
|
|
|
|
|
|
|
|
|
|
# 查看详情:构造时曾漏导入 QHBoxLayout,点了必崩(NameError)—— 这里守住回归
|
|
|
|
|
|
detail = rt._build_detail_dialog(base)
|
|
|
|
|
|
check("查看详情可正常构造", detail.windowTitle() == f"授权详情 {base.lic_id}",
|
|
|
|
|
|
detail.windowTitle())
|
|
|
|
|
|
detail.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
# 收尾:删掉本节新增的那条,让本节结束时的台账状态与插入之前一致(第 10 节按 1 条断言)
|
|
|
|
|
|
reg.delete(changed_rec.lic_id)
|
|
|
|
|
|
rt.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 10) PostgreSQL 后端(可选功能;本机无服务器,故只验证可验证的部分)----
|
|
|
|
|
|
from chongming_license_gui import pg_store
|
|
|
|
|
|
from chongming_license_gui.pg_store import PgConfig, PostgresRegistry, driver_available
|
|
|
|
|
|
|
|
|
|
|
|
check("PG 默认端口是 5432", pg_store.DEFAULT_PORT == 5432, str(pg_store.DEFAULT_PORT))
|
|
|
|
|
|
check("PG 连接展示串不含密码",
|
|
|
|
|
|
"S3cr3t-PW" not in PgConfig(host="h", user="u", password="S3cr3t-PW").display())
|
|
|
|
|
|
check("PG 拒绝非法 schema(防标识符注入)", _rejects(lambda: pg_store._quote_identifier("a;DROP")))
|
|
|
|
|
|
|
|
|
|
|
|
# SQL 形状:必须与库的 SQLite 版逐列一致,否则两个后端会漂移
|
|
|
|
|
|
check("PG 的 INSERT 是 15 列且占位符数量匹配",
|
|
|
|
|
|
pg_store.INSERT_SQL.count("%s") == len(pg_store.COLUMNS) == 15,
|
|
|
|
|
|
f"{pg_store.INSERT_SQL.count('%s')} 个占位符")
|
|
|
|
|
|
check("PG 列序与库一致",
|
|
|
|
|
|
list(pg_store.COLUMNS) == [
|
|
|
|
|
|
"lic_id", "machine_code", "customer", "note", "plugins_json",
|
|
|
|
|
|
"sources_json", "not_before", "expires_at", "issued_at", "kid",
|
|
|
|
|
|
"public_key_fp", "license_json", "source", "created_at", "updated_at",
|
|
|
|
|
|
])
|
|
|
|
|
|
check("PG 冲突时更新 13 列且**不含** created_at(保留首次入库时间)",
|
|
|
|
|
|
len(pg_store.UPDATE_COLUMNS) == 13
|
|
|
|
|
|
and "created_at" not in pg_store.UPDATE_COLUMNS
|
|
|
|
|
|
and "lic_id" not in pg_store.UPDATE_COLUMNS)
|
|
|
|
|
|
|
|
|
|
|
|
# 入库:走幂等 upsert,且参数与库的派生规则一致
|
|
|
|
|
|
fake = _FakePgConn()
|
|
|
|
|
|
pg_reg = _pg_registry(fake)
|
|
|
|
|
|
pg_reg.save(doc, public_key_fp="PG-FP", source="pg-smoke")
|
|
|
|
|
|
insert_sql, params = fake.calls[-1]
|
|
|
|
|
|
check("PG 入库用 ON CONFLICT(lic_id) 幂等写入",
|
|
|
|
|
|
"INSERT INTO" in insert_sql and "ON CONFLICT (lic_id) DO UPDATE" in insert_sql)
|
|
|
|
|
|
check("PG 入库传满 15 个参数", len(params) == 15, str(len(params)))
|
|
|
|
|
|
check("PG 机器码大写、公钥指纹与来源已写入",
|
|
|
|
|
|
params[1] == doc["payload"]["machine_code"].upper()
|
|
|
|
|
|
and params[10] == "PG-FP" and params[12] == "pg-smoke")
|
|
|
|
|
|
check("PG 的 JSON 文本中文不转义(与库一致)",
|
|
|
|
|
|
"\\u" not in params[4] and params[4].startswith("["))
|
|
|
|
|
|
|
|
|
|
|
|
# 覆盖已有记录:保留人工批注、保留 created_at、只刷新 updated_at(库的硬语义)
|
|
|
|
|
|
from chongming_license import LicenseRecord
|
|
|
|
|
|
|
|
|
|
|
|
old = LicenseRecord(
|
|
|
|
|
|
lic_id=doc["payload"]["lic_id"],
|
|
|
|
|
|
machine_code=doc["payload"]["machine_code"],
|
|
|
|
|
|
note="人工批注(不该被覆盖)",
|
|
|
|
|
|
created_at="2026-01-01T00:00:00Z",
|
|
|
|
|
|
)
|
|
|
|
|
|
fake2 = _FakePgConn()
|
|
|
|
|
|
pg_reg2 = _pg_registry(fake2, old=old)
|
|
|
|
|
|
pg_reg2.save(doc, public_key_fp="PG-FP", source="renew")
|
|
|
|
|
|
_, params2 = fake2.calls[-1]
|
|
|
|
|
|
check("PG 覆盖记录时保留人工批注", params2[3] == "人工批注(不该被覆盖)", params2[3])
|
|
|
|
|
|
check("PG 覆盖记录时保留首次入库时间", params2[13] == "2026-01-01T00:00:00Z", params2[13])
|
|
|
|
|
|
check("PG 覆盖记录时刷新 updated_at", params2[14] != "2026-01-01T00:00:00Z", params2[14])
|
|
|
|
|
|
|
|
|
|
|
|
pg_reg2.save(doc, public_key_fp="PG-FP", source="renew", overwrite_note=True)
|
|
|
|
|
|
_, params3 = fake2.calls[-1]
|
|
|
|
|
|
check("PG 显式 overwrite_note=True 时才用载荷备注",
|
|
|
|
|
|
params3[3] == (doc["payload"].get("note") or ""), params3[3])
|
|
|
|
|
|
|
|
|
|
|
|
# 查询:方言差异必须被抹平(SQLite 的 LIKE 大小写不敏感 → PG 用 ILIKE)
|
|
|
|
|
|
fake3 = _FakePgConn()
|
|
|
|
|
|
pg_reg3 = _pg_registry(fake3)
|
|
|
|
|
|
pg_reg3.query(customer="Acme", machine_code="bc60", plugin="aibox", keyword="kw")
|
|
|
|
|
|
query_sql, query_args = fake3.calls[-1]
|
|
|
|
|
|
check("PG 四个过滤条件都用 ILIKE(对齐 SQLite 的大小写不敏感)",
|
|
|
|
|
|
query_sql.count("ILIKE") == 7, f"{query_sql.count('ILIKE')} 处")
|
|
|
|
|
|
check("PG 机器码条件参数已大写", query_args[1].strip("%") == "BC60", query_args[1])
|
|
|
|
|
|
check("PG 插件条件带引号(避免前缀误命中)", query_args[2] == '%"aibox"%', query_args[2])
|
|
|
|
|
|
check("PG 排序与库一致(永久排最后)",
|
|
|
|
|
|
"COALESCE(expires_at, '9999-12-31')" in query_sql)
|
|
|
|
|
|
check("PG 状态筛选不写进 SQL(留在 Python 侧,与库同一套优先级)",
|
|
|
|
|
|
"status" not in query_sql.lower())
|
|
|
|
|
|
|
|
|
|
|
|
# 界面:后端下拉存在、默认仍是本地 SQLite(本地路径的行为不能变)
|
|
|
|
|
|
check("台账页有「存储」后端下拉", hasattr(rt, "backend_combo"))
|
|
|
|
|
|
check("默认后端仍是本地 SQLite",
|
|
|
|
|
|
rt.backend_combo.currentData() == "sqlite", str(rt.backend_combo.currentData()))
|
|
|
|
|
|
check("下拉里有 PostgreSQL 选项",
|
|
|
|
|
|
any(rt.backend_combo.itemData(i) == "postgres"
|
|
|
|
|
|
for i in range(rt.backend_combo.count())))
|
|
|
|
|
|
|
|
|
|
|
|
# 切换后端:行要跟着切,holder 也必须知道目标后端
|
|
|
|
|
|
# (不知道的话"确保可用"会按旧后端开库 —— 用户切回本地库、签发却写进了 PG)
|
|
|
|
|
|
rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("postgres"))
|
|
|
|
|
|
app.processEvents()
|
|
|
|
|
|
check("切到 PG 后只显示「连接」行",
|
|
|
|
|
|
rt._db_form.isRowVisible(rt.conn_row) and not rt._db_form.isRowVisible(rt.path_row))
|
|
|
|
|
|
check("切到 PG 后 holder 目标后端为 postgres", w.registry.backend == "postgres")
|
|
|
|
|
|
check("切到 PG 后原连接已断开(不留「界面显示 A、实际写 B」)",
|
|
|
|
|
|
not w.registry.is_open)
|
|
|
|
|
|
check("未连接时 ensure_open 给出可读提示,而不是静默连错库",
|
|
|
|
|
|
"未连接" in (_error_text(lambda: w.registry.ensure_open()) or ""),
|
|
|
|
|
|
(_error_text(lambda: w.registry.ensure_open()) or "").splitlines()[0])
|
|
|
|
|
|
|
|
|
|
|
|
rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("sqlite"))
|
|
|
|
|
|
app.processEvents()
|
|
|
|
|
|
check("切回本地后只显示「路径」行",
|
|
|
|
|
|
rt._db_form.isRowVisible(rt.path_row) and not rt._db_form.isRowVisible(rt.conn_row))
|
|
|
|
|
|
check("切回本地后 holder 目标后端为 sqlite", w.registry.backend == "sqlite")
|
|
|
|
|
|
rt.db_edit.setText(str(db))
|
|
|
|
|
|
rt._open_db(silent=True)
|
|
|
|
|
|
check("切回本地后能重新打开本地库(原记录还在)",
|
|
|
|
|
|
w.registry.is_open and len(rt.records) == 1, str(len(rt.records)))
|
|
|
|
|
|
|
|
|
|
|
|
# 未安装驱动时要给出可照抄的安装命令(把 psycopg 置为 None 即为"不可导入")
|
|
|
|
|
|
import sys as _sys
|
|
|
|
|
|
|
|
|
|
|
|
saved_module = _sys.modules.get("psycopg")
|
|
|
|
|
|
_sys.modules["psycopg"] = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
check("未装驱动时 driver_available() 返回 False", not driver_available())
|
|
|
|
|
|
missing = _error_text(lambda: PostgresRegistry(PgConfig(host="h", dbname="d")))
|
|
|
|
|
|
check("未装驱动时给出可照抄的安装提示",
|
|
|
|
|
|
"uv sync --extra postgres" in missing, missing.splitlines()[0])
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if saved_module is None:
|
|
|
|
|
|
_sys.modules.pop("psycopg", None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
_sys.modules["psycopg"] = saved_module
|
|
|
|
|
|
|
|
|
|
|
|
# 连不上时给的是人话而不是原始堆栈。
|
|
|
|
|
|
# 用绝不可能存在的库名,确保任何情况下都不会在真实服务器上建表。
|
|
|
|
|
|
dead = PgConfig(host="127.0.0.1", port=5432,
|
|
|
|
|
|
dbname="chongming_selftest_no_such_db_9f3a", user="nobody",
|
|
|
|
|
|
password="S3cr3t-NEVER-PRINT")
|
|
|
|
|
|
failure = _error_text(lambda: PostgresRegistry(dead))
|
|
|
|
|
|
if failure is None:
|
|
|
|
|
|
print("[NOTE] 本机 5432 上有 PostgreSQL 在跑,跳过「连不上时的提示」检查")
|
|
|
|
|
|
else:
|
|
|
|
|
|
check("连不上时给出可读提示(含地址与排查方向)",
|
|
|
|
|
|
"无法连接 PostgreSQL" in failure and "5432" in failure,
|
|
|
|
|
|
failure.splitlines()[0])
|
|
|
|
|
|
check("连接失败的提示与异常里都不含密码", "S3cr3t-NEVER-PRINT" not in failure)
|
|
|
|
|
|
|
2026-09-21 14:06:55 +08:00
|
|
|
|
print("\n" + "=" * 56)
|
|
|
|
|
|
if failures:
|
|
|
|
|
|
print(f"❌ {len(failures)} 项未通过: {failures}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
print("✅ GUI 冒烟测试全部通过")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|