Files
svfs/tests/test_studio_ui.py
T
chakcy f62704ca0e feat: 插件化桌面客户端 svfs-client(含凭据/会话/网络/日志的脚手架补齐)
一个插件 = 一个完整的 .svfs 包:客户端把插件前端经 svfs://<插件id>/ 直接加载
进 WebView(不落盘、不起端口、不解包),每个插件一个独立 origin(浏览器按
origin 隔离 localStorage)与独立键值存储;快照即版本——升级 = 派生快照的增量
打包,回滚 = 把注册表指针切回旧快照,包文件一个字节不用动。svfs_client 复用
svfs_studio 的主题与基础部件,故两个包一并入库。

按评估结论补齐的 P0 三块:

* 会话:桥在"宿主注入的 token 被 401 拒绝"时通知壳层,提示一次并给出一键
  「重新登录…」;服务器状态灯把「在线但登录失效」(黄)与「离线」(红)分开,
  不再把凭据失效误报成网络不可达;
* 凭据:新增 credentials.py——token 不再明文进 settings.json。Windows 走 DPAPI
  (密文只有当前用户在本机解得开),其它平台走系统凭据库(可选依赖 keyring),
  都没有才明文兜底并在「设置 → 登录凭据」如实提示;令牌与签发它的 base_url
  绑定(换服务器不会把旧令牌发出去);老字段 auth_token 启动时自动迁移;
* 强登录链路保持:启动动画 → 静默校验 → 登录窗 → 主窗口,不可达时离线宽限。

P1/P2:net 超时与重试(只对幂等方法,避免重发 POST 变两单)可配、代理与
自签 CA 可配(loopback 永远直连);诊断日志滚动落盘(1MB×3,可关)——崩溃后
还能回看现场;客户端自更新检查(静态 <base_url>/client/manifest.json,缺失
即静默);插件清单可选 net_hosts 白名单;notify 改走系统托盘(无托盘降级状态栏);
设置界面新增网络 / 登录凭据 / 诊断日志三块。

examples/:示例服务端(FastAPI,含登录、上传即 publish、客户端版本接口)、
Vue 3 插件模板(带浏览器版宿主模拟器)、svfs-plugins-root/ 为模板配套的演示
插件实体。测试:tests/test_client_*.py(纯层 + Qt 层 + 一次真实 WebEngine 冒烟)、
tests/test_studio_*.py、tests/test_plugin_server.py。
2026-09-16 17:15:48 +08:00

