"""打包进度回调(pack_folder 的 progress 参数)。 约定: * 每处理一个条目(目录也算)回调一次,``done`` 从 1 递增到 ``total``; * ``total`` 是完整遍历一遍得到的条目数,为了拿到它多做一次"只 stat"的预扫描, 因此**只有传了回调时**才算,不传回调的路径(CLI)开销一点不变; * 回调抛出的异常会一路向上抛——GUI 的中断按钮就靠这个语义。 """ import unittest import helpers from sqlite_vfs.core import SQLiteVFS from sqlite_vfs.folder_packer import FolderPacker class PackProgressTest(helpers.VFSTestCase): def setUp(self): super().setUp() self.src = helpers.make_tree( self.path("src"), { "a.txt": b"aaa", "b.txt": b"bbb", "sub/c.txt": b"ccc", "sub/deep/d.txt": b"ddd", "empty_dir/.keep": b"", }, ) # 条目数 = 5 个文件(含 empty_dir/.keep)+ 3 个目录(sub / sub/deep / empty_dir) self.expected_entries = 8 def collect(self, incremental=False): calls = [] def progress(done, total, rel_path): calls.append((done, total, rel_path)) vfs = SQLiteVFS(self.path("prog.svfs"), compress=True) try: packer = FolderPacker(vfs, fs_name="prog") with helpers.quiet(): ok = packer.pack_folder(self.src, incremental=incremental, progress=progress) finally: vfs.close() return ok, calls def test_full_progress_is_one_call_per_entry(self): ok, calls = self.collect() self.assertTrue(ok) self.assertEqual(len(calls), self.expected_entries) self.assertEqual([done for done, _total, _path in calls], list(range(1, len(calls) + 1))) def test_total_matches_entry_count(self): _ok, calls = self.collect() totals = {total for _done, total, _path in calls} self.assertEqual(totals, {self.expected_entries}) def test_progress_paths_are_relative_and_normalized(self): _ok, calls = self.collect() paths = [path.replace("\\", "/") for _done, _total, path in calls] self.assertEqual(sorted(paths), sorted(["a.txt", "b.txt", "sub", "sub/c.txt", "sub/deep", "sub/deep/d.txt", "empty_dir", "empty_dir/.keep"])) def test_incremental_progress_counts_all_entries(self): ok, calls = self.collect(incremental=True) self.assertTrue(ok) self.assertEqual(len(calls), self.expected_entries) self.assertEqual({total for _done, total, _path in calls}, {self.expected_entries}) def test_result_is_same_with_and_without_progress(self): """带回调不该改变打包结果:条目集合必须一模一样。""" db_with = self.path("with.svfs") db_without = self.path("without.svfs") vfs = SQLiteVFS(db_with, compress=True) try: with helpers.quiet(): FolderPacker(vfs, fs_name="prog").pack_folder( self.src, progress=lambda *_args: None ) finally: vfs.close() helpers.pack(self.src, db_without, compress=True) def snapshot(db_path): vfs = SQLiteVFS(db_path, readonly=True) try: return [ (row["path"], row["is_directory"], row["file_size"], row["content_hash"]) for row in vfs.all_entries() ], sorted(row["hash"] for row in vfs.list_blobs()) finally: vfs.close() self.assertEqual(snapshot(db_with), snapshot(db_without)) def test_no_precount_when_progress_is_absent(self): """不传回调就不该多做那次预扫描(CLI 的开销必须保持原样)。""" vfs = SQLiteVFS(self.path("nopre.svfs"), compress=True) try: packer = FolderPacker(vfs, fs_name="prog") original = packer._count_entries called = [] def spy(source_folder): called.append(source_folder) return original(source_folder) packer._count_entries = spy with helpers.quiet(): self.assertTrue(packer.pack_folder(self.src)) self.assertEqual(called, []) finally: vfs.close() def test_exception_from_callback_propagates(self): """回调抛异常要能中断打包——GUI 的"中断"就是靠这个语义实现的。""" class Stop(Exception): pass seen = [] def progress(done, total, rel_path): seen.append(rel_path) if done >= 2: raise Stop("用户中断") vfs = SQLiteVFS(self.path("cancel.svfs"), compress=True) try: packer = FolderPacker(vfs, fs_name="prog") with helpers.quiet(): with self.assertRaises(Stop): packer.pack_folder(self.src, progress=progress) self.assertEqual(len(seen), 2) finally: vfs.close() if __name__ == "__main__": unittest.main()