2026-09-16 17:15:48 +08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""svfs_client 纯标准库层的测试:manifest / paths / registry / installer / sample。
|
|
|
|
|
|
|
|
|
|
不 import Qt——这五个模块必须能在没有 PySide6 的环境里跑通(分层约定见
|
|
|
|
|
src/svfs_client/__init__.py 的文档字符串)。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
from helpers import VFSTestCase, make_tree, quiet, temp_dir, write_file
|
|
|
|
|
|
|
|
|
|
from sqlite_vfs.core import SQLiteVFS
|
|
|
|
|
from sqlite_vfs.publish import export_objects
|
|
|
|
|
|
|
|
|
|
from svfs_client import PLUGIN_API_VERSION, installer, manifest as manifest_mod
|
|
|
|
|
from svfs_client import diagnostics, paths, registry, sample, search, settings
|
|
|
|
|
|
|
|
|
|
# helpers.py 会把 src/ 加进 sys.path;这里只需要保证它是测试的先决导入
|
|
|
|
|
assert sys.modules.get("helpers") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# 测试用的插件包构造
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
BASE_MANIFEST = {
|
|
|
|
|
"id": "com.example.demo",
|
|
|
|
|
"name": "演示插件",
|
|
|
|
|
"version": "1.0.0",
|
|
|
|
|
"api_version": PLUGIN_API_VERSION,
|
|
|
|
|
"entry": "index.html",
|
|
|
|
|
"icon": "icon.svg",
|
|
|
|
|
"permissions": ["storage"],
|
|
|
|
|
"description": "测试用",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_plugin(out_db, spec, version="1.0.0", overrides=None):
|
|
|
|
|
"""把一棵目录树打成插件包(沿用 installer 的打包路径)。"""
|
|
|
|
|
tree = dict(spec)
|
|
|
|
|
data = dict(BASE_MANIFEST)
|
|
|
|
|
data["version"] = version
|
|
|
|
|
data.update(overrides or {})
|
|
|
|
|
tree["plugin.json"] = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
|
|
|
with temp_dir() as src:
|
|
|
|
|
make_tree(src, tree)
|
|
|
|
|
installer._pack_tree(out_db, src, version)
|
|
|
|
|
return out_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
V1_SPEC = {
|
|
|
|
|
"index.html": b"<html>v1</html>",
|
|
|
|
|
"style.css": b"body { color: #eee; }",
|
|
|
|
|
"icon.svg": b"<svg/>",
|
|
|
|
|
"app.js": b"console.log('v1');",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
V2_SPEC = {
|
|
|
|
|
"index.html": b"<html>v1</html>", # 未变 → 应共享 blob
|
|
|
|
|
"style.css": b"body { color: #eee; }", # 未变 → 应共享 blob
|
|
|
|
|
"icon.svg": b"<svg/>", # 未变 → 应共享 blob
|
|
|
|
|
"app.js": b"console.log('v2 with more code');",
|
|
|
|
|
"changelog.txt": b"v2: new feature",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ManifestTest(unittest.TestCase):
|
|
|
|
|
def test_valid_minimal(self):
|
|
|
|
|
data = manifest_mod.validate(dict(BASE_MANIFEST, icon=None))
|
|
|
|
|
self.assertEqual(data["permissions"], ["storage"])
|
|
|
|
|
|
|
|
|
|
def test_net_permission_accepted(self):
|
|
|
|
|
data = manifest_mod.validate(
|
|
|
|
|
dict(BASE_MANIFEST, permissions=["net", "storage", "net"])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(data["permissions"], ["net", "storage"])
|
|
|
|
|
|
|
|
|
|
def test_missing_field(self):
|
|
|
|
|
for field in ("id", "name", "version", "entry", "api_version"):
|
|
|
|
|
broken = {k: v for k, v in BASE_MANIFEST.items() if k != field}
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, field):
|
|
|
|
|
manifest_mod.validate(broken)
|
|
|
|
|
|
|
|
|
|
def test_bad_id(self):
|
|
|
|
|
for bad in ("demo", "Demo.App", "com/example", "-com.example", "", 123,
|
|
|
|
|
"a" * 64 + ".x"):
|
|
|
|
|
with self.assertRaises(manifest_mod.ManifestError, msg=repr(bad)):
|
|
|
|
|
manifest_mod.validate(dict(BASE_MANIFEST, id=bad))
|
|
|
|
|
|
|
|
|
|
def test_wrong_api_version(self):
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, "api_version"):
|
|
|
|
|
manifest_mod.validate(dict(BASE_MANIFEST, api_version=99))
|
|
|
|
|
|
|
|
|
|
def test_older_api_version_accepted(self):
|
|
|
|
|
# 旧清单跑在新宿主上没问题:api_version 是下限匹配,不是相等匹配
|
|
|
|
|
data = manifest_mod.validate(
|
|
|
|
|
dict(BASE_MANIFEST, api_version=PLUGIN_API_VERSION - 1)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(data["api_version"], PLUGIN_API_VERSION - 1)
|
|
|
|
|
|
|
|
|
|
def test_api_version_must_be_positive(self):
|
|
|
|
|
for bad in (0, -1):
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, "api_version"):
|
|
|
|
|
manifest_mod.validate(dict(BASE_MANIFEST, api_version=bad))
|
|
|
|
|
|
|
|
|
|
def test_permissions_normalized(self):
|
|
|
|
|
data = manifest_mod.validate(dict(BASE_MANIFEST, permissions=["notify", "storage", "notify"]))
|
|
|
|
|
self.assertEqual(data["permissions"], ["notify", "storage"])
|
|
|
|
|
|
|
|
|
|
def test_net_hosts_optional_and_normalized(self):
|
|
|
|
|
# 不写 = 不限制(空列表)
|
|
|
|
|
self.assertEqual(manifest_mod.validate(BASE_MANIFEST)["net_hosts"], [])
|
|
|
|
|
data = manifest_mod.validate({
|
|
|
|
|
**BASE_MANIFEST, "permissions": ["net"],
|
|
|
|
|
"net_hosts": ["API.Example.com", "*.Corp.Example.", "api.example.com"],
|
|
|
|
|
})
|
|
|
|
|
self.assertEqual(data["net_hosts"],
|
|
|
|
|
["*.corp.example", "api.example.com"])
|
|
|
|
|
|
|
|
|
|
def test_net_hosts_rejects_bad_input(self):
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, "net_hosts"):
|
|
|
|
|
manifest_mod.validate({**BASE_MANIFEST, "permissions": ["net"],
|
|
|
|
|
"net_hosts": []})
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, "net_hosts"):
|
|
|
|
|
manifest_mod.validate({**BASE_MANIFEST, "permissions": ["net"],
|
|
|
|
|
"net_hosts": ["http://x:8000/api"]})
|
|
|
|
|
# 声明了白名单却没有 net 权限:几乎肯定写错了,安装时就报出来
|
|
|
|
|
with self.assertRaisesRegex(manifest_mod.ManifestError, "net 权限"):
|
|
|
|
|
manifest_mod.validate({**BASE_MANIFEST, "permissions": ["storage"],
|
|
|
|
|
"net_hosts": ["api.example.com"]})
|
|
|
|
|
|
|
|
|
|
def test_host_allowed(self):
|
|
|
|
|
# 空 = 不限制
|
|
|
|
|
self.assertTrue(manifest_mod.host_allowed("any.example", []))
|
|
|
|
|
self.assertTrue(manifest_mod.host_allowed("api.example.com",
|
|
|
|
|
["api.example.com"]))
|
|
|
|
|
self.assertFalse(manifest_mod.host_allowed("evil.example",
|
|
|
|
|
["api.example.com"]))
|
|
|
|
|
# *. 后缀匹配:子域与其本身都算,但不吃 "notcorp.example"
|
|
|
|
|
patterns = ["*.corp.example"]
|
|
|
|
|
self.assertTrue(manifest_mod.host_allowed("a.corp.example", patterns))
|
|
|
|
|
self.assertTrue(manifest_mod.host_allowed("corp.example", patterns))
|
|
|
|
|
self.assertFalse(manifest_mod.host_allowed("notcorp.example", patterns))
|
|
|
|
|
# 大小写不敏感
|
|
|
|
|
self.assertTrue(manifest_mod.host_allowed("API.Example.com",
|
|
|
|
|
["api.example.com"]))
|
|
|
|
|
|
|
|
|
|
def test_bad_permission(self):
|
|
|
|
|
with self.assertRaises(manifest_mod.ManifestError):
|
|
|
|
|
manifest_mod.validate(dict(BASE_MANIFEST, permissions=["filesystem"]))
|
|
|
|
|
|
|
|
|
|
def test_from_json_bom(self):
|
|
|
|
|
text = b"\xef\xbb\xbf" + json.dumps(BASE_MANIFEST).encode("utf-8")
|
|
|
|
|
data = manifest_mod.from_json(text.decode("utf-8"))
|
|
|
|
|
self.assertEqual(data["id"], BASE_MANIFEST["id"])
|
|
|
|
|
|
|
|
|
|
def test_from_json_broken(self):
|
|
|
|
|
with self.assertRaises(manifest_mod.ManifestError):
|
|
|
|
|
manifest_mod.from_json("{oops")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PathsTest(VFSTestCase):
|
|
|
|
|
def test_explicit_data_dir(self):
|
|
|
|
|
self.assertEqual(paths.data_dir("D:/ somewhere/x"), os.path.abspath("D:/ somewhere/x"))
|
|
|
|
|
|
|
|
|
|
def test_layout_and_id_paths(self):
|
|
|
|
|
base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
for sub in ("plugins", "storage", "web"):
|
|
|
|
|
self.assertTrue(os.path.isdir(os.path.join(base, sub)))
|
|
|
|
|
pkg = paths.plugin_package(base, "com.example.demo")
|
|
|
|
|
self.assertTrue(pkg.endswith(os.path.join("plugins", "com.example.demo") + os.sep + "plugin.svfs"))
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
paths.storage_file(base, "com.example.demo"),
|
|
|
|
|
os.path.join(base, "storage", "com.example.demo.json"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_id_with_slash_rejected(self):
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
paths.plugin_package(self.tmp, "../evil")
|
|
|
|
|
|
|
|
|
|
def test_dir_size_and_clear(self):
|
|
|
|
|
make_tree(self.path("cache"), {"a.bin": b"x" * 10, "sub/b.bin": b"y" * 5})
|
|
|
|
|
self.assertEqual(paths.dir_size(self.path("cache")), 15)
|
|
|
|
|
self.assertEqual(paths.clear_dir_contents(self.path("cache")), 2)
|
|
|
|
|
self.assertTrue(os.path.isdir(self.path("cache"))) # 目录本身保留
|
|
|
|
|
self.assertEqual(paths.dir_size(self.path("cache")), 0)
|
|
|
|
|
|
|
|
|
|
def test_dir_helpers_tolerate_missing_dir(self):
|
|
|
|
|
self.assertEqual(paths.dir_size(self.path("nope")), 0)
|
|
|
|
|
self.assertEqual(paths.clear_dir_contents(self.path("nope")), 0)
|
|
|
|
|
|
|
|
|
|
def test_object_cache_dir_is_absolute(self):
|
|
|
|
|
self.assertTrue(os.path.isabs(paths.object_cache_dir()))
|
|
|
|
|
|
|
|
|
|
def test_data_dir_pointer_file(self):
|
|
|
|
|
# 默认:无指针 → 默认目录
|
|
|
|
|
self.assertEqual(paths.resolve_data_dir(), paths.data_dir())
|
|
|
|
|
# 写指针(目标目录必须真实存在)→ 生效
|
|
|
|
|
target = paths.ensure_layout(self.path("alt-data"))
|
|
|
|
|
self.assertIsNone(paths.set_data_dir(target))
|
|
|
|
|
self.assertEqual(paths.resolve_data_dir(), os.path.abspath(target))
|
|
|
|
|
# --data 命令行参数最优先
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
paths.resolve_data_dir(self.path("cmdline")),
|
|
|
|
|
os.path.abspath(self.path("cmdline")),
|
|
|
|
|
)
|
|
|
|
|
# 指向不存在的目录 → 回退默认(坏指针不让客户端起不来)
|
|
|
|
|
os.unlink(paths.data_dir_file())
|
|
|
|
|
self.assertEqual(paths.resolve_data_dir(), paths.data_dir())
|
|
|
|
|
|
|
|
|
|
def test_set_data_dir_clear_and_invalid(self):
|
|
|
|
|
target = paths.ensure_layout(self.path("alt-data"))
|
|
|
|
|
paths.set_data_dir(target)
|
|
|
|
|
# 空串 = 清除重定向
|
|
|
|
|
self.assertIsNone(paths.set_data_dir(""))
|
|
|
|
|
self.assertEqual(paths.resolve_data_dir(), paths.data_dir())
|
|
|
|
|
# 目录不存在 → 报错且不写指针
|
|
|
|
|
error = paths.set_data_dir(self.path("nope"))
|
|
|
|
|
self.assertIn("不存在", error)
|
|
|
|
|
self.assertEqual(paths.resolve_data_dir(), paths.data_dir())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RegistryTest(VFSTestCase):
|
|
|
|
|
def test_empty_load(self):
|
|
|
|
|
data = registry.load(self.tmp)
|
|
|
|
|
self.assertEqual(data["plugins"], {})
|
|
|
|
|
|
|
|
|
|
def test_roundtrip(self):
|
|
|
|
|
data = registry.load(self.tmp)
|
|
|
|
|
registry.put(data, {"id": "com.a.b", "name": "A", "enabled": True})
|
|
|
|
|
registry.save(self.tmp, data)
|
|
|
|
|
again = registry.load(self.tmp)
|
|
|
|
|
self.assertEqual(again["plugins"]["com.a.b"]["name"], "A")
|
|
|
|
|
self.assertEqual(registry.get(again, "com.a.b")["enabled"], True)
|
|
|
|
|
self.assertIsNone(registry.remove(again, "nope"))
|
|
|
|
|
self.assertEqual(registry.remove(again, "com.a.b")["id"], "com.a.b")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsTest(VFSTestCase):
|
|
|
|
|
"""base_url / 登录凭据的规范化、持久化与地址解析。"""
|
|
|
|
|
|
|
|
|
|
def test_normalize(self):
|
|
|
|
|
self.assertEqual(settings.normalize_base_url(" http://srv:8000/ "), "http://srv:8000")
|
|
|
|
|
self.assertEqual(settings.normalize_base_url("https://a.b/c/"), "https://a.b/c")
|
|
|
|
|
self.assertEqual(settings.normalize_base_url(None), "")
|
|
|
|
|
self.assertEqual(settings.normalize_base_url(""), "")
|
|
|
|
|
for bad in ("ftp://x", "srv:8000", "//host", "http:/miss"):
|
|
|
|
|
with self.assertRaisesRegex(ValueError, "http", msg=bad):
|
|
|
|
|
settings.normalize_base_url(bad)
|
|
|
|
|
|
|
|
|
|
def test_load_defaults_and_roundtrip(self):
|
|
|
|
|
data = settings.load(self.tmp)
|
|
|
|
|
# 缺文件 → 默认(含登录凭据/启动直显/缓存目录/网络/日志)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
data,
|
|
|
|
|
{"base_url": "", "auth_token": "", "auth_user": "",
|
2026-09-17 01:06:02 +08:00
|
|
|
"startup_plugin": "", "plugin_order": [], "cache_dir": "",
|
2026-09-16 17:15:48 +08:00
|
|
|
"net_timeout": settings.NET_TIMEOUT_DEFAULT,
|
|
|
|
|
"net_retries": settings.NET_RETRIES_DEFAULT,
|
2026-09-17 01:06:02 +08:00
|
|
|
"proxy": "", "ca_bundle": "", "log_to_file": True,
|
|
|
|
|
"org": "", "mqtt_host": "",
|
|
|
|
|
"mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False,
|
|
|
|
|
"mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""},
|
2026-09-16 17:15:48 +08:00
|
|
|
)
|
|
|
|
|
data["base_url"] = "http://10.0.0.5:9000"
|
|
|
|
|
settings.save(self.tmp, data)
|
|
|
|
|
self.assertEqual(settings.load(self.tmp)["base_url"], "http://10.0.0.5:9000")
|
|
|
|
|
|
|
|
|
|
def test_broken_settings_file_is_ignored(self):
|
|
|
|
|
write_file(paths.settings_file(self.tmp), b"{broken")
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.load(self.tmp),
|
|
|
|
|
{"base_url": "", "auth_token": "", "auth_user": "",
|
2026-09-17 01:06:02 +08:00
|
|
|
"startup_plugin": "", "plugin_order": [], "cache_dir": "",
|
2026-09-16 17:15:48 +08:00
|
|
|
"net_timeout": settings.NET_TIMEOUT_DEFAULT,
|
|
|
|
|
"net_retries": settings.NET_RETRIES_DEFAULT,
|
2026-09-17 01:06:02 +08:00
|
|
|
"proxy": "", "ca_bundle": "", "log_to_file": True,
|
|
|
|
|
"org": "", "mqtt_host": "",
|
|
|
|
|
"mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False,
|
|
|
|
|
"mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""},
|
2026-09-16 17:15:48 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_resolve_cache_dir(self):
|
|
|
|
|
# 未配置 → 系统默认
|
|
|
|
|
self.assertEqual(settings.resolve_cache_dir(self.tmp),
|
|
|
|
|
paths.object_cache_dir())
|
|
|
|
|
# 配置了(支持 ~ 与相对展开)→ 生效
|
|
|
|
|
data = settings.load(self.tmp)
|
|
|
|
|
data["cache_dir"] = "~/svfs-cache"
|
|
|
|
|
settings.save(self.tmp, data)
|
|
|
|
|
resolved = settings.resolve_cache_dir(self.tmp)
|
|
|
|
|
self.assertTrue(os.path.isabs(resolved))
|
|
|
|
|
self.assertIn("svfs-cache", resolved)
|
|
|
|
|
self.assertNotIn("~", resolved)
|
|
|
|
|
|
|
|
|
|
def test_resolve_manifest_url(self):
|
|
|
|
|
base = "http://srv:8000"
|
|
|
|
|
# 完整地址原样通过
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.resolve_manifest_url(base, "http://other/x/manifest.json"),
|
|
|
|
|
"http://other/x/manifest.json",
|
|
|
|
|
)
|
|
|
|
|
# 插件 id → 约定子目录
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.resolve_manifest_url(base, "com.example.clock"),
|
|
|
|
|
"http://srv:8000/com.example.clock/manifest.json",
|
|
|
|
|
)
|
|
|
|
|
# 相对路径
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.resolve_manifest_url(base, "/clock/stable/manifest.json"),
|
|
|
|
|
"http://srv:8000/clock/stable/manifest.json",
|
|
|
|
|
)
|
|
|
|
|
# 未配置服务器时相对地址无从解析
|
|
|
|
|
with self.assertRaisesRegex(ValueError, "服务器"):
|
|
|
|
|
settings.resolve_manifest_url("", "com.example.clock")
|
|
|
|
|
with self.assertRaisesRegex(ValueError, "请填写"):
|
|
|
|
|
settings.resolve_manifest_url(base, " ")
|
|
|
|
|
|
|
|
|
|
def test_resolve_net_url(self):
|
|
|
|
|
base = "http://srv:8000"
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.resolve_net_url(base, url="https://api.x/v1"), "https://api.x/v1"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.resolve_net_url(base, path="/api/ping"), "http://srv:8000/api/ping"
|
|
|
|
|
)
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
settings.resolve_net_url("", path="/api/ping")
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
settings.resolve_net_url(base)
|
|
|
|
|
with self.assertRaisesRegex(ValueError, "http"):
|
|
|
|
|
settings.resolve_net_url(base, url="file:///etc/passwd")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InstallerTest(VFSTestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
self.pid = BASE_MANIFEST["id"]
|
|
|
|
|
|
|
|
|
|
def install(self, spec, version="1.0.0", **kwargs):
|
|
|
|
|
pkg = self.path(f"plugin-{version}.svfs")
|
|
|
|
|
build_plugin(pkg, spec, version=version)
|
|
|
|
|
with quiet():
|
|
|
|
|
return installer.install_from_file(pkg, base=self.base, **kwargs)
|
|
|
|
|
|
|
|
|
|
def test_first_install(self):
|
|
|
|
|
entry = self.install(V1_SPEC)
|
|
|
|
|
self.assertTrue(entry["enabled"])
|
|
|
|
|
self.assertEqual(entry["version"], "1.0.0")
|
|
|
|
|
self.assertEqual(entry["source"]["type"], "local")
|
|
|
|
|
target = paths.plugin_package(self.base, self.pid)
|
|
|
|
|
self.assertTrue(os.path.exists(target))
|
|
|
|
|
# 注册表落盘,列表可读
|
|
|
|
|
self.assertEqual(installer.list_installed(self.base)[0]["id"], self.pid)
|
|
|
|
|
|
|
|
|
|
def test_reinstall_is_upgrade(self):
|
|
|
|
|
self.install(V1_SPEC)
|
|
|
|
|
entry = self.install(V2_SPEC, version="2.0.0")
|
|
|
|
|
self.assertEqual(entry["version"], "2.0.0")
|
|
|
|
|
self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"])
|
|
|
|
|
self.assertEqual(entry["current_snapshot_id"], entry["snapshots"][1]["id"])
|
|
|
|
|
|
|
|
|
|
# 快照即版本:新旧内容都在一个包里
|
|
|
|
|
vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True)
|
|
|
|
|
try:
|
|
|
|
|
self.assertEqual(vfs.read_file("app.js"), V2_SPEC["app.js"])
|
|
|
|
|
vfs.use_snapshot(1)
|
|
|
|
|
self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"])
|
|
|
|
|
# 未变的三个文件共享 blob:8 个 blob < 两个快照的条目总数
|
|
|
|
|
self.assertLess(len(vfs.list_blobs()), 5 + 6)
|
|
|
|
|
finally:
|
|
|
|
|
vfs.close()
|
|
|
|
|
|
|
|
|
|
def test_upgrade_rollback(self):
|
|
|
|
|
self.install(V1_SPEC)
|
|
|
|
|
self.install(V2_SPEC, version="2.0.0")
|
|
|
|
|
entry = installer.use_version(self.base, self.pid, 1)
|
|
|
|
|
self.assertEqual(entry["version"], "1.0.0")
|
|
|
|
|
self.assertEqual(entry["current_snapshot_id"], 1)
|
|
|
|
|
# 回滚对协议层生效:按注册表指向的快照开包,看到的是旧内容
|
|
|
|
|
registry_data = registry.load(self.base)
|
|
|
|
|
sid = registry_data["plugins"][self.pid]["current_snapshot_id"]
|
|
|
|
|
vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True,
|
|
|
|
|
snapshot_id=sid)
|
|
|
|
|
try:
|
|
|
|
|
self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"])
|
|
|
|
|
finally:
|
|
|
|
|
vfs.close()
|
|
|
|
|
|
|
|
|
|
def test_rollback_to_missing_snapshot(self):
|
|
|
|
|
self.install(V1_SPEC)
|
|
|
|
|
with self.assertRaises(installer.InstallError):
|
|
|
|
|
installer.use_version(self.base, self.pid, 42)
|
|
|
|
|
|
|
|
|
|
def test_uninstall(self):
|
|
|
|
|
self.install(V1_SPEC)
|
|
|
|
|
# 留一份键值存储,卸载时应一并删除
|
|
|
|
|
write_file(paths.storage_file(self.base, self.pid), b"{}")
|
|
|
|
|
installer.uninstall(self.base, self.pid)
|
|
|
|
|
self.assertFalse(os.path.exists(paths.plugin_package(self.base, self.pid)))
|
|
|
|
|
self.assertFalse(os.path.exists(paths.storage_file(self.base, self.pid)))
|
|
|
|
|
self.assertEqual(installer.list_installed(self.base), [])
|
|
|
|
|
|
|
|
|
|
def test_reject_package_without_manifest(self):
|
|
|
|
|
src = make_tree(self.path("bare"), {"index.html": b"<html/>"})
|
|
|
|
|
pkg = self.path("bare.svfs")
|
|
|
|
|
with quiet():
|
|
|
|
|
vfs = SQLiteVFS(pkg, compress=True)
|
|
|
|
|
try:
|
|
|
|
|
from sqlite_vfs.folder_packer import FolderPacker
|
|
|
|
|
|
|
|
|
|
FolderPacker(vfs).pack_folder(src, incremental=True)
|
|
|
|
|
finally:
|
|
|
|
|
vfs.close()
|
|
|
|
|
with quiet(), self.assertRaisesRegex(installer.InstallError, "plugin.json"):
|
|
|
|
|
installer.install_from_file(pkg, base=self.base)
|
|
|
|
|
|
|
|
|
|
def test_reject_tampered_package(self):
|
|
|
|
|
pkg = self.path("plugin-1.0.0.svfs")
|
|
|
|
|
build_plugin(pkg, V1_SPEC)
|
|
|
|
|
# 绕过 API 改元数据但不 recount():摘要对不上,verify() 应当拦住它
|
|
|
|
|
vfs = SQLiteVFS(pkg)
|
|
|
|
|
try:
|
|
|
|
|
vfs.conn.execute(
|
|
|
|
|
"UPDATE files SET file_size = file_size + 1 WHERE is_directory = 0"
|
|
|
|
|
)
|
|
|
|
|
vfs.conn.commit()
|
|
|
|
|
finally:
|
|
|
|
|
vfs.close()
|
|
|
|
|
with quiet(), self.assertRaises(installer.InstallError):
|
|
|
|
|
installer.install_from_file(pkg, base=self.base)
|
|
|
|
|
|
|
|
|
|
def test_reject_wrong_api_version(self):
|
|
|
|
|
pkg = self.path("future.svfs")
|
|
|
|
|
build_plugin(pkg, V1_SPEC, overrides={"api_version": PLUGIN_API_VERSION + 1})
|
|
|
|
|
with quiet(), self.assertRaisesRegex(installer.InstallError, "api_version"):
|
|
|
|
|
installer.install_from_file(pkg, base=self.base)
|
|
|
|
|
|
|
|
|
|
def test_accept_older_api_version(self):
|
|
|
|
|
# 语义同 manifest:要求 ≤ 宿主版本即可安装(升级宿主不废掉旧插件)
|
|
|
|
|
pkg = self.path("older.svfs")
|
|
|
|
|
build_plugin(pkg, V1_SPEC, overrides={"api_version": PLUGIN_API_VERSION - 1})
|
|
|
|
|
with quiet():
|
|
|
|
|
entry = installer.install_from_file(pkg, base=self.base)
|
|
|
|
|
self.assertEqual(entry["id"], self.pid)
|
|
|
|
|
|
|
|
|
|
def test_set_enabled(self):
|
|
|
|
|
self.install(V1_SPEC)
|
|
|
|
|
entry = installer.set_enabled(self.base, self.pid, False)
|
|
|
|
|
self.assertFalse(entry["enabled"])
|
|
|
|
|
self.assertFalse(registry.load(self.base)["plugins"][self.pid]["enabled"])
|
|
|
|
|
with self.assertRaises(installer.InstallError):
|
|
|
|
|
installer.set_enabled(self.base, "nope.app", True)
|
|
|
|
|
|
|
|
|
|
def test_install_progress(self):
|
|
|
|
|
pkg = self.path("plugin-1.0.0.svfs")
|
|
|
|
|
build_plugin(pkg, V1_SPEC)
|
|
|
|
|
events = []
|
|
|
|
|
with quiet():
|
|
|
|
|
installer.install_from_file(pkg, base=self.base,
|
|
|
|
|
progress=lambda d, t, label: events.append((d, t, label)))
|
|
|
|
|
self.assertEqual(events, []) # 首次安装是整包复制,不走逐文件进度
|
|
|
|
|
|
|
|
|
|
v2 = self.path("plugin-2.0.0.svfs")
|
|
|
|
|
build_plugin(v2, V2_SPEC, version="2.0.0")
|
|
|
|
|
events = []
|
|
|
|
|
with quiet():
|
|
|
|
|
installer.install_from_file(v2, base=self.base,
|
|
|
|
|
progress=lambda d, t, label: events.append((d, t, label)))
|
|
|
|
|
self.assertTrue(events)
|
|
|
|
|
self.assertEqual(max(d for d, _, _ in events), max(t for _, t, _ in events))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RemoteInstallTest(VFSTestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
self.pid = BASE_MANIFEST["id"]
|
|
|
|
|
|
|
|
|
|
def test_install_from_local_objects(self):
|
|
|
|
|
src = self.path("plugin-1.0.0.svfs")
|
|
|
|
|
build_plugin(src, V1_SPEC)
|
|
|
|
|
objects = export_objects(src, self.path("public"))
|
|
|
|
|
|
|
|
|
|
entry = installer.install_from_url(
|
|
|
|
|
os.path.join(objects["out_dir"], "manifest.json"), base=self.base
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(entry["version"], "1.0.0")
|
|
|
|
|
self.assertEqual(entry["source"]["type"], "url")
|
|
|
|
|
# 内容逐字节一致
|
|
|
|
|
vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True)
|
|
|
|
|
try:
|
|
|
|
|
self.assertEqual(vfs.read_file("index.html"), V1_SPEC["index.html"])
|
|
|
|
|
self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"])
|
|
|
|
|
finally:
|
|
|
|
|
vfs.close()
|
|
|
|
|
|
|
|
|
|
def test_remote_upgrade(self):
|
|
|
|
|
src1 = self.path("plugin-1.0.0.svfs")
|
|
|
|
|
build_plugin(src1, V1_SPEC)
|
|
|
|
|
with quiet():
|
|
|
|
|
installer.install_from_file(src1, base=self.base)
|
|
|
|
|
|
|
|
|
|
src2 = self.path("plugin-2.0.0.svfs")
|
|
|
|
|
build_plugin(src2, V2_SPEC, version="2.0.0")
|
|
|
|
|
objects = export_objects(src2, self.path("p2"))
|
|
|
|
|
entry = installer.install_from_url(
|
|
|
|
|
os.path.join(objects["out_dir"], "manifest.json"), base=self.base
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(entry["version"], "2.0.0")
|
|
|
|
|
self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SampleTest(VFSTestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
|
|
|
|
|
def test_sample_source_is_valid_plugin(self):
|
|
|
|
|
with temp_dir() as src:
|
|
|
|
|
sample.write_source(src, version=1)
|
|
|
|
|
with open(os.path.join(src, "plugin.json"), "rb") as f:
|
|
|
|
|
data = manifest_mod.from_json(f.read().decode("utf-8"))
|
|
|
|
|
self.assertEqual(data["id"], sample.PLUGIN_ID)
|
|
|
|
|
for name in ("index.html", "app.js", "style.css", "icon.svg"):
|
|
|
|
|
self.assertTrue(os.path.exists(os.path.join(src, name)), name)
|
|
|
|
|
with open(os.path.join(src, "app.js"), encoding="utf-8") as f:
|
|
|
|
|
self.assertIn("svfsReady", f.read())
|
|
|
|
|
|
|
|
|
|
def test_sample_build_and_upgrade(self):
|
|
|
|
|
out = self.path("sample.svfs")
|
|
|
|
|
_, v = sample.build(out)
|
|
|
|
|
self.assertEqual(v, "1.0.0")
|
|
|
|
|
with quiet():
|
|
|
|
|
installer.install_from_file(out, base=self.base)
|
|
|
|
|
|
|
|
|
|
_, v = sample.build(out)
|
|
|
|
|
self.assertEqual(v, "2.0.0")
|
|
|
|
|
with quiet():
|
|
|
|
|
entry = installer.install_from_file(out, base=self.base)
|
|
|
|
|
self.assertEqual(entry["version"], "2.0.0")
|
|
|
|
|
self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"])
|
|
|
|
|
|
|
|
|
|
# 升级后的包必须能通过权威校验
|
|
|
|
|
with quiet():
|
|
|
|
|
report = installer._verify_content(out)
|
|
|
|
|
self.assertTrue(report["ok"], report["errors"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VersionCompareTest(VFSTestCase):
|
|
|
|
|
"""更新检查用的版本比较(installer.is_newer_version)。"""
|
|
|
|
|
|
|
|
|
|
def test_newer(self):
|
|
|
|
|
self.assertTrue(installer.is_newer_version("2.0.0", "1.0.0"))
|
|
|
|
|
self.assertTrue(installer.is_newer_version("1.10.0", "1.9.0"))
|
|
|
|
|
self.assertTrue(installer.is_newer_version("1.0.1", "1.0.0"))
|
|
|
|
|
self.assertTrue(installer.is_newer_version("2.0.0", "1.0.0-2"))
|
|
|
|
|
|
|
|
|
|
def test_not_newer(self):
|
|
|
|
|
for remote, current in (("1.0.0", "1.0.0"), ("1.0.0", "2.0.0"),
|
|
|
|
|
("1.0.0", "1.0.0-2")):
|
|
|
|
|
self.assertFalse(installer.is_newer_version(remote, current),
|
|
|
|
|
f"{remote} vs {current}")
|
|
|
|
|
|
|
|
|
|
def test_unparsable_is_conservative(self):
|
|
|
|
|
self.assertFalse(installer.is_newer_version("", "1.0.0"))
|
|
|
|
|
self.assertFalse(installer.is_newer_version(None, "1.0.0"))
|
|
|
|
|
|
|
|
|
|
def test_client_update_status(self):
|
|
|
|
|
payload = {"version": "0.9.0", "url": "https://x/SVFS-Client.zip",
|
|
|
|
|
"notes": "修了几个坑"}
|
|
|
|
|
result = installer.client_update_status(payload, "0.1.0")
|
|
|
|
|
self.assertTrue(result["update"])
|
|
|
|
|
self.assertEqual(result["latest"], "0.9.0")
|
|
|
|
|
self.assertEqual(result["url"], "https://x/SVFS-Client.zip")
|
|
|
|
|
self.assertEqual(result["notes"], "修了几个坑")
|
|
|
|
|
self.assertEqual(result["current"], "0.1.0")
|
|
|
|
|
# 不比当前新 → 不提示
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
installer.client_update_status(payload, "0.9.0")["update"])
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
installer.client_update_status(payload, "1.0.0")["update"])
|
|
|
|
|
|
|
|
|
|
def test_client_update_status_tolerates_missing_or_bad_payload(self):
|
|
|
|
|
for payload in (None, [], "x", {}, {"url": "https://x"}):
|
|
|
|
|
result = installer.client_update_status(payload, "0.1.0")
|
|
|
|
|
self.assertFalse(result["update"], payload)
|
|
|
|
|
self.assertEqual(result["latest"], "")
|
|
|
|
|
self.assertEqual(result["current"], "0.1.0")
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 01:06:02 +08:00
|
|
|
class PluginOrderTest(VFSTestCase):
|
|
|
|
|
"""插件顺序编排(纯函数 + 设置持久化)。"""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _entry(plugin_id, name):
|
|
|
|
|
return {"id": plugin_id, "name": name}
|
|
|
|
|
|
|
|
|
|
def test_arrange_without_order_keeps_name_order(self):
|
|
|
|
|
entries = [self._entry("com.b", "Bravo"), self._entry("com.a", "Alpha")]
|
|
|
|
|
arranged = installer.arrange(entries, [])
|
|
|
|
|
self.assertEqual([e["id"] for e in arranged], ["com.a", "com.b"])
|
|
|
|
|
# 空 order 与 None 等价("从没编排过"这两种写法都可能出现)
|
|
|
|
|
self.assertEqual(installer.arrange(entries, None), arranged)
|
|
|
|
|
# 不改动传入的列表(原地排序会污染调用方持有的 payload)
|
|
|
|
|
self.assertEqual([e["id"] for e in entries], ["com.b", "com.a"])
|
|
|
|
|
|
|
|
|
|
def test_arrange_puts_ordered_ids_first(self):
|
|
|
|
|
entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo"),
|
|
|
|
|
self._entry("com.c", "Charlie")]
|
|
|
|
|
arranged = installer.arrange(entries, ["com.c", "com.a"])
|
|
|
|
|
self.assertEqual([e["id"] for e in arranged],
|
|
|
|
|
["com.c", "com.a", "com.b"]) # 未编排的按名称跟后面
|
|
|
|
|
|
|
|
|
|
def test_arrange_ignores_uninstalled_ids(self):
|
|
|
|
|
entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo")]
|
|
|
|
|
# com.gone 已卸载:当没看见,剩下两个仍按给定顺序排
|
|
|
|
|
arranged = installer.arrange(entries, ["com.gone", "com.b"])
|
|
|
|
|
self.assertEqual([e["id"] for e in arranged], ["com.b", "com.a"])
|
|
|
|
|
|
|
|
|
|
def test_arrange_is_stable_for_new_plugins(self):
|
|
|
|
|
"""新装的插件(不在 order 里)落在"未编排"那一档,不干扰已编排的。"""
|
|
|
|
|
entries = [self._entry("com.a", "Alpha"), self._entry("com.new", "Zulu"),
|
|
|
|
|
self._entry("com.b", "Bravo")]
|
|
|
|
|
arranged = installer.arrange(entries, ["com.b", "com.a"])
|
|
|
|
|
self.assertEqual([e["id"] for e in arranged],
|
|
|
|
|
["com.b", "com.a", "com.new"])
|
|
|
|
|
|
|
|
|
|
def test_plugin_order_roundtrip_and_cleanup(self):
|
|
|
|
|
base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
self.assertEqual(settings.plugin_order(base), [])
|
|
|
|
|
# 去重 + 保序 + 丢掉空值
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
settings.set_plugin_order(base, ["com.b", "com.a", "com.b", "", None]),
|
|
|
|
|
["com.b", "com.a"],
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(settings.plugin_order(base), ["com.b", "com.a"])
|
|
|
|
|
# 复位
|
|
|
|
|
settings.set_plugin_order(base, [])
|
|
|
|
|
self.assertEqual(settings.plugin_order(base), [])
|
|
|
|
|
|
|
|
|
|
def test_plugin_order_survives_bad_values(self):
|
|
|
|
|
base = paths.ensure_layout(self.path("data"))
|
|
|
|
|
data = settings.load(base)
|
|
|
|
|
data["plugin_order"] = "com.a" # 手改坏成字符串
|
|
|
|
|
settings.save(base, data)
|
|
|
|
|
self.assertEqual(settings.plugin_order(base), [])
|
|
|
|
|
# 返回的是副本:改它不影响模块级默认值(否则后面每次读取都被污染)
|
|
|
|
|
first = settings.load(base)["plugin_order"]
|
|
|
|
|
first.append("com.polluted")
|
|
|
|
|
self.assertEqual(settings.load(base)["plugin_order"], [])
|
|
|
|
|
self.assertEqual(settings.DEFAULTS["plugin_order"], [])
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 17:15:48 +08:00
|
|
|
class DiagnosticsTest(VFSTestCase):
|
|
|
|
|
"""诊断日志:环形缓冲、报告、自身绝不抛异常。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
diagnostics.clear()
|
|
|
|
|
# 落盘是全局开关:默认关掉(否则会把日志写进别的用例的临时目录)
|
|
|
|
|
diagnostics.configure(None)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
diagnostics.clear()
|
|
|
|
|
diagnostics.configure(None)
|
|
|
|
|
super().tearDown()
|
|
|
|
|
|
|
|
|
|
def test_log_and_recent(self):
|
|
|
|
|
diagnostics.log("第一条", "worker")
|
|
|
|
|
diagnostics.log("第二条", "net")
|
|
|
|
|
lines = diagnostics.recent()
|
|
|
|
|
self.assertEqual(len(lines), 2)
|
|
|
|
|
self.assertIn("[worker] 第一条", lines[0])
|
|
|
|
|
self.assertIn("[net] 第二条", lines[1])
|
|
|
|
|
self.assertEqual(diagnostics.recent(1), [lines[-1]])
|
|
|
|
|
|
|
|
|
|
def test_ring_limit(self):
|
|
|
|
|
for i in range(diagnostics.LOG_LIMIT + 30):
|
|
|
|
|
diagnostics.log(f"第 {i} 条")
|
|
|
|
|
lines = diagnostics.recent()
|
|
|
|
|
self.assertEqual(len(lines), diagnostics.LOG_LIMIT)
|
|
|
|
|
# 最旧的被挤掉,最新的一定在
|
|
|
|
|
self.assertIn(f"第 {diagnostics.LOG_LIMIT + 29} 条", lines[-1])
|
|
|
|
|
self.assertNotIn("第 0 条", lines[0])
|
|
|
|
|
|
|
|
|
|
def test_clear(self):
|
|
|
|
|
diagnostics.log("x")
|
|
|
|
|
diagnostics.clear()
|
|
|
|
|
self.assertEqual(diagnostics.recent(), [])
|
|
|
|
|
|
|
|
|
|
def test_log_never_raises(self):
|
|
|
|
|
# 日志服务于排障,不能反过来把调用方搞挂
|
|
|
|
|
diagnostics.log(None) # 非字符串 message
|
|
|
|
|
diagnostics.log("ok", object()) # 非字符串 source
|
|
|
|
|
self.assertTrue(diagnostics.recent())
|
|
|
|
|
|
|
|
|
|
def test_report_contains_env_and_logs(self):
|
|
|
|
|
diagnostics.log("页面报错: boom", "console:com.svfs.demo")
|
|
|
|
|
text = diagnostics.report({"数据目录": "C:/x", "Qt 版本": "6.6"})
|
|
|
|
|
self.assertIn("SVFS Client 诊断报告", text)
|
|
|
|
|
self.assertIn("数据目录: C:/x", text)
|
|
|
|
|
self.assertIn("Qt 版本: 6.6", text)
|
|
|
|
|
self.assertIn("页面报错: boom", text)
|
|
|
|
|
self.assertIn("console:com.svfs.demo", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DiagnosticsFileTest(VFSTestCase):
|
|
|
|
|
"""诊断日志落盘与轮转:崩了/重启之后还能回看现场。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
diagnostics.clear()
|
|
|
|
|
self.logs = self.path("logs")
|
|
|
|
|
self.addCleanup(diagnostics.configure, None)
|
|
|
|
|
|
|
|
|
|
def _log_file(self):
|
|
|
|
|
return os.path.join(self.logs, diagnostics.LOG_FILE_NAME)
|
|
|
|
|
|
|
|
|
|
def test_log_is_appended_to_file(self):
|
|
|
|
|
diagnostics.configure(self.logs)
|
|
|
|
|
self.assertEqual(diagnostics.log_path(), self._log_file())
|
|
|
|
|
diagnostics.log("插件页面报错", "console:com.svfs.demo")
|
|
|
|
|
with open(self._log_file(), encoding="utf-8") as handle:
|
|
|
|
|
text = handle.read()
|
|
|
|
|
self.assertIn("[console:com.svfs.demo] 插件页面报错", text)
|
|
|
|
|
# 报告里也带上日志文件位置(贴给开发者时知道去哪儿找全量日志)
|
|
|
|
|
self.assertIn(diagnostics.log_path(), diagnostics.report({}))
|
|
|
|
|
|
|
|
|
|
def test_configure_none_stops_writing(self):
|
|
|
|
|
diagnostics.configure(self.logs)
|
|
|
|
|
diagnostics.log("第一条")
|
|
|
|
|
diagnostics.configure(None)
|
|
|
|
|
self.assertEqual(diagnostics.log_path(), "")
|
|
|
|
|
diagnostics.log("第二条(不落盘)")
|
|
|
|
|
with open(self._log_file(), encoding="utf-8") as handle:
|
|
|
|
|
text = handle.read()
|
|
|
|
|
self.assertIn("第一条", text)
|
|
|
|
|
self.assertNotIn("不落盘", text)
|
|
|
|
|
# 内存那份照旧(导出报告仍然有它)
|
|
|
|
|
self.assertIn("第二条(不落盘)", diagnostics.recent()[-1])
|
|
|
|
|
|
|
|
|
|
def test_rotation_keeps_size_bounded(self):
|
|
|
|
|
diagnostics.configure(self.logs, max_bytes=1024, backups=2)
|
|
|
|
|
for index in range(200):
|
|
|
|
|
diagnostics.log(f"第 {index} 行,凑一点长度好触发轮转")
|
|
|
|
|
files = sorted(os.listdir(self.logs))
|
|
|
|
|
self.assertIn(diagnostics.LOG_FILE_NAME, files)
|
|
|
|
|
self.assertIn(diagnostics.LOG_FILE_NAME + ".1", files)
|
|
|
|
|
# 份数封顶:最多 1 个当前文件 + backups 个历史文件
|
|
|
|
|
self.assertLessEqual(len(files), 1 + 2)
|
|
|
|
|
# 最新的日志一定还在当前文件里;总量有界(不会无限长)
|
|
|
|
|
with open(self._log_file(), encoding="utf-8") as handle:
|
|
|
|
|
self.assertIn("第 199 行", handle.read())
|
|
|
|
|
total = sum(os.path.getsize(os.path.join(self.logs, name))
|
|
|
|
|
for name in files)
|
|
|
|
|
self.assertLess(total, 1024 * (1 + 2) * 4)
|
|
|
|
|
|
|
|
|
|
def test_unusable_log_dir_falls_back_to_memory_only(self):
|
|
|
|
|
# 目标是个文件(不是目录):建不出来 → 只留内存,不能抛
|
|
|
|
|
blocker = self.path("not-a-dir")
|
|
|
|
|
write_file(blocker, b"x")
|
|
|
|
|
diagnostics.configure(blocker)
|
|
|
|
|
self.assertEqual(diagnostics.log_path(), "")
|
|
|
|
|
diagnostics.log("还能记")
|
|
|
|
|
self.assertIn("还能记", diagnostics.recent()[-1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SearchTest(unittest.TestCase):
|
|
|
|
|
"""侧栏搜索的模糊匹配(纯标准库)。"""
|
|
|
|
|
|
|
|
|
|
def test_empty_query_matches_everything(self):
|
|
|
|
|
self.assertEqual(search.score("", "任意"), 0.0)
|
|
|
|
|
self.assertTrue(search.matches(" ", "任意"))
|
|
|
|
|
|
|
|
|
|
def test_contains_ignores_case(self):
|
|
|
|
|
self.assertTrue(search.matches("svfs", "SVFS 桥调试台", "com.svfs.demo"))
|
|
|
|
|
self.assertTrue(search.matches("SVFS", "svfs demo"))
|
|
|
|
|
self.assertTrue(search.matches("桥", "SVFS 桥调试台", "com.svfs.demo"))
|
|
|
|
|
|
|
|
|
|
def test_contains_beats_acronym(self):
|
|
|
|
|
# "clock" 包含匹配;"clk" 只能缩写匹配 —— 包含分应更高
|
|
|
|
|
contains = search.score("clo", "com.example.clock")
|
|
|
|
|
acronym = search.score("clo", "c-l-o")
|
|
|
|
|
self.assertIsNotNone(contains)
|
|
|
|
|
self.assertIsNotNone(acronym)
|
|
|
|
|
self.assertGreater(contains, acronym)
|
|
|
|
|
|
|
|
|
|
def test_acronym_matches_subsequence(self):
|
|
|
|
|
self.assertTrue(search.matches("csd", "SVFS 桥调试台", "com.svfs.demo"))
|
|
|
|
|
self.assertTrue(search.matches("cec", "clock", "com.example.clock"))
|
|
|
|
|
|
|
|
|
|
def test_position_matters(self):
|
|
|
|
|
early = search.score("ab", "ab-xyz")
|
|
|
|
|
late = search.score("ab", "xyz-abcdef")
|
|
|
|
|
self.assertGreater(early, late)
|
|
|
|
|
|
|
|
|
|
def test_no_match_returns_none(self):
|
|
|
|
|
self.assertIsNone(search.score("zzz", "SVFS 桥调试台", "com.svfs.demo"))
|
|
|
|
|
# 字符顺序不对 → 不算缩写匹配
|
|
|
|
|
self.assertIsNone(search.score("dcs", "SVFS", "com.svfs.demo"))
|
|
|
|
|
|
|
|
|
|
def test_matches_id_or_name(self):
|
|
|
|
|
# 显示名不匹配但 id 匹配
|
|
|
|
|
self.assertTrue(search.matches("demo", "调试台", "com.svfs.demo"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|