Files
svfs/tests/test_studio_ui.py
T

1348 lines
56 KiB
Python
Raw Normal View History

"""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()