1348 lines
56 KiB
Python
1348 lines
56 KiB
Python
"""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()
|