1348 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SVFS Studio 的界面测试(offscreen 渲染,不需要显示器)。
界面好不好看没法断言,但这些都是可以钉死的:
* 七个标签页都能渲染出来,没有 0 尺寸/被裁掉的文本;
* 主题确实是暗色(采样渲染结果的像素算亮度);
* 预览显示的内容与 ``SQLiteVFS.read_file()`` 读出来的字节一致;
* 旧格式包会给出横幅,而 SQL 控制台在旧格式包上照样能用;
* SQL 控制台拒绝写语句;搜索能过滤树;校验/签名能跑通。
没装 PySide6 时整个模块跳过(GUI 是可选 extra,这不影响库本身)。
"""
import os
import sys
import time
import unittest
import helpers
from svfs_studio.format import short_hash
# 必须在创建 QApplication 之前设定,否则会去找真实显示设备
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
try:
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication, QLabel, QWidget
from svfs_studio.app import create_app
from svfs_studio.sample import build_demo_folder
from svfs_studio.widgets.cards import ElidedLabel
from svfs_studio.widgets.shell import MainWindow
HAVE_QT = True
except ImportError as exc: # pragma: no cover - 取决于环境
HAVE_QT = False
IMPORT_ERROR = str(exc)
def pump(app, predicate=None, timeout=15.0, interval=0.02, settle=0.08):
"""转事件循环。
* 给了 ``predicate``:一直转到它成立(或超时),返回它是否成立;
* 没给:只把事件循环转 ``settle`` 秒,让布局/定时器/排队信号落地。
(早期版本这里会白等满 timeout,七个标签页就是七个 15 秒。)
"""
if predicate is None:
deadline = time.monotonic() + settle
while time.monotonic() < deadline:
app.processEvents()
time.sleep(interval / 4)
app.processEvents()
return True
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(interval)
app.processEvents()
return bool(predicate())
def walk(widget):
yield widget
for child in widget.findChildren(QWidget):
yield child
def luminance(color):
return 0.2126 * color.red() + 0.7152 * color.green() + 0.0722 * color.blue()
@unittest.skipUnless(HAVE_QT, f"需要 PySide6:{'未安装' if not HAVE_QT else ''}")
class StudioUITestCase(helpers.VFSTestCase):
"""所有界面测试的公共脚手架:一个共享的 QApplication + 每个用例一个窗口。"""
@classmethod
def setUpClass(cls):
# 把 QSettings 关进临时目录:测试不该读用户真实的窗口几何/最近文件,
# 否则"上次把窗口拖得很窄"会让某个用例莫名其妙地失败,也不该往注册表里写东西。
import tempfile
settings_dir = tempfile.mkdtemp(prefix="svfs_studio_settings_")
QSettings.setDefaultFormat(QSettings.Format.IniFormat)
QSettings.setPath(
QSettings.Format.IniFormat, QSettings.Scope.UserScope, settings_dir
)
cls.app = QApplication.instance() or create_app([sys.argv[0]])
cls.app.setQuitOnLastWindowClosed(False)
def setUp(self):
super().setUp()
self.window = MainWindow()
self.window.resize(1400, 900)
self.window.show()
pump(self.app, interval=0.005)
self.progress_events = []
self.window.client.progress.connect(
lambda job, done, total, label: self.progress_events.append((job, done, total))
)
def tearDown(self):
try:
self.window.close_package(quiet=True)
self.window._thread.quit()
self.window._thread.wait(5000)
self.window.close()
finally:
pump(self.app, interval=0.005)
super().tearDown()
# ------------------------------------------------------------------
def demo_package(self, target=None):
"""打一个演示包(与界面里「打个示例包看看」同一份数据)。"""
target = target or self.path("demo.svfs")
source = self.path("src_tree")
os.makedirs(source, exist_ok=True)
build_demo_folder(source)
helpers.pack(source, target, compress=True)
return target, source
def load(self, path):
"""通过真实路径(后台线程 + 信号)打开一个包,并等到界面拿到数据。"""
self.window.open_package(path)
ok = pump(self.app, lambda: self.window.payload is not None, timeout=20)
self.assertTrue(ok, "打开包超时")
return self.window.payload
def run_op(self, op, payload=None, timeout=30):
"""在真实的事件循环里跑一个后台操作,返回结果。
先等界面空闲再提交——独占车道一次只跑一个任务,界面上刚触发的"刷新"之类
还没结束时提交会被明确拒绝(这是刻意设计的,不是 bug)。
"""
pump(self.app, lambda: not self.window.client.busy, timeout=20)
box = {}
def done(result):
box["result"] = result
def fail(title, detail):
box["error"] = f"{title}:{detail}"
self.window.client.submit(op, payload or {}, on_done=done, on_fail=fail, label=op)
pump(self.app, lambda: "result" in box or "error" in box, timeout=timeout)
self.assertNotIn("error", box, box.get("error"))
return box["result"]
def assert_rendered_cleanly(self, label):
"""渲染自查:没有 0 尺寸部件、没有该省略却没省略的文本、整体是暗色。"""
image = self.window.grab().toImage()
self.assertFalse(image.isNull(), f"{label}: 渲染结果为空")
# 背景必须是暗的(左上角是侧栏,右下角是内容区边缘)
for name, (x, y) in (
("左上", (4, 4)),
("右下", (image.width() - 4, image.height() - 4)),
):
value = luminance(image.pixelColor(x, y))
self.assertLess(value, 110, f"{label}: {name}像素亮度 {value:.0f},暗色主题没生效")
broken = [
widget
for widget in walk(self.window)
if widget.isVisible()
and (widget.width() <= 0 or widget.height() <= 0)
# 空的表头/滚动视口在 Qt 里本来就是 0 尺寸
and not widget.inherits("QHeaderView")
and widget.objectName() != "qt_scrollarea_viewport"
]
self.assertEqual(
[(type(w).__name__, w.objectName()) for w in broken], [], f"{label}: 有部件被压成 0 尺寸"
)
clipped = []
for widget in walk(self.window):
if not isinstance(widget, QLabel) or isinstance(widget, ElidedLabel):
continue
if not widget.isVisible() or not widget.text() or widget.wordWrap():
continue
if widget.sizeHint().width() > widget.width() + 2:
clipped.append(widget.text()[:30])
self.assertEqual(clipped, [], f"{label}: 这些文本被裁掉了")
class RenderTest(StudioUITestCase):
def test_recent_list_remembers_opened_packages(self):
target, _source = self.demo_package()
self.load(target)
self.assertEqual(self.window._recent_paths()[:1], [os.path.abspath(target)])
# 重复打开不该在列表里出现两次
self.load(target)
self.assertEqual(self.window._recent_paths().count(os.path.abspath(target)), 1)
self.window._fill_recent_menu()
labels = [
action.text() for action in self.window.recent_menu.actions() if action.text()
]
self.assertTrue(any(os.path.basename(target) in text for text in labels), labels)
def test_all_tabs_render_cleanly(self):
target, _source = self.demo_package()
self.load(target)
tabs = self.window.tabs
self.assertEqual(tabs.count(), 7)
for index in range(tabs.count()):
tabs.setCurrentIndex(index)
pump(self.app, interval=0.02)
self.assert_rendered_cleanly(tabs.tabText(index))
def test_empty_state_renders_cleanly(self):
self.assert_rendered_cleanly("空状态页")
def test_overview_numbers_match_library(self):
target, _source = self.demo_package()
payload = self.load(target)
overview = self.window._views["overview"]
summary = payload["summary"]
self.assertIn(str(summary["total_files"]), overview.card_files._value.text())
self.assertIn("份", overview.card_unique._value.text())
# 演示目录里 assets/vendor.js 与 admin/vendor.js 内容相同 → 必然有去重收益
self.assertGreater(summary["dedup_saved"], 0)
self.assertGreater(summary["compression_saved"], 0)
segments = overview.savings._segments
self.assertEqual(len(segments), 3)
total = sum(value for _label, value, _color in segments)
self.assertEqual(total, summary["logical_size"], "堆叠条三段之和必须等于逻辑大小")
def test_storage_tab_lists_every_blob(self):
target, _source = self.demo_package()
payload = self.load(target)
self.window.goto_tab("storage")
pump(self.app, interval=0.02)
storage = self.window._views["storage"]
self.assertEqual(storage.table.table.rowCount(), len(payload["blobs"]))
self.assertEqual(len(payload["duplicates"]), 1)
self.assertEqual(payload["duplicates"][0]["refcount"], 2)
class BrowserTest(StudioUITestCase):
def test_preview_content_matches_library(self):
target, _source = self.demo_package()
payload = self.load(target)
browser = self.window._views["browser"]
self.window.goto_tab("browser")
path = "assets/vendor.js"
browser.select_path(path)
pump(self.app, lambda: bool(browser.text_view.toPlainText()), timeout=15)
self.assertTrue(pump(self.app, lambda: "解码" in browser.preview_note.text(), timeout=15))
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
expected = vfs.read_file(path)
finally:
vfs.close()
shown = browser.text_view.toPlainText()
self.assertTrue(expected.startswith(shown.encode("utf-8")) or shown.startswith("export const"))
self.assertEqual(shown, expected.decode("utf-8")[: len(shown)])
self.assertIn(path, browser.path_label.text())
def test_detail_card_lists_duplicate_paths(self):
target, _source = self.demo_package()
self.load(target)
browser = self.window._views["browser"]
# 必须切到这一页:标签页没被选中的话整棵子树 isVisible() 都是 False
self.window.goto_tab("browser")
browser.select_path("assets/vendor.js")
self.assertTrue(
pump(self.app, lambda: browser.other_card.isVisible(), timeout=15),
"同内容的其它路径没有显示出来",
)
texts = [
browser.other_list.itemAt(index).widget().text()
for index in range(browser.other_list.count())
if browser.other_list.itemAt(index).widget() is not None
]
self.assertIn("admin/vendor.js", texts)
def test_hex_view_renders_bytes(self):
target, _source = self.demo_package()
self.load(target)
browser = self.window._views["browser"]
browser.select_path("index.html")
pump(self.app, lambda: bool(browser.text_view.toPlainText()), timeout=15)
browser.mode_box.setCurrentIndex(1) # 十六进制
pump(self.app, lambda: bool(browser.hex_view.toPlainText()), timeout=15)
first = browser.hex_view.toPlainText().splitlines()[0]
self.assertTrue(first.startswith("00000000"), first)
self.assertIn("|", first)
def test_search_filters_the_tree(self):
target, _source = self.demo_package()
self.load(target)
browser = self.window._views["browser"]
before = browser.proxy.rowCount()
browser.search.setText("vendor")
pump(self.app, interval=0.02)
self.assertLess(browser.proxy.rowCount(), before)
self.assertGreater(browser.proxy.rowCount(), 0)
browser.search.setText("")
pump(self.app, interval=0.02)
self.assertEqual(browser.proxy.rowCount(), before)
class SnapshotViewTest(StudioUITestCase):
def two_snapshot_package(self):
"""造一个有两版历史的包:v2 里改一个文件、删一个文件、加一个文件。"""
from sqlite_vfs.core import SQLiteVFS
source = self.path("tree")
helpers.make_tree(
source, {"keep.txt": b"keep", "edit.txt": b"old", "gone.txt": b"gone"}
)
db = self.path("snap.svfs")
helpers.pack(source, db, compress=True)
vfs = SQLiteVFS(db, compress=True)
try:
parent = vfs._active_snapshot()
vfs.create_snapshot("v2", parent_id=parent, copy_entries=True)
helpers.write_file(os.path.join(source, "edit.txt"), b"new-content")
helpers.write_file(os.path.join(source, "fresh.txt"), b"fresh")
os.remove(os.path.join(source, "gone.txt"))
with helpers.quiet():
from sqlite_vfs.folder_packer import FolderPacker
FolderPacker(vfs, fs_name="snap").pack_folder(
source, incremental=True, rehash=True
)
ids = [row["id"] for row in vfs.list_snapshots()]
finally:
vfs.close()
return db, ids
def test_snapshot_list_and_diff(self):
db, ids = self.two_snapshot_package()
self.load(db)
view = self.window._views["snapshots"]
self.window.goto_tab("snapshots")
pump(self.app, interval=0.02)
self.assertEqual(view.tree.topLevelItemCount(), len(ids))
# 默认比较"最新两版"
index_a = view.combo_a.findData(ids[0])
index_b = view.combo_b.findData(ids[1])
self.assertGreaterEqual(index_a, 0)
self.assertGreaterEqual(index_b, 0)
view.combo_a.setCurrentIndex(index_a)
view.combo_b.setCurrentIndex(index_b)
self.assertTrue(
pump(self.app, lambda: bool(view.diff_tree.topLevelItemCount()), timeout=20),
"差异树没有渲染出来",
)
counts = view._diff["counts"]
self.assertEqual(counts["added"], 1) # fresh.txt
self.assertEqual(counts["removed"], 1) # gone.txt
self.assertEqual(counts["content"], 1) # edit.txt
self.assertEqual(view._diff["unchanged"], 1) # keep.txt
self.assertIn("3 处变化", view.diff_badge.text())
def test_selecting_snapshot_switches_the_tree(self):
db, ids = self.two_snapshot_package()
self.load(db)
self.assertEqual(self.window.payload["snapshot_id"], ids[1])
self.window.select_snapshot(ids[0])
self.assertTrue(
pump(self.app, lambda: self.window.payload["snapshot_id"] == ids[0], timeout=20)
)
browser = self.window._views["browser"]
# v1 里 gone.txt 还在,v2 里没有
self.assertTrue(browser.model.find("gone.txt").isValid())
self.assertFalse(browser.model.find("fresh.txt").isValid())
class IntegrityViewTest(StudioUITestCase):
def test_verify_and_sign_roundtrip(self):
target, _source = self.demo_package()
self.load(target)
view = self.window._views["integrity"]
self.window.goto_tab("integrity")
result = self.run_op("verify", {"check_content": True})
self.assertTrue(result["report"]["ok"], result["report"]["errors"])
view._on_verify(result)
pump(self.app, interval=0.02)
self.assertEqual(view.result_badge.text(), "通过")
self.assertIn("逐文件重算哈希 6", view.result_detail.text())
# 签名 → 验签
view.key_input.setText("测试密钥")
view.key_id_input.setText("ci")
view._sign()
self.assertTrue(
pump(self.app, lambda: bool(view.sign_note.text()), timeout=20)
)
self.assertIn("已签名", view.sign_note.text())
verify = self.run_op(
"verify_signature",
{"key": "测试密钥", "key_id": "ci"},
)
self.assertTrue(verify["report"]["ok"], verify["report"]["errors"])
wrong = self.run_op("verify_signature", {"key": "别的密钥"})
self.assertFalse(wrong["report"]["ok"])
def test_signature_shows_up_in_table_after_refresh(self):
target, _source = self.demo_package()
self.load(target)
self.run_op("sign", {"key": "k", "key_id": "id-1"})
self.window.refresh()
self.assertTrue(
pump(self.app, lambda: bool(self.window.payload.get("signatures")), timeout=20)
)
view = self.window._views["integrity"]
view._fill_signatures(self.window.payload)
self.assertEqual(view.signature_table.table.rowCount(), 1)
self.assertEqual(view.signature_table.table.item(0, 1).text(), "id-1")
class InternalsViewTest(StudioUITestCase):
def test_internals_tables_populated(self):
target, _source = self.demo_package()
self.load(target)
self.window.goto_tab("internals")
info = self.run_op("internals", {})
view = self.window._views["internals"]
view._on_internals(info)
pump(self.app, interval=0.02)
self.assertTrue(info["header"]["valid"])
self.assertEqual(info["header"]["page_size"], 4096)
self.assertEqual(info["occupancy_mode"], "dbstat")
self.assertIn("SQLite format 3", view.header_badge.text())
self.assertGreater(view.table.table.rowCount(), 3)
names = {
view.table.table.item(row, 1).text()
for row in range(view.table.table.rowCount())
}
self.assertTrue({"files", "blobs", "snapshots"} <= names)
def test_sql_console_runs_select_and_rejects_write(self):
target, _source = self.demo_package()
self.load(target)
view = self.window._views["internals"]
result = self.run_op("run_sql", {"sql": "SELECT count(*) AS n FROM files"})
view._on_result(result)
pump(self.app, interval=0.02)
self.assertEqual(result["columns"], ["n"])
self.assertEqual(view.result_table.rowCount(), 1)
self.assertIn("返回 1 行", view.sql_note.text())
view.sql_edit.setPlainText("DELETE FROM files")
view._run_sql()
pump(self.app, lambda: "只读" in view.sql_note.text(), timeout=10)
self.assertIn("只读控制台只允许", view.sql_note.text())
# 白名单挡不住的话,mode=ro 连接也会挡——两层都要在
view.sql_edit.setPlainText("SELECT * FROM files")
view._run_sql()
pump(self.app, lambda: "返回" in view.sql_note.text(), timeout=10)
class LegacyPackageTest(StudioUITestCase):
def legacy_path(self):
candidate = os.path.join(
os.path.dirname(SRC_DIR), "appcation.svfs"
)
if not os.path.exists(candidate):
self.skipTest("仓库里没有 appcation.svfs")
return candidate
def test_legacy_package_shows_banner_but_sql_still_works(self):
path = self.legacy_path()
payload = self.load(path)
self.assertEqual(payload.get("legacy"), 1)
self.assertTrue(self.window._banner_wrap.isVisible())
self.assertIn("schema v1", self.window.banner._title.text())
self.assertIn("去迁移", self.window.banner._button.text())
# 旧格式下 SQL 与内部结构仍然可用
result = self.run_op("run_sql", {"sql": "SELECT count(*) AS n FROM files"})
self.assertEqual(result["rows"][0][0], 39)
info = self.run_op("internals", {})
self.assertTrue(info["header"]["valid"])
# 而需要 v3 结构的操作会明确报错,而不是静默返回空
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking("entries", {})
self.assertIn("旧格式", str(caught.exception))
def test_migrate_from_banner_entry(self):
path = self.legacy_path()
self.load(path)
self.window._open_migrate_for_current()
pump(self.app, interval=0.02)
ops = self.window._views["ops"]
self.assertEqual(ops.stack.currentIndex(), 2)
self.assertEqual(ops.migrate_source.text(), path)
self.assertIn("v1", ops.migrate_detect.text())
output = self.path("migrated.svfs")
result = self.run_op(
"migrate", {"path": path, "backup": False, "output": output}, timeout=60
)
self.assertEqual(result["from_version"], 1)
self.assertEqual(result["to_version"], 3)
self.assertTrue(os.path.exists(output))
ops._on_migrate_done(result)
# 迁移完会自动打开新包;要等到界面真的切换到新包为止
# (原来的旧格式 payload 还在,所以不能用 "payload 不是 None" 当判据)
self.assertTrue(
pump(
self.app,
lambda: self.window.payload.get("legacy") is None
and self.window.payload.get("path") == output,
timeout=20,
),
f"没等到界面切到迁移后的包:{self.window.payload.get('path')}",
)
self.assertEqual(self.window.payload["stats"]["schema_version"], 3)
class OpsViewTest(StudioUITestCase):
def test_pack_through_the_ui_reports_progress(self):
source = self.path("tree")
helpers.make_tree(source, {"a.txt": b"a" * 5000, "sub/b.txt": b"b" * 5000})
target = self.path("packed.svfs")
ops = self.window._views["ops"]
ops.pack_source.setText(source)
ops.pack_target.setText(target)
ops.pack_compress.setChecked(True)
ops._run_pack()
self.assertTrue(pump(self.app, lambda: not self.window.client.busy, timeout=30))
self.assertTrue(os.path.exists(target))
jobs = {job for job, _done, _total in self.progress_events}
self.assertIn("打包", jobs)
self.assertTrue(
any(total and done == total for _job, done, total in self.progress_events),
f"进度没有走完:{self.progress_events[-3:]}",
)
# 打完自动打开,界面上应该看到这两个文件
self.assertTrue(pump(self.app, lambda: self.window.payload is not None, timeout=20))
browser = self.window._views["browser"]
self.assertTrue(browser.model.find("a.txt").isValid())
self.assertTrue(browser.model.find("sub/b.txt").isValid())
def test_unpack_then_compare_reports_identical(self):
target, source = self.demo_package()
self.load(target)
output = self.path("exported")
result = self.run_op("unpack", {"output": output}, timeout=30)
self.assertEqual(result["kind"], "filesystem")
self.assertGreater(result["count"], 0)
payload = self.run_op("compare_local", {"local_root": source}, timeout=30)
self.assertTrue(payload["result"]["ok"], payload["result"]["counts"])
def test_compare_detects_a_changed_file(self):
target, source = self.demo_package()
self.load(target)
helpers.write_file(os.path.join(source, "app.js"), b"console.log('changed');\n")
payload = self.run_op("compare_local", {"local_root": source}, timeout=30)
counts = payload["result"]["counts"]
self.assertEqual(counts["content"], 1)
self.assertTrue(counts["only_pack"] + counts["identical"] >= 4)
def test_gc_and_recount_via_ops(self):
target, _source = self.demo_package()
self.load(target)
# 造一份没人引用的内容
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target)
try:
vfs.conn.execute(
"INSERT INTO blobs (hash, size, stored_size, content, freq_blob, compress_algo)"
" VALUES ('deadbeef', 100, 100, X'00', NULL, 'none')"
)
vfs.conn.commit()
finally:
vfs.close()
self.window.refresh()
self.assertTrue(pump(self.app, lambda: self.window.payload["orphans"]["count"] == 1, timeout=20))
result = self.run_op("gc", {})
self.assertEqual(result["removed"], 1)
self.assertEqual(result["after"]["count"], 0)
recount = self.run_op("recount", {})
self.assertEqual(recount["recount"]["files"], 6)
def test_publish_and_fetch_roundtrip(self):
target, source = self.demo_package()
self.load(target)
out_dir = self.path("public")
published = self.run_op(
"publish", {"out_dir": out_dir, "shard": 2}, timeout=30
)
self.assertGreater(published["objects"], 0)
self.assertTrue(os.path.exists(published["manifest"]))
# 清单必须覆盖当前快照里的每一个条目(文件 + 目录)
self.assertEqual(len(published["entries"]), len(self.window.payload["entries"]))
fetched = self.run_op(
"fetch", {"manifest": published["manifest"], "out_dir": self.path("back")}, timeout=60
)
self.assertEqual(fetched["files"], 6)
self.assertEqual(fetched["verified"], published["objects"])
def test_fetch_result_matches_source_bytes(self):
target, source = self.demo_package()
self.load(target)
out_dir = self.path("public")
published = self.run_op("publish", {"out_dir": out_dir}, timeout=30)
back = self.path("back")
self.run_op("fetch", {"manifest": published["manifest"], "out_dir": back}, timeout=60)
helpers.assert_tree_equal(self, helpers.read_tree(source), helpers.read_tree(back))
class OddFileTest(StudioUITestCase):
"""打开"不是 SVFS 包"的文件:既不能崩,也不能假装打开成功。"""
def test_plain_sqlite_db_degrades_to_internals(self):
import sqlite3
path = self.path("plain.db")
connection = sqlite3.connect(path)
connection.execute("CREATE TABLE t (a)")
connection.execute("INSERT INTO t VALUES (1)")
connection.commit()
connection.close()
payload = self.load(path)
self.assertIsNone(payload.get("legacy"), "普通 SQLite 库不该被当成旧格式 SVFS")
self.assertTrue(self.window._banner_wrap.isVisible())
self.assertIn("不是", self.window.banner._title.text())
# 内部结构与 SQL 依然可用——正好用来看清"这到底是什么文件"
result = self.run_op("run_sql", {"sql": "SELECT name FROM sqlite_master"})
self.assertEqual(result["rows"], [("t",)])
info = self.run_op("internals", {})
self.assertTrue(info["header"]["valid"])
def test_non_database_file_reports_clearly(self):
path = self.path("notes.txt")
helpers.write_file(path, b"hello, definitely not a database\n" * 10)
self.window.open_package(path)
self.assertTrue(
pump(self.app, lambda: self.window._banner_wrap.isVisible(), timeout=20)
)
self.assertIn("无法打开", self.window.banner._title.text())
self.assertIn("也不是能读的 SQLite 数据库", self.window.banner._detail.text())
# 失败之后不能留下上一个包的影子数据
self.assertIsNone(self.window.payload)
self.assertEqual(self.window.pages.currentIndex(), 0)
def test_failed_open_clears_previous_package(self):
target, _source = self.demo_package()
self.load(target)
self.assertIsNotNone(self.window.payload)
bad = self.path("bad.bin")
helpers.write_file(bad, b"\x00\x01\x02 not a db")
self.window.open_package(bad)
self.assertTrue(pump(self.app, lambda: self.window.payload is None, timeout=20))
overview = self.window._views["overview"]
self.assertEqual(overview.info._row, 0, "概览页还留着上一个包的信息")
class NewPackageTest(StudioUITestCase):
"""新建空包,再往里增量打文件——这是"从零开始用这个格式"的最短路径。"""
def test_new_package_is_a_valid_empty_v3_package(self):
target = self.path("fresh.svfs")
self.assertFalse(os.path.exists(target))
payload = self.run_op("new_package", {"path": target, "name": "从零开始"})
self.assertTrue(payload.get("created"))
self.assertEqual(payload["stats"]["name"], "从零开始")
self.assertEqual(payload["stats"]["schema_version"], 3)
self.assertEqual(payload["summary"]["total_files"], 0)
self.assertEqual(payload["entries"], [])
self.assertEqual(payload["blobs"], [])
self.assertEqual(len(payload["snapshots"]), 1)
# 空快照也要有摘要,而且校验要通过(不是"未计算摘要"那种半成品)
self.assertTrue(payload["stats"]["digest"])
report = self.run_op("verify", {})
self.assertTrue(report["report"]["ok"], report["report"]["errors"])
def test_ui_flow_create_then_pack_into_it(self):
target = self.path("collect.svfs")
self.window.new_package(target)
self.assertTrue(
pump(self.app, lambda: self.window.payload is not None, timeout=20)
)
self.assertEqual(self.window.payload["summary"]["total_files"], 0)
self.assertIn("空包已建好", self.window.banner._title.text())
self.assertTrue(self.window._banner_wrap.isVisible())
# 从横幅的入口跳到工具箱:应该预填目标文件并勾好增量
self.window._focus_pack_into(target)
pump(self.app, interval=0.02)
ops = self.window._views["ops"]
self.assertEqual(self.window.tabs.currentIndex(), self.window._tab_index["ops"])
self.assertEqual(ops.stack.currentIndex(), 0)
self.assertEqual(ops.pack_target.text(), target)
self.assertTrue(ops.pack_incremental.isChecked())
# 真打一轮进去:空包 → 有内容的包
source = self.path("drop")
helpers.make_tree(source, {"hello.txt": b"hi", "sub/two.txt": b"two"})
ops.pack_source.setText(source)
ops.pack_name.setText("从零开始")
ops._run_pack()
self.assertTrue(
pump(
self.app,
lambda: self.window.payload is not None
and self.window.payload["summary"]["total_files"] == 2,
timeout=30,
),
f"文件没打进空包:{(self.window.payload or {}).get('summary')}",
)
browser = self.window._views["browser"]
self.window.goto_tab("browser")
self.assertTrue(browser.model.find("hello.txt").isValid())
self.assertTrue(browser.model.find("sub/two.txt").isValid())
self.assertEqual(self.window.payload["stats"]["name"], "从零开始")
# 再打一轮,这次填上快照名:增量 + 快照名 = 新开一版历史(只写 delta)
helpers.write_file(os.path.join(source, "third.txt"), b"three")
ops.pack_snapshot.setText("v2")
ops._run_pack()
self.assertTrue(
pump(
self.app,
lambda: len((self.window.payload or {}).get("snapshots") or []) == 2
and (self.window.payload or {}).get("summary", {}).get("total_files") == 3,
timeout=30,
),
"带快照名的增量打包没有产生第二版历史",
)
# 第一版的快照名沿用建包时的默认名(取自文件名 collect.svfs → "collect");
# 第二版用了显式的快照名 v2。断言"多了一版、且名字对得上"。
names = {row["name"] for row in self.window.payload["snapshots"]}
self.assertEqual(names, {"collect", "v2"})
# 新版是基于旧版复制出来的:旧版内容还在,切换回去能看到
default_id = next(
row["id"] for row in self.window.payload["snapshots"] if row["name"] == "collect"
)
self.window.select_snapshot(default_id)
self.assertTrue(
pump(
self.app,
lambda: (self.window.payload or {}).get("summary", {}).get("total_files") == 2,
timeout=20,
),
"切回第一版应该还是 2 个文件(历史没有被动过)",
)
# 回到最新版,第三个文件在那里
self.window.select_snapshot(
max(row["id"] for row in self.window.payload["snapshots"])
)
self.assertTrue(
pump(
self.app,
lambda: (self.window.payload or {}).get("summary", {}).get("total_files") == 3,
timeout=20,
),
"最新版应该有 3 个文件",
)
def test_new_package_refuses_to_overwrite_content(self):
target, _source = self.demo_package()
before = os.path.getsize(target)
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking("new_package", {"path": target})
self.assertIn("已经存在", str(caught.exception))
self.assertEqual(os.path.getsize(target), before, "已有包被改动了")
def test_rename_package(self):
target, _source = self.demo_package()
self.load(target)
payload = self.run_op("rename_package", {"name": "改名后的包"})
self.assertEqual(payload["stats"]["name"], "改名后的包")
# 只动名字,条目与内容不能受影响
self.assertEqual(payload["summary"]["total_files"], 6)
self.assertEqual(len(payload["snapshots"]), 1)
self.assertEqual(payload["summary"]["stored_bytes"], self.window.payload["summary"]["stored_bytes"])
ops = self.window._views["ops"]
ops._on_rename_done(payload)
self.assertIn("改名后的包", self.window.payload["stats"]["name"])
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking("rename_package", {"name": " "})
self.assertIn("不能为空", str(caught.exception))
def test_pack_full_into_existing_package_refuses_new_snapshot(self):
"""全量打包会清空整库,所以"在新快照里累加"这种组合必须被拒绝而不是静默清库。"""
target, source = self.demo_package()
self.load(target)
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking(
"pack",
{
"source": source,
"target": target,
"incremental": False,
"snapshot_name": "v2",
},
)
self.assertIn("增量打包", str(caught.exception))
class EditTest(StudioUITestCase):
"""包内编辑:建目录 / 加文件 / 改名 / 删除。
这些是写路径,断言两条:动作生效了,而且没有偷偷改到别的东西(引用计数、快照数)。
"""
def browser(self, target):
self.load(target)
self.window.goto_tab("browser")
pump(self.app, interval=0.02)
return self.window._views["browser"]
def wait_entries(self, count):
"""等到当前快照的条目数变成 count。"""
return pump(
self.app,
lambda: (self.window.payload or {}).get("summary", {}).get("total_entries") == count,
timeout=20,
)
def test_edit_buttons_follow_package_state(self):
browser = self.window._views["browser"]
self.assertFalse(browser.add_button.isEnabled(), "没打开包时不该能编辑")
target, _source = self.demo_package()
browser = self.browser(target)
self.assertTrue(browser.add_button.isEnabled())
self.assertTrue(browser.mkdir_button.isEnabled())
# 没选中任何条目时,改名/删除不可用
self.assertFalse(browser.rename_button.isEnabled())
self.assertFalse(browser.delete_button.isEnabled())
def test_edit_keeps_package_verifiable(self):
"""改完库要能立刻校验通过:否则用户会以为库坏了。
靠的是编辑动作结束时调一次 recount()(刷新快照统计 + 摘要),
与打包结束时做的是同一件事。
"""
target, _source = self.demo_package()
browser = self.browser(target)
self.assertTrue(self.run_op("verify", {"check_content": True})["report"]["ok"])
local = self.path("extra.txt")
helpers.write_file(local, "新增内容\n".encode("utf-8"))
before = self.window.payload["summary"]["total_entries"]
browser.add_local([local], dest="", replace=False)
self.assertTrue(self.wait_entries(before + 1))
report = self.run_op("verify", {"check_content": True})["report"]
self.assertTrue(report["ok"], report["errors"])
self.assertEqual(report["files_checked"], 7)
# 快照行上的统计也同步了(不是只更新了界面)
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertEqual(vfs.get_stats()["total_files"], 7)
finally:
vfs.close()
def test_edit_invalidates_existing_signature_and_says_so(self):
target, _source = self.demo_package()
browser = self.browser(target)
self.run_op("sign", {"key": "k1", "key_id": "before-edit"})
self.assertTrue(self.run_op("verify_signature", {"key": "k1"})["report"]["ok"])
local = self.path("after.txt")
helpers.write_file(local, b"after")
before = self.window.payload["summary"]["total_entries"]
browser.add_local([local], dest="", replace=False)
self.assertTrue(self.wait_entries(before + 1))
# 摘要变了,旧签名必须验不过——这是设计如此,界面上也会提示
self.assertFalse(
self.run_op("verify_signature", {"key": "k1"})["report"]["ok"],
"改动之后旧签名不该还能验过",
)
browser._after_edit(self.window.payload, "已加入 after.txt")
self.assertIn("签名已失效", self.window.status_label.full_text())
def test_create_add_rename_delete_roundtrip(self):
target, _source = self.demo_package()
browser = self.browser(target)
before = self.window.payload["summary"]["total_entries"] # 10
browser.create_directory("notes", parent="")
self.assertTrue(self.wait_entries(before + 1), "建目录没生效")
self.assertTrue(browser.model.find("notes").isValid())
local = self.path("note.txt")
helpers.write_file(local, "编辑器加进来的内容\n".encode("utf-8"))
browser.add_local([local], dest="notes", replace=False)
self.assertTrue(self.wait_entries(before + 2), "加文件没生效")
self.assertTrue(browser.model.find("notes/note.txt").isValid())
browser.rename_to("notes/note.txt", "renamed.txt")
# 改名不改变条目数,所以要等"新路径出现"而不是等计数
self.assertTrue(
pump(
self.app,
lambda: browser.model.find("notes/renamed.txt").isValid()
and not browser.model.find("notes/note.txt").isValid(),
timeout=20,
),
"改名没生效",
)
browser.delete_path("notes/renamed.txt")
self.assertTrue(self.wait_entries(before + 1), "删除没生效")
self.assertFalse(browser.model.find("notes/renamed.txt").isValid())
# 建目录再接上:删除目录会把子项一起带走
browser.delete_path("notes")
self.assertTrue(self.wait_entries(before), "删目录没生效")
self.assertFalse(browser.model.find("notes").isValid())
def test_added_file_content_matches_disk(self):
target, _source = self.demo_package()
browser = self.browser(target)
local = self.path("payload.bin")
data = bytes(range(256)) * 8
helpers.write_file(local, data)
browser.add_local([local], dest="", replace=False)
self.assertTrue(pump(self.app, lambda: browser.model.find("payload.bin").isValid(), timeout=20))
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertEqual(vfs.read_file("payload.bin"), data)
finally:
vfs.close()
def test_adding_a_folder_brings_the_whole_tree(self):
target, _source = self.demo_package()
browser = self.browser(target)
folder = self.path("drop")
helpers.make_tree(
folder, {"a.txt": b"a", "sub/b.txt": b"b", "sub/deep/c.txt": b"c"}
)
browser.add_local([folder], dest="incoming", replace=False)
self.assertTrue(
pump(
self.app,
lambda: browser.model.find("incoming/drop/sub/deep/c.txt").isValid(),
timeout=20,
),
"整棵目录没加进去",
)
paths = {row["path"] for row in self.window.payload["entries"]}
self.assertTrue(
{
"incoming/drop",
"incoming/drop/a.txt",
"incoming/drop/sub/b.txt",
"incoming/drop/sub/deep/c.txt",
}
<= paths
)
def test_name_conflict_is_reported_and_can_be_replaced(self):
target, _source = self.demo_package()
browser = self.browser(target)
local = self.path("app.js")
helpers.write_file(local, "console.log('替换后的内容');\n".encode("utf-8"))
# 不替换:路径撞上包里的 app.js,结果应该是"跳过了"
payload = self.run_op(
"add_paths",
{"local_paths": [local], "dest_dir": "", "replace_existing": False},
)
self.assertEqual(payload["conflicts"], ["app.js"])
self.assertEqual(payload["added_files"], [])
# 冲突检测在 GUI 侧也要能提前算出来(按钮路径用它来决定要不要问)
self.assertEqual(browser._conflicting_paths([local], ""), ["app.js"])
# 替换:内容真的换了,而且条目数不变(还是覆盖那个路径)
before = len(self.window.payload["entries"])
payload = self.run_op(
"add_paths",
{"local_paths": [local], "dest_dir": "", "replace_existing": True},
)
self.assertEqual(payload["added_files"], ["app.js"])
self.assertEqual(len(payload["entries"]), before)
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertIn(b"\xe6\x9b\xbf\xe6\x8d\xa2", vfs.read_file("app.js"))
finally:
vfs.close()
def test_delete_directory_takes_children_and_collects_orphans(self):
target, _source = self.demo_package()
browser = self.browser(target)
before = self.window.payload["summary"]["total_entries"]
browser.delete_path("assets")
self.assertTrue(pump(self.app, lambda: not browser.model.find("assets").isValid(), timeout=20))
# assets 下有 logo.svg 与 vendor.js,连带目录行一共少 3 个条目
self.assertEqual(self.window.payload["summary"]["total_entries"], before - 3)
# vendor.js 的内容还被 admin/vendor.js 引用着,所以那份 blob 必须留着
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
hashes = {row["hash"] for row in vfs.list_blobs()}
self.assertIn(
vfs.get_file_info("admin/vendor.js")["content_hash"],
hashes,
"还有路径引用这份内容,不该被回收",
)
finally:
vfs.close()
self.assertEqual(self.window.payload["orphans"]["count"], 0)
def test_rename_rejects_collision_and_slash(self):
target, _source = self.demo_package()
browser = self.browser(target)
self.assertIsNone(browser.rename_to("app.js", "index.html"), "重名不该放行")
self.assertIsNone(browser.rename_to("app.js", "sub/app.js"), "带斜杠的名字不该放行")
with self.assertRaises(Exception) as caught:
self.run_op("rename_path", {"source": "app.js", "dest": "index.html"})
self.assertIn("已存在", str(caught.exception))
def test_edit_ops_refuse_legacy_package(self):
legacy = os.path.join(os.path.dirname(SRC_DIR), "appcation.svfs")
if not os.path.exists(legacy):
self.skipTest("仓库里没有 appcation.svfs")
self.load(legacy)
for op, payload in (
("mkdir", {"path": "x"}),
("rename_path", {"source": "a", "dest": "b"}),
("remove_paths", {"paths": ["a"]}),
("add_paths", {"local_paths": [__file__], "dest_dir": ""}),
):
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking(op, payload)
self.assertIn("旧格式", str(caught.exception), f"{op} 应该拒绝旧格式包")
def test_mkdir_requires_existing_parent(self):
target, _source = self.demo_package()
self.load(target)
with self.assertRaises(Exception) as caught:
self.window.client.call_blocking("mkdir", {"path": "nope/deep"})
self.assertIn("父目录", str(caught.exception))
class TextEditTest(StudioUITestCase):
"""就地编辑文件内容:进入 → 改 → 保存 → 包内字节真的变了,且库仍可校验。"""
def browser(self, target):
self.load(target)
self.window.goto_tab("browser")
pump(self.app, interval=0.02)
return self.window._views["browser"]
def wait_badge(self, browser, content_hash):
return pump(
self.app,
lambda: browser.hash_badge.text().endswith(short_hash(content_hash, 10)),
timeout=15,
)
def test_edit_roundtrip_updates_package(self):
from sqlite_vfs.core import SQLiteVFS
target, _source = self.demo_package()
browser = self.browser(target)
browser.select_path("app.js")
self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15))
old_badge = browser.hash_badge.text()
browser.edit_button.setChecked(True)
self.assertTrue(
pump(
self.app,
lambda: browser._editing and not browser.text_view.isReadOnly(),
timeout=15,
),
"没进入编辑模式",
)
new_text = browser.text_view.toPlainText() + "\n// 由界面编辑器追加"
browser.text_view.setPlainText(new_text)
self.assertTrue(browser._edit_dirty)
self.assertTrue(browser.save_button.isVisible() and browser.save_button.isEnabled())
browser.save_edit()
self.assertTrue(
pump(self.app, lambda: (self.window.payload or {}).get("saved"), timeout=20),
"保存没有完成",
)
saved = self.window.payload["saved"]
self.assertEqual(saved["path"], "app.js")
# 包内的字节与编辑器里的一致
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertEqual(vfs.read_file("app.js").decode("utf-8"), new_text)
finally:
vfs.close()
# 详情面板的哈希徽章刷新成了新内容哈希
self.assertTrue(self.wait_badge(browser, saved["hash"]), browser.hash_badge.text())
self.assertNotEqual(old_badge, browser.hash_badge.text())
# 库仍然可校验(编辑结束的 recount 刷新了摘要)
report = self.run_op("verify", {"check_content": True})["report"]
self.assertTrue(report["ok"], report["errors"])
# 退出编辑回到只读
browser.edit_button.setChecked(False)
self.assertTrue(pump(self.app, lambda: browser.text_view.isReadOnly(), timeout=10))
def test_edit_gbk_file_keeps_encoding(self):
from sqlite_vfs.core import SQLiteVFS
target, _source = self.demo_package()
browser = self.browser(target)
local = self.path("gbk.txt")
original = "这是 GBK 编码的内容"
helpers.write_file(local, original.encode("gbk"))
payload = self.run_op(
"add_paths", {"local_paths": [local], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
browser.select_path("gbk.txt")
self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15))
browser.edit_button.setChecked(True)
# _editing 在内容装载前就置位了;要等编辑器可写、编码嗅探完成
self.assertTrue(
pump(self.app, lambda: browser._editing and not browser.text_view.isReadOnly(), timeout=15)
)
self.assertEqual(browser._edit_encoding, "gbk", "GBK 文件应按 GBK 编辑")
edited = browser.text_view.toPlainText() + "\n追加一行中文"
browser.text_view.setPlainText(edited)
browser.save_edit()
self.assertTrue(pump(self.app, lambda: (self.window.payload or {}).get("saved"), timeout=20))
vfs = SQLiteVFS(target, readonly=True)
try:
raw = vfs.read_file("gbk.txt")
finally:
vfs.close()
self.assertEqual(raw, edited.encode("gbk"), "保存必须保留 GBK 编码")
self.assertEqual(self.window.payload["saved"]["encoding"], "gbk")
def test_edit_guards(self):
target, _source = self.demo_package()
browser = self.browser(target)
# 目录不可编辑
browser.select_path("docs")
self.assertTrue(pump(self.app, lambda: b"_x" or browser._current == "docs", timeout=10))
self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10))
# 二进制不可编辑(PNG 魔数 + NUL)
binary = self.path("logo.bin")
helpers.write_file(binary, b"\x89PNG\r\n\x1a\n" + b"\x00\x01" * 40)
payload = self.run_op(
"add_paths", {"local_paths": [binary], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
browser.select_path("logo.bin")
self.assertTrue(pump(self.app, lambda: browser._current == "logo.bin", timeout=10))
self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10))
# 超过上限不可编辑
big = self.path("big.txt")
helpers.write_file(big, b"x" * (1024 * 1024 + 100))
payload = self.run_op(
"add_paths", {"local_paths": [big], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
browser.select_path("big.txt")
self.assertTrue(pump(self.app, lambda: browser._current == "big.txt", timeout=10))
self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10))
self.assertIn("上限", browser.edit_button.toolTip())
# 空文件可以编辑(往里写内容是正常需求)
empty = self.path("empty.txt")
helpers.write_file(empty, b"")
payload = self.run_op(
"add_paths", {"local_paths": [empty], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
browser.select_path("empty.txt")
self.assertTrue(pump(self.app, lambda: browser._current == "empty.txt", timeout=10))
self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=10))
def test_save_rejects_unencodable_text(self):
"""latin-1 文件里输入中文:编码失败要报人话,而不是半个坏文件进库。"""
target, _source = self.demo_package()
browser = self.browser(target)
local = self.path("latin.txt")
helpers.write_file(local, "caf\xe9".encode("latin-1"))
payload = self.run_op(
"add_paths", {"local_paths": [local], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
browser.select_path("latin.txt")
self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15))
browser.edit_button.setChecked(True)
self.assertTrue(
pump(self.app, lambda: browser._editing and not browser.text_view.isReadOnly(), timeout=15)
)
self.assertEqual(browser._edit_encoding, "latin-1")
box = {}
self.window.client.submit(
"save_text",
{"path": "latin.txt", "text": "这里换成中文", "encoding": "latin-1"},
on_done=lambda r: box.setdefault("ok", r),
on_fail=lambda t, d: box.setdefault("err", (t, d)),
label="保存",
)
self.assertTrue(
pump(self.app, lambda: "ok" in box or "err" in box, timeout=20),
"保存既没成功也没失败",
)
self.assertIn("err", box)
self.assertIn("编码", box["err"][1])
# 包里的内容保持原样(没有半写状态)
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertEqual(vfs.read_file("latin.txt"), "caf\xe9".encode("latin-1"))
finally:
vfs.close()
def test_oversize_save_refused_by_service(self):
"""服务层兜底:对超过上限的文件做文本保存必须拒绝——编辑器装不下它,
任何"保存"都意味着把没显示的部分截掉。(往小文件里粘贴一大段是合法增长,不拦)"""
target, _source = self.demo_package()
self.load(target)
big = self.path("big.txt")
helpers.write_file(big, b"x" * (1024 * 1024 + 100))
payload = self.run_op(
"add_paths", {"local_paths": [big], "dest_dir": "", "replace_existing": False}
)
self.window.apply_payload(payload)
box = {}
self.window.client.submit(
"save_text",
{"path": "big.txt", "text": "替换", "encoding": "utf-8"},
on_done=lambda r: box.setdefault("ok", r),
on_fail=lambda t, d: box.setdefault("err", (t, d)),
label="保存",
)
self.assertTrue(
pump(self.app, lambda: "ok" in box or "err" in box, timeout=20),
"超限保存既没成功也没失败",
)
self.assertIn("err", box)
self.assertIn("上限", box["err"][1])
# 包里的内容原封不动
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertEqual(len(vfs.read_file("big.txt")), 1024 * 1024 + 100)
finally:
vfs.close()
class CancelTest(StudioUITestCase):
"""中断语义:库里包在进度回调里抛异常,异常会一路抛回服务层。
这是"中断按钮不是装饰"的证据——顺带说明为什么只有带进度回调的任务才给中断按钮。
"""
def test_cancel_interrupts_packing(self):
source = self.path("big")
helpers.make_tree(
source, {f"f{index}.bin": bytes(40000 + index) for index in range(60)}
)
target = self.path("big.svfs")
ticks = []
def on_progress(job, done, total, label):
if job != "打包":
return
ticks.append(done)
if len(ticks) >= 3:
# 在第三个进度点请求中断 → 下一个进度点抛 TaskCancelled
self.window.client.cancel()
self.window.client.progress.connect(on_progress)
box = {}
self.window.client.submit(
"pack",
{"source": source, "target": target, "compress": True},
on_done=lambda result: box.setdefault("result", result),
on_fail=lambda title, detail: box.setdefault("error", (title, detail)),
label="打包",
)
self.assertTrue(
pump(self.app, lambda: "result" in box or "error" in box, timeout=60),
"任务既没成功也没失败",
)
self.assertIn("error", box, f"中断没有生效:{box.get('result')}")
self.assertEqual(box["error"][0], "已中断")
self.assertLess(len(ticks), 60, "进度点比条目数还多,说明循环没被打断")
# 被打断的包是个部分结果:库能打开,但条目数明显不全
from sqlite_vfs.core import SQLiteVFS
vfs = SQLiteVFS(target, readonly=True)
try:
self.assertLess(len(vfs.all_entries()), 60)
finally:
vfs.close()
if __name__ == "__main__":
unittest.main